From f182b2b82ad31bdaeed08db1a65ffa7ccd1de128 Mon Sep 17 00:00:00 2001 From: Garfie Date: Mon, 9 Mar 2026 22:00:00 -0500 Subject: [PATCH] Initial release --- .github/CLAUDE.md | 31 + .github/workflows/android.yml | 50 + .gitignore | 26 + CLAUDE.md | 50 + DISCOVERY.md | 292 ++++ KNOWN_ISSUES.md | 38 + LICENSE | 21 + README.md | 67 + app/CLAUDE.md | 71 + app/build.gradle.kts | 42 + app/proguard-rules.pro | 17 + .../com/librechat/android/AuthFlowTest.kt | 49 + app/src/main/AndroidManifest.xml | 89 ++ .../librechat/android/LibreChatApplication.kt | 86 ++ .../com/librechat/android/MainActivity.kt | 192 +++ .../android/navigation/DrawerContent.kt | 520 +++++++ .../android/navigation/LibreChatNavHost.kt | 428 ++++++ .../android/navigation/NavHostViewModel.kt | 449 ++++++ .../navigation/SettingsSidebarContent.kt | 151 ++ .../android/navigation/SidebarMode.kt | 17 + .../android/navigation/SidebarScaffold.kt | 75 + .../android/navigation/TabletLayout.kt | 442 ++++++ .../android/navigation/TopLevelDestination.kt | 28 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 6444 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 11566 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 6444 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3652 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 6665 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3652 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 9243 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 17147 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 9243 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 16248 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 29323 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 16248 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10925 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 44651 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 10925 bytes app/src/main/res/values-night/themes.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 31 + app/src/main/res/values/themes.xml | 10 + app/src/main/res/xml/file_provider_paths.xml | 6 + .../main/res/xml/network_security_config.xml | 18 + build-logic/CLAUDE.md | 37 + build-logic/convention/build.gradle.kts | 44 + build-logic/convention/gradle.properties | 3 + .../AndroidApplicationConventionPlugin.kt | 46 + .../kotlin/AndroidComposeConventionPlugin.kt | 59 + .../kotlin/AndroidFeatureConventionPlugin.kt | 27 + .../kotlin/AndroidHiltConventionPlugin.kt | 21 + .../kotlin/AndroidLibraryConventionPlugin.kt | 36 + .../kotlin/AndroidRoomConventionPlugin.kt | 27 + .../KotlinSerializationConventionPlugin.kt | 19 + build-logic/gradle.properties | 2 + build-logic/settings.gradle.kts | 15 + build.gradle.kts | 10 + compose-stability.conf | 44 + core/common/CLAUDE.md | 41 + core/common/build.gradle.kts | 13 + .../android/core/common/BackendVersion.kt | 75 + .../android/core/common/Constants.kt | 17 + .../core/common/di/CoroutineScopeModule.kt | 26 + .../core/common/di/DispatcherModule.kt | 35 + .../android/core/common/extensions/DateExt.kt | 26 + .../android/core/common/extensions/FlowExt.kt | 31 + .../core/common/extensions/StringExt.kt | 16 + .../common/network/ConnectivityObserver.kt | 62 + .../android/core/common/result/Result.kt | 25 + .../core/common/result/SafeApiCallTest.kt | 86 ++ .../core/common/test/TestDispatcherRule.kt | 23 + core/data/CLAUDE.md | 74 + core/data/build.gradle.kts | 22 + .../1.json | 643 ++++++++ .../2.json | 675 ++++++++ .../3.json | 668 ++++++++ .../data/datastore/ConfigCacheDataStore.kt | 99 ++ .../core/data/datastore/ServerDataStore.kt | 54 + .../core/data/datastore/SettingsDataStore.kt | 390 +++++ .../core/data/datastore/ThemeDataStore.kt | 42 + .../core/data/datastore/TokenDataStore.kt | 148 ++ .../android/core/data/db/LibreChatDatabase.kt | 49 + .../core/data/db/converter/Converters.kt | 16 + .../android/core/data/db/dao/AgentDao.kt | 25 + .../core/data/db/dao/ConversationDao.kt | 43 + .../core/data/db/dao/ConversationTagDao.kt | 22 + .../android/core/data/db/dao/DraftDao.kt | 22 + .../android/core/data/db/dao/FileDao.kt | 25 + .../android/core/data/db/dao/MessageDao.kt | 43 + .../android/core/data/db/dao/PresetDao.kt | 22 + .../core/data/db/entity/AgentEntity.kt | 21 + .../core/data/db/entity/ConversationEntity.kt | 30 + .../data/db/entity/ConversationTagEntity.kt | 21 + .../core/data/db/entity/DraftEntity.kt | 16 + .../android/core/data/db/entity/FileEntity.kt | 30 + .../core/data/db/entity/MessageEntity.kt | 37 + .../core/data/db/entity/PresetEntity.kt | 18 + .../android/core/data/di/DataModule.kt | 173 +++ .../android/core/data/di/DatabaseModule.kt | 40 + .../core/data/mapper/ConversationMapper.kt | 64 + .../android/core/data/mapper/MessageMapper.kt | 84 + .../core/data/repository/AgentRepository.kt | 59 + .../data/repository/AgentRepositoryImpl.kt | 208 +++ .../core/data/repository/ApiKeyRepository.kt | 12 + .../data/repository/ApiKeyRepositoryImpl.kt | 31 + .../core/data/repository/AuthRepository.kt | 22 + .../data/repository/AuthRepositoryImpl.kt | 172 +++ .../core/data/repository/BalanceRepository.kt | 8 + .../data/repository/BalanceRepositoryImpl.kt | 18 + .../core/data/repository/BannerRepository.kt | 9 + .../data/repository/BannerRepositoryImpl.kt | 17 + .../data/repository/ChatPayloadBuilder.kt | 60 + .../core/data/repository/ChatRepository.kt | 32 + .../data/repository/ChatRepositoryImpl.kt | 96 ++ .../core/data/repository/ConfigRepository.kt | 38 + .../data/repository/ConfigRepositoryImpl.kt | 189 +++ .../data/repository/ConversationRepository.kt | 33 + .../repository/ConversationRepositoryImpl.kt | 170 ++ .../core/data/repository/DraftRepository.kt | 10 + .../data/repository/DraftRepositoryImpl.kt | 76 + .../core/data/repository/FileRepository.kt | 26 + .../data/repository/FileRepositoryImpl.kt | 72 + .../core/data/repository/KeyRepository.kt | 12 + .../core/data/repository/KeyRepositoryImpl.kt | 31 + .../core/data/repository/McpRepository.kt | 27 + .../core/data/repository/McpRepositoryImpl.kt | 45 + .../core/data/repository/MemoryRepository.kt | 17 + .../data/repository/MemoryRepositoryImpl.kt | 33 + .../core/data/repository/MessageRepository.kt | 14 + .../data/repository/MessageRepositoryImpl.kt | 98 ++ .../core/data/repository/PresetRepository.kt | 11 + .../data/repository/PresetRepositoryImpl.kt | 48 + .../core/data/repository/PromptRepository.kt | 23 + .../data/repository/PromptRepositoryImpl.kt | 80 + .../core/data/repository/SearchRepository.kt | 8 + .../data/repository/SearchRepositoryImpl.kt | 20 + .../core/data/repository/ShareRepository.kt | 15 + .../data/repository/ShareRepositoryImpl.kt | 58 + .../core/data/repository/SpeechRepository.kt | 13 + .../data/repository/SpeechRepositoryImpl.kt | 36 + .../core/data/repository/TagRepository.kt | 9 + .../core/data/repository/TagRepositoryImpl.kt | 24 + .../core/data/repository/UserRepository.kt | 18 + .../data/repository/UserRepositoryImpl.kt | 54 + core/model/CLAUDE.md | 30 + core/model/build.gradle.kts | 16 + .../com/librechat/android/core/model/Agent.kt | 52 + .../android/core/model/AgentAction.kt | 39 + .../android/core/model/AgentCategory.kt | 13 + .../android/core/model/AgentExpanded.kt | 40 + .../librechat/android/core/model/AgentTool.kt | 19 + .../librechat/android/core/model/ApiKey.kt | 22 + .../librechat/android/core/model/Balance.kt | 8 + .../librechat/android/core/model/Banner.kt | 18 + .../android/core/model/Conversation.kt | 39 + .../android/core/model/ConversationExport.kt | 11 + .../android/core/model/ConversationTag.kt | 18 + .../librechat/android/core/model/Endpoint.kt | 68 + .../android/core/model/EndpointConfig.kt | 18 + .../librechat/android/core/model/Feedback.kt | 13 + .../librechat/android/core/model/FileModel.kt | 52 + .../android/core/model/LoginOutcome.kt | 6 + .../librechat/android/core/model/Memory.kt | 19 + .../librechat/android/core/model/Message.kt | 36 + .../android/core/model/MessageContentPart.kt | 78 + .../android/core/model/PaginatedAgents.kt | 8 + .../android/core/model/ParameterDefinition.kt | 32 + .../librechat/android/core/model/Preset.kt | 30 + .../librechat/android/core/model/Prompt.kt | 40 + .../android/core/model/ServerConnection.kt | 7 + .../android/core/model/SharedLink.kt | 18 + .../android/core/model/SseContentEvent.kt | 33 + .../android/core/model/StartupConfig.kt | 133 ++ .../android/core/model/StreamEvent.kt | 72 + .../android/core/model/SupportContact.kt | 9 + .../android/core/model/ToolAuthStatus.kt | 13 + .../android/core/model/ToolCallRecord.kt | 20 + .../android/core/model/ToolCallResult.kt | 15 + .../com/librechat/android/core/model/User.kt | 31 + .../librechat/android/core/model/UserKey.kt | 10 + .../core/model/mcp/McpConnectionStatus.kt | 25 + .../android/core/model/mcp/McpOAuth.kt | 19 + .../android/core/model/mcp/McpRequests.kt | 28 + .../android/core/model/mcp/McpServer.kt | 70 + .../android/core/model/mcp/McpTool.kt | 15 + .../model/request/AddPromptToGroupRequest.kt | 9 + .../core/model/request/AddedConversation.kt | 17 + .../core/model/request/AgentActionRequest.kt | 27 + .../request/ArchiveConversationRequest.kt | 14 + .../model/request/BranchMessageRequest.kt | 10 + .../core/model/request/ChatAbortRequest.kt | 9 + .../android/core/model/request/ChatRequest.kt | 49 + .../core/model/request/ConvoDeleteBody.kt | 17 + .../core/model/request/ConvoUpdateBody.kt | 14 + .../core/model/request/CreateAgentRequest.kt | 27 + .../core/model/request/CreateApiKeyRequest.kt | 8 + .../core/model/request/CreateMemoryRequest.kt | 9 + .../core/model/request/CreatePromptRequest.kt | 20 + .../core/model/request/CreateShareRequest.kt | 8 + .../core/model/request/CreateTagRequest.kt | 16 + .../core/model/request/DeleteFilesRequest.kt | 18 + .../request/DuplicateConversationRequest.kt | 9 + .../core/model/request/EphemeralAgent.kt | 12 + .../core/model/request/FeedbackRequest.kt | 8 + .../model/request/ForkConversationRequest.kt | 24 + .../core/model/request/LoginRequest.kt | 9 + .../model/request/PasswordResetRequest.kt | 17 + .../core/model/request/PresetDeleteRequest.kt | 8 + .../core/model/request/RegisterRequest.kt | 13 + .../core/model/request/RevertAgentRequest.kt | 8 + .../core/model/request/SaveMessageRequest.kt | 22 + .../core/model/request/TagUpdateRequest.kt | 8 + .../core/model/request/ToolCallRequest.kt | 9 + .../model/request/TwoFactorDisableRequest.kt | 8 + .../model/request/TwoFactorVerifyRequest.kt | 25 + .../core/model/request/UpdateAgentRequest.kt | 27 + .../model/request/UpdateArtifactRequest.kt | 8 + .../core/model/request/UpdateKeyRequest.kt | 10 + .../request/UpdateMemoryPreferencesRequest.kt | 8 + .../core/model/request/UpdateMemoryRequest.kt | 8 + .../model/request/UpdateMessageRequest.kt | 8 + .../model/request/UpdatePromptGroupRequest.kt | 10 + .../model/request/UpdatePromptTagRequest.kt | 8 + .../core/model/request/VerifyEmailRequest.kt | 14 + .../core/model/response/ActiveJobsResponse.kt | 15 + .../core/model/response/AgentListResponse.kt | 15 + .../core/model/response/CategoriesResponse.kt | 9 + .../core/model/response/ChatAbortResponse.kt | 8 + .../core/model/response/ChatStartResponse.kt | 8 + .../core/model/response/ChatStatusResponse.kt | 9 + .../response/ConversationListResponse.kt | 10 + .../core/model/response/FileUploadConfig.kt | 12 + .../response/ForkConversationResponse.kt | 11 + .../model/response/GenerateTitleResponse.kt | 8 + .../core/model/response/LoginResponse.kt | 12 + .../model/response/MessageListResponse.kt | 9 + .../model/response/PromptGroupListResponse.kt | 16 + .../core/model/response/PromptListResponse.kt | 9 + .../core/model/response/RefreshResponse.kt | 10 + .../core/model/response/RegisterResponse.kt | 9 + .../core/model/response/SearchResponse.kt | 14 + .../model/response/ShareLinkCheckResponse.kt | 10 + .../model/response/SharedLinksResponse.kt | 12 + .../core/model/response/TermsResponse.kt | 9 + .../model/response/TwoFactorSetupResponse.kt | 10 + .../android/core/model/speech/SpeechModels.kt | 93 ++ core/network/CLAUDE.md | 46 + core/network/build.gradle.kts | 17 + .../android/core/network/api/AgentsApi.kt | 184 +++ .../android/core/network/api/ApiKeysApi.kt | 68 + .../android/core/network/api/AuthApi.kt | 170 ++ .../android/core/network/api/BalanceApi.kt | 17 + .../android/core/network/api/BannerApi.kt | 17 + .../android/core/network/api/ChatApi.kt | 41 + .../android/core/network/api/ConfigApi.kt | 49 + .../core/network/api/ConversationsApi.kt | 134 ++ .../android/core/network/api/FilesApi.kt | 109 ++ .../android/core/network/api/FilesExtApi.kt | 22 + .../android/core/network/api/KeysApi.kt | 42 + .../android/core/network/api/McpApi.kt | 224 +++ .../android/core/network/api/MemoriesApi.kt | 50 + .../android/core/network/api/MessagesApi.kt | 82 + .../android/core/network/api/PresetsApi.kt | 39 + .../android/core/network/api/PromptsApi.kt | 131 ++ .../android/core/network/api/SearchApi.kt | 16 + .../android/core/network/api/ShareApi.kt | 58 + .../android/core/network/api/SpeechApi.kt | 72 + .../android/core/network/api/TagsApi.kt | 49 + .../android/core/network/api/UserApi.kt | 112 ++ .../core/network/client/ApiException.kt | 11 + .../network/client/AuthInterceptorPlugin.kt | 87 ++ .../core/network/client/CookieHelper.kt | 22 + .../network/client/LibreChatHttpClient.kt | 156 ++ .../core/network/client/SecureTokenStorage.kt | 8 + .../core/network/client/ServerUrlProvider.kt | 5 + .../core/network/client/TokenManager.kt | 12 + .../android/core/network/di/NetworkModule.kt | 107 ++ .../android/core/network/di/RefreshClient.kt | 7 + .../core/network/di/StreamingClient.kt | 7 + .../android/core/network/sse/SseClient.kt | 110 ++ .../android/core/network/sse/SseEvent.kt | 6 + .../core/network/sse/SseEventMapper.kt | 425 +++++ .../android/core/network/sse/SseLineParser.kt | 82 + core/ui/CLAUDE.md | 106 ++ core/ui/build.gradle.kts | 14 + .../android/core/ui/components/AvatarImage.kt | 97 ++ .../core/ui/components/BannerDisplay.kt | 127 ++ .../core/ui/components/DynamicCheckbox.kt | 69 + .../core/ui/components/DynamicDropdown.kt | 94 ++ .../core/ui/components/DynamicInput.kt | 67 + .../ui/components/DynamicParameterPanel.kt | 173 +++ .../core/ui/components/DynamicSlider.kt | 93 ++ .../core/ui/components/DynamicTagsInput.kt | 121 ++ .../core/ui/components/DynamicTextarea.kt | 72 + .../android/core/ui/components/EmptyState.kt | 59 + .../core/ui/components/EndpointIcons.kt | 59 + .../components/EndpointParameterRegistry.kt | 521 +++++++ .../android/core/ui/components/ErrorBanner.kt | 64 + .../core/ui/components/LibreChatTopBar.kt | 55 + .../core/ui/components/LoadingIndicator.kt | 29 + .../core/ui/components/ModelParameterSheet.kt | 284 ++++ .../ui/components/ScreenTransitionWrapper.kt | 71 + .../core/ui/theme/AccessibilityUtils.kt | 27 + .../librechat/android/core/ui/theme/Color.kt | 45 + .../librechat/android/core/ui/theme/Shape.kt | 12 + .../librechat/android/core/ui/theme/Theme.kt | 78 + .../librechat/android/core/ui/theme/Type.kt | 52 + core/ui/src/main/res/drawable/ic_agents.xml | 28 + .../ui/src/main/res/drawable/ic_anthropic.xml | 16 + core/ui/src/main/res/drawable/ic_azure.xml | 16 + core/ui/src/main/res/drawable/ic_bedrock.xml | 10 + core/ui/src/main/res/drawable/ic_google.xml | 10 + core/ui/src/main/res/drawable/ic_openai.xml | 9 + core/ui/src/main/res/values/strings.xml | 34 + feature/agents/CLAUDE.md | 56 + feature/agents/build.gradle.kts | 15 + .../feature/agents/AgentDisplayData.kt | 52 + .../agents/components/AgentActionsPanel.kt | 868 +++++++++++ .../agents/components/AgentAdvancedPanel.kt | 176 +++ .../agents/components/AgentAvatarPicker.kt | 92 ++ .../components/AgentCapabilitiesSection.kt | 131 ++ .../components/AgentCategorySelector.kt | 99 ++ .../components/AgentCodeInterpreterSection.kt | 56 + .../components/AgentFileSearchSection.kt | 56 + .../agents/components/AgentHandoffConfig.kt | 209 +++ .../components/AgentMcpToolsSelector.kt | 171 +++ .../agents/components/AgentModelPicker.kt | 119 ++ .../agents/components/AgentSharingSection.kt | 148 ++ .../components/AgentSupportContactSection.kt | 69 + .../agents/components/AgentVersionHistory.kt | 125 ++ .../agents/components/ToolSelectDialog.kt | 293 ++++ .../agents/navigation/AgentsNavigation.kt | 68 + .../agents/screen/AgentDetailScreen.kt | 362 +++++ .../agents/screen/AgentEditorScreen.kt | 622 ++++++++ .../agents/screen/AgentMarketplaceScreen.kt | 283 ++++ .../feature/agents/util/OpenApiSpecParser.kt | 411 +++++ .../agents/viewmodel/AgentDetailViewModel.kt | 152 ++ .../agents/viewmodel/AgentEditorViewModel.kt | 960 ++++++++++++ .../viewmodel/AgentMarketplaceViewModel.kt | 208 +++ .../agents/src/main/res/values/strings.xml | 207 +++ .../AgentMarketplaceViewModelTest.kt | 282 ++++ feature/auth/CLAUDE.md | 49 + feature/auth/build.gradle.kts | 13 + .../feature/auth/navigation/AuthNavigation.kt | 124 ++ .../feature/auth/oauth/OAuthManager.kt | 51 + .../auth/screen/ForgotPasswordScreen.kt | 127 ++ .../feature/auth/screen/LoginScreen.kt | 185 +++ .../feature/auth/screen/RegisterScreen.kt | 152 ++ .../auth/screen/ResetPasswordScreen.kt | 161 ++ .../feature/auth/screen/ServerUrlScreen.kt | 140 ++ .../feature/auth/screen/TermsScreen.kt | 163 ++ .../feature/auth/screen/TwoFactorScreen.kt | 216 +++ .../feature/auth/screen/VerifyEmailScreen.kt | 166 ++ .../auth/viewmodel/ForgotPasswordViewModel.kt | 59 + .../feature/auth/viewmodel/LoginViewModel.kt | 137 ++ .../auth/viewmodel/RegisterViewModel.kt | 106 ++ .../auth/viewmodel/ResetPasswordViewModel.kt | 84 + .../auth/viewmodel/ServerUrlViewModel.kt | 116 ++ .../feature/auth/viewmodel/TermsViewModel.kt | 81 + .../auth/viewmodel/TwoFactorViewModel.kt | 94 ++ .../auth/viewmodel/VerifyEmailViewModel.kt | 105 ++ feature/auth/src/main/res/values/strings.xml | 85 + .../auth/viewmodel/LoginViewModelTest.kt | 242 +++ .../auth/viewmodel/RegisterViewModelTest.kt | 267 ++++ feature/chat/CLAUDE.md | 101 ++ feature/chat/build.gradle.kts | 20 + .../android/feature/chat/ChatDisplayData.kt | 26 + .../feature/chat/ShareIntentConsumer.kt | 64 + .../feature/chat/audio/VoiceRecorder.kt | 114 ++ .../chat/components/AgentHandoffCard.kt | 134 ++ .../chat/components/AttachmentChipsRow.kt | 341 ++++ .../feature/chat/components/AudioContent.kt | 90 ++ .../chat/components/AudioContentPlayer.kt | 211 +++ .../feature/chat/components/ChatInput.kt | 614 ++++++++ .../chat/components/ChatInputToolbar.kt | 147 ++ .../chat/components/ChatToolsBottomSheet.kt | 435 ++++++ .../feature/chat/components/CitationChip.kt | 296 ++++ .../feature/chat/components/CodeBlock.kt | 580 +++++++ .../chat/components/CodeExecutionCard.kt | 232 +++ .../chat/components/ComparisonDualPane.kt | 106 ++ .../chat/components/ComparisonTabBar.kt | 127 ++ .../chat/components/ContentPartRenderer.kt | 901 +++++++++++ .../chat/components/FeedbackCommentDialog.kt | 71 + .../chat/components/ForkOptionsBottomSheet.kt | 143 ++ .../chat/components/FullscreenImageViewer.kt | 319 ++++ .../feature/chat/components/ImageGenCard.kt | 171 +++ .../chat/components/InConvoSearchBar.kt | 163 ++ .../chat/components/InlineEditInput.kt | 92 ++ .../feature/chat/components/LandingContent.kt | 133 ++ .../feature/chat/components/LatexBlock.kt | 341 ++++ .../feature/chat/components/LogContentCard.kt | 122 ++ .../chat/components/MarkdownContent.kt | 1038 +++++++++++++ .../chat/components/McpResourceCarousel.kt | 133 ++ .../chat/components/McpServerSelector.kt | 172 +++ .../chat/components/MemoryArtifactCard.kt | 116 ++ .../feature/chat/components/MermaidDiagram.kt | 304 ++++ .../feature/chat/components/MessageBubble.kt | 851 ++++++++++ .../feature/chat/components/MessageFiles.kt | 242 +++ .../feature/chat/components/MessageList.kt | 478 ++++++ .../chat/components/MessageTimestamp.kt | 94 ++ .../chat/components/ModelSelectorButton.kt | 46 + .../chat/components/ModelSelectorSheet.kt | 400 +++++ .../feature/chat/components/PresetPicker.kt | 161 ++ .../chat/components/SavePresetDialog.kt | 74 + .../chat/components/SearchHighlight.kt | 110 ++ .../chat/components/SecondaryMessageList.kt | 144 ++ .../chat/components/SiblingNavigator.kt | 80 + .../chat/components/SlashCommandMenu.kt | 55 + .../chat/components/StreamingIndicator.kt | 57 + .../chat/components/StreamingMessageBubble.kt | 245 +++ .../chat/components/StreamingToolCallCard.kt | 126 ++ .../feature/chat/components/TempChatToggle.kt | 52 + .../chat/components/ToolsDropdownMenu.kt | 89 ++ .../feature/chat/components/VideoContent.kt | 94 ++ .../chat/components/VideoContentPlayer.kt | 116 ++ .../chat/components/WebSearchResultCard.kt | 185 +++ .../components/artifact/ArtifactButton.kt | 130 ++ .../components/artifact/ArtifactDetector.kt | 119 ++ .../artifact/ArtifactDownloadHelper.kt | 144 ++ .../chat/components/artifact/ArtifactPanel.kt | 639 ++++++++ .../components/artifact/ArtifactVersionNav.kt | 74 + .../components/artifact/MarkdownWebContent.kt | 128 ++ .../components/artifact/MermaidWebContent.kt | 120 ++ .../feature/chat/navigation/ChatNavigation.kt | 136 ++ .../chat/prompts/PromptDetailScreen.kt | 168 ++ .../feature/chat/prompts/PromptDisplayData.kt | 34 + .../chat/prompts/PromptEditorScreen.kt | 214 +++ .../chat/prompts/PromptEditorViewModel.kt | 243 +++ .../chat/prompts/PromptVariablesSection.kt | 104 ++ .../chat/prompts/PromptVersionsSheet.kt | 150 ++ .../chat/prompts/PromptsLibraryScreen.kt | 361 +++++ .../feature/chat/prompts/PromptsViewModel.kt | 380 +++++ .../prompts/components/PromptFilterSheet.kt | 116 ++ .../prompts/components/PromptPreviewPanel.kt | 50 + .../prompts/components/PromptShareDialog.kt | 140 ++ .../prompts/components/VariableInputDialog.kt | 134 ++ .../android/feature/chat/screen/ChatScreen.kt | 1119 ++++++++++++++ .../feature/chat/screen/NewChatScreen.kt | 25 + .../android/feature/chat/util/FileUtils.kt | 263 ++++ .../android/feature/chat/util/MessageTree.kt | 100 ++ .../feature/chat/viewmodel/ChatStateHandle.kt | 19 + .../feature/chat/viewmodel/ChatUiState.kt | 151 ++ .../feature/chat/viewmodel/ChatViewModel.kt | 1366 +++++++++++++++++ .../feature/chat/viewmodel/ComparisonState.kt | 26 + .../delegate/ConversationActionsDelegate.kt | 177 +++ .../delegate/FileAttachmentDelegate.kt | 320 ++++ .../delegate/InConversationSearchDelegate.kt | 113 ++ .../delegate/ModelSelectionDelegate.kt | 353 +++++ .../delegate/PresetPromptDelegate.kt | 161 ++ .../delegate/TextToSpeechDelegate.kt | 212 +++ .../viewmodel/delegate/VoiceInputDelegate.kt | 118 ++ feature/chat/src/main/res/values/strings.xml | 325 ++++ .../chat/components/MarkdownSegmentsTest.kt | 153 ++ .../artifact/ArtifactDetectorTest.kt | 353 +++++ .../feature/chat/util/MessageTreeTest.kt | 116 ++ .../chat/viewmodel/DetectMimeTypeTest.kt | 147 ++ feature/conversations/CLAUDE.md | 46 + feature/conversations/build.gradle.kts | 13 + .../ArchivedConversationDisplayData.kt | 21 + .../components/ConversationActions.kt | 283 ++++ .../components/ConversationItem.kt | 210 +++ .../components/ConversationSearchBar.kt | 74 + .../components/DateGroupHeader.kt | 23 + .../conversations/components/TagFilterBar.kt | 113 ++ .../conversations/components/TagPicker.kt | 132 ++ .../export/ConversationExporter.kt | 83 + .../export/ConversationImporter.kt | 28 + .../export/ExportFormatPicker.kt | 110 ++ .../navigation/ConversationsNavigation.kt | 32 + .../screen/ArchivedConversationsScreen.kt | 223 +++ .../screen/ConversationListScreen.kt | 435 ++++++ .../ArchivedConversationsViewModel.kt | 137 ++ .../viewmodel/ConversationListViewModel.kt | 452 ++++++ .../src/main/res/values/strings.xml | 79 + .../ConversationListViewModelTest.kt | 448 ++++++ feature/files/CLAUDE.md | 47 + feature/files/build.gradle.kts | 13 + .../android/feature/files/FileDisplayData.kt | 26 + .../files/components/FileTypeFilterBar.kt | 36 + .../files/components/UploadProgressCard.kt | 106 ++ .../files/navigation/FilesNavigation.kt | 16 + .../feature/files/screen/FilePreviewDialog.kt | 726 +++++++++ .../feature/files/screen/FileSortMenu.kt | 96 ++ .../feature/files/screen/FilesScreen.kt | 602 ++++++++ .../feature/files/viewmodel/FilesViewModel.kt | 524 +++++++ feature/files/src/main/res/values/strings.xml | 67 + .../files/viewmodel/FilesViewModelTest.kt | 386 +++++ feature/settings/CLAUDE.md | 63 + feature/settings/build.gradle.kts | 12 + .../settings/PresetManagerDisplayData.kt | 11 + .../feature/settings/SettingsDisplayData.kt | 19 + .../settings/navigation/McpNavigation.kt | 20 + .../settings/navigation/MemoriesNavigation.kt | 20 + .../settings/navigation/SettingsNavigation.kt | 122 ++ .../settings/screen/AccountSettingsScreen.kt | 688 +++++++++ .../settings/screen/ApiKeyCreateDialog.kt | 183 +++ .../feature/settings/screen/ApiKeysScreen.kt | 316 ++++ .../settings/screen/AvatarUploadDialog.kt | 147 ++ .../feature/settings/screen/BalanceSection.kt | 100 ++ .../settings/screen/ChatSettingsScreen.kt | 351 +++++ .../settings/screen/ChatSettingsSection.kt | 330 ++++ .../settings/screen/CommandsConfigScreen.kt | 109 ++ .../settings/screen/DataSettingsScreen.kt | 320 ++++ .../settings/screen/DataSettingsSection.kt | 147 ++ .../settings/screen/ForkSettingsDialog.kt | 112 ++ .../settings/screen/GeneralSettingsScreen.kt | 356 +++++ .../settings/screen/LanguageSelectorDialog.kt | 150 ++ .../settings/screen/McpServerDialog.kt | 357 +++++ .../screen/McpServerStatusIndicator.kt | 30 + .../settings/screen/McpServersScreen.kt | 363 +++++ .../settings/screen/McpSettingsSection.kt | 202 +++ .../feature/settings/screen/McpToolList.kt | 123 ++ .../feature/settings/screen/McpToolsSheet.kt | 151 ++ .../feature/settings/screen/MemoriesScreen.kt | 313 ++++ .../screen/MemoriesSettingsSection.kt | 271 ++++ .../settings/screen/MemoryEditDialog.kt | 75 + .../settings/screen/PersonalizationDialog.kt | 110 ++ .../settings/screen/PresetManagerScreen.kt | 209 +++ .../settings/screen/SecuritySection.kt | 103 ++ .../feature/settings/screen/SettingsScreen.kt | 630 ++++++++ .../settings/screen/SharedLinksScreen.kt | 305 ++++ .../settings/screen/SpeechDetailDialogs.kt | 513 +++++++ .../settings/screen/SpeechSettingsSection.kt | 199 +++ .../settings/screen/TabbedSettingsScreen.kt | 123 ++ .../screen/sections/AccountSection.kt | 96 ++ .../screen/sections/AppearanceSection.kt | 109 ++ .../screen/sections/ChatPreferencesSection.kt | 151 ++ .../screen/sections/DataManagementSection.kt | 79 + .../screen/sections/SecuritySection.kt | 244 +++ .../settings/screen/sections/SpeechSection.kt | 69 + .../settings/viewmodel/ApiKeysViewModel.kt | 146 ++ .../settings/viewmodel/McpViewModel.kt | 224 +++ .../settings/viewmodel/MemoriesViewModel.kt | 146 ++ .../viewmodel/PresetManagerViewModel.kt | 107 ++ .../settings/viewmodel/SettingsStateHandle.kt | 24 + .../settings/viewmodel/SettingsViewModel.kt | 685 +++++++++ .../viewmodel/delegate/ApiKeysDelegate.kt | 14 + .../delegate/DataManagementDelegate.kt | 188 +++ .../viewmodel/delegate/McpServerDelegate.kt | 130 ++ .../delegate/MemoryManagementDelegate.kt | 100 ++ .../delegate/SpeechSettingsDelegate.kt | 268 ++++ .../delegate/TwoFactorSecurityDelegate.kt | 139 ++ .../settings/src/main/res/values/strings.xml | 358 +++++ .../viewmodel/SettingsViewModelTest.kt | 366 +++++ gradle.properties | 6 + gradle/libs.versions.toml | 204 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++ gradlew.bat | 93 ++ settings.gradle.kts | 32 + 553 files changed, 64717 insertions(+) create mode 100644 .github/CLAUDE.md create mode 100644 .github/workflows/android.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 DISCOVERY.md create mode 100644 KNOWN_ISSUES.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/CLAUDE.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/kotlin/com/librechat/android/AuthFlowTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt create mode 100644 app/src/main/kotlin/com/librechat/android/MainActivity.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/DrawerContent.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/SettingsSidebarContent.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/SidebarMode.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/SidebarScaffold.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt create mode 100644 app/src/main/kotlin/com/librechat/android/navigation/TopLevelDestination.kt create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_provider_paths.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build-logic/CLAUDE.md create mode 100644 build-logic/convention/build.gradle.kts create mode 100644 build-logic/convention/gradle.properties create mode 100644 build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/KotlinSerializationConventionPlugin.kt create mode 100644 build-logic/gradle.properties create mode 100644 build-logic/settings.gradle.kts create mode 100644 build.gradle.kts create mode 100644 compose-stability.conf create mode 100644 core/common/CLAUDE.md create mode 100644 core/common/build.gradle.kts create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/di/CoroutineScopeModule.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/di/DispatcherModule.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/extensions/DateExt.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/extensions/StringExt.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/result/Result.kt create mode 100644 core/common/src/test/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt create mode 100644 core/common/src/test/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt create mode 100644 core/data/CLAUDE.md create mode 100644 core/data/build.gradle.kts create mode 100644 core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/1.json create mode 100644 core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/2.json create mode 100644 core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/3.json create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/converter/Converters.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/di/DataModule.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/di/DatabaseModule.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt create mode 100644 core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt create mode 100644 core/model/CLAUDE.md create mode 100644 core/model/build.gradle.kts create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/AgentAction.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/AgentCategory.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/AgentExpanded.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/AgentTool.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ApiKey.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Balance.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Banner.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ConversationExport.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ConversationTag.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Endpoint.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/EndpointConfig.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Feedback.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/FileModel.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/LoginOutcome.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Memory.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Message.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/MessageContentPart.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/PaginatedAgents.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ParameterDefinition.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/Prompt.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ServerConnection.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/SharedLink.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/SseContentEvent.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/SupportContact.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallRecord.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallResult.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/User.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/UserKey.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpServer.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpTool.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/AddedConversation.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/LoginRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/CategoriesResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/LoginResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/SearchResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/TermsResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt create mode 100644 core/network/CLAUDE.md create mode 100644 core/network/build.gradle.kts create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/AgentsApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/BalanceApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/BannerApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/ChatApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/ConfigApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/KeysApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/McpApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/MessagesApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/PresetsApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/PromptsApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/SearchApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/ShareApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/SpeechApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/TagsApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/client/CookieHelper.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/client/TokenManager.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/di/NetworkModule.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/di/RefreshClient.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/di/StreamingClient.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEvent.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt create mode 100644 core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt create mode 100644 core/ui/CLAUDE.md create mode 100644 core/ui/build.gradle.kts create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EmptyState.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Color.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Shape.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Theme.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Type.kt create mode 100644 core/ui/src/main/res/drawable/ic_agents.xml create mode 100644 core/ui/src/main/res/drawable/ic_anthropic.xml create mode 100644 core/ui/src/main/res/drawable/ic_azure.xml create mode 100644 core/ui/src/main/res/drawable/ic_bedrock.xml create mode 100644 core/ui/src/main/res/drawable/ic_google.xml create mode 100644 core/ui/src/main/res/drawable/ic_openai.xml create mode 100644 core/ui/src/main/res/values/strings.xml create mode 100644 feature/agents/CLAUDE.md create mode 100644 feature/agents/build.gradle.kts create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt create mode 100644 feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt create mode 100644 feature/agents/src/main/res/values/strings.xml create mode 100644 feature/agents/src/test/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt create mode 100644 feature/auth/CLAUDE.md create mode 100644 feature/auth/build.gradle.kts create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/oauth/OAuthManager.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt create mode 100644 feature/auth/src/main/res/values/strings.xml create mode 100644 feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt create mode 100644 feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt create mode 100644 feature/chat/CLAUDE.md create mode 100644 feature/chat/build.gradle.kts create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ChatDisplayData.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AgentHandoffCard.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AttachmentChipsRow.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInputToolbar.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatToolsBottomSheet.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CitationChip.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeBlock.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeExecutionCard.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonDualPane.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonTabBar.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FeedbackCommentDialog.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ForkOptionsBottomSheet.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ImageGenCard.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InConvoSearchBar.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InlineEditInput.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LandingContent.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LogContentCard.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpResourceCarousel.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpServerSelector.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MemoryArtifactCard.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageBubble.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageFiles.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageList.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageTimestamp.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorButton.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorSheet.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/PresetPicker.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SavePresetDialog.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SearchHighlight.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SecondaryMessageList.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SiblingNavigator.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SlashCommandMenu.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingIndicator.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingMessageBubble.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingToolCallCard.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/TempChatToggle.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ToolsDropdownMenu.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContent.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContentPlayer.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/WebSearchResultCard.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactButton.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetector.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDownloadHelper.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactPanel.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactVersionNav.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MarkdownWebContent.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MermaidWebContent.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/navigation/ChatNavigation.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDetailScreen.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDisplayData.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorScreen.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorViewModel.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVariablesSection.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVersionsSheet.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsLibraryScreen.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsViewModel.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptFilterSheet.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptPreviewPanel.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptShareDialog.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/VariableInputDialog.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/ChatScreen.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/NewChatScreen.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/FileUtils.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/MessageTree.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatStateHandle.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ComparisonState.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ConversationActionsDelegate.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/FileAttachmentDelegate.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/InConversationSearchDelegate.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/TextToSpeechDelegate.kt create mode 100644 feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/VoiceInputDelegate.kt create mode 100644 feature/chat/src/main/res/values/strings.xml create mode 100644 feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/MarkdownSegmentsTest.kt create mode 100644 feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetectorTest.kt create mode 100644 feature/chat/src/test/kotlin/com/librechat/android/feature/chat/util/MessageTreeTest.kt create mode 100644 feature/chat/src/test/kotlin/com/librechat/android/feature/chat/viewmodel/DetectMimeTypeTest.kt create mode 100644 feature/conversations/CLAUDE.md create mode 100644 feature/conversations/build.gradle.kts create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt create mode 100644 feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt create mode 100644 feature/conversations/src/main/res/values/strings.xml create mode 100644 feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt create mode 100644 feature/files/CLAUDE.md create mode 100644 feature/files/build.gradle.kts create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/FileDisplayData.kt create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt create mode 100644 feature/files/src/main/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt create mode 100644 feature/files/src/main/res/values/strings.xml create mode 100644 feature/files/src/test/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt create mode 100644 feature/settings/CLAUDE.md create mode 100644 feature/settings/build.gradle.kts create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt create mode 100644 feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt create mode 100644 feature/settings/src/main/res/values/strings.xml create mode 100644 feature/settings/src/test/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md new file mode 100644 index 0000000..fd473e4 --- /dev/null +++ b/.github/CLAUDE.md @@ -0,0 +1,31 @@ +# GitHub CI/CD + +## Workflow: Android CI + +File: `.github/workflows/android.yml` + +### Triggers + +- Push to `main` or `develop` +- Pull requests targeting `main` + +### Pipeline Steps + +1. Checkout code +2. Set up JDK 17 (Temurin) +3. Setup Gradle with caching (`~/.gradle/caches` and `~/.gradle/wrapper`) +4. `./gradlew lint` -- Android lint checks +5. `./gradlew test` -- unit tests across all modules +6. `./gradlew assembleDebug` -- build debug APK +7. Upload `app/build/outputs/apk/debug/app-debug.apk` as artifact (named `debug-apk`) + +### Environment + +- Runs on `ubuntu-latest` +- Gradle cache key based on `**/*.gradle*` and `gradle-wrapper.properties` + +### Notes + +- No release signing configured yet (debug builds only) +- No instrumented/UI tests in CI (only unit tests) +- APK artifact is available for download from the Actions run summary diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..98ad0eb --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,50 @@ +name: Android CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Lint + run: ./gradlew lint + + - name: Unit Tests + run: ./gradlew test + + - name: Build Debug APK + run: ./gradlew assembleDebug + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6306c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +local.properties + +# Kotlin +.kotlin/ + +# Android +*.apk +*.aab +*.dex +*.class + +# Keystore +*.jks +*.keystore + +# OS +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5f0c82d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# LibreChat Android + +Native Android client for LibreChat. Connects to existing LibreChat backend servers (no backend changes). Users specify server URL during onboarding. + +## Tech Stack + +- **UI**: Jetpack Compose + Compose Navigation +- **DI**: Hilt +- **Network**: Ktor Client (OkHttp engine) +- **Serialization**: Kotlinx Serialization +- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens) +- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26 + +## Module Layout + +``` +app/ → Single Activity, adaptive navigation (phone/tablet) +build-logic/ → 7 convention plugins for consistent Gradle config +core/common/ → Result type, dispatcher DI, coroutine scopes, extensions +core/model/ → @Serializable data classes (pure Kotlin, no Android deps) +core/network/ → Ktor client, 16 API services, SSE client, auth interceptor +core/data/ → Room DB, DataStore, EncryptedSharedPrefs, repository impls +core/ui/ → Material 3 theme, shared composables +feature/auth/ → Server URL, Login, Register, 2FA, OAuth, Forgot Password +feature/chat/ → Real-time chat with SSE streaming, message rendering +feature/conversations/→ Paginated list, CRUD, tags, search, export/import +feature/settings/ → Account, appearance, about, danger zone +feature/agents/ → Agent marketplace with search and categories +feature/files/ → File upload, management, image viewer +``` + +Each module has its own `CLAUDE.md` with specific guidance. + +## Architecture Rules + +- Feature modules depend on `:core:*` only, never on each other +- Single Activity with Compose Navigation +- Unidirectional data flow: UI → ViewModel → Repository → API/Room +- Room is a read-through cache; server is source of truth +- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin) + +## Backend Quirks + +- Mutation endpoints wrap body in `arg` field: `{ "arg": { ... } }` +- Two-phase SSE: POST → `{ streamId }`, then GET stream. `streamId === conversationId` +- `GET /api/config` drives feature availability — never hardcode +- ua-parser-js middleware rejects non-browser User-Agents with 403 (workaround: Chrome UA string) +- Refresh token sent via request body (not HTTP-only cookies) + +@~/.claude/librechat-android-local.md diff --git a/DISCOVERY.md b/DISCOVERY.md new file mode 100644 index 0000000..e449418 --- /dev/null +++ b/DISCOVERY.md @@ -0,0 +1,292 @@ +# LibreChat Android - Discovery & Context Document + +## Project Goal +Build a native Android app (LibreChat-Android) with full feature parity to the LibreChat web application. The app must connect to an existing LibreChat backend server (no backend changes). Users specify the server URL during onboarding. + +## Tech Stack (Mandated) +- **UI**: Jetpack Compose + Compose Navigation +- **DI**: Hilt +- **Network**: Ktor Client +- **Serialization**: Kotlinx Serialization +- **Min SDK**: TBD (recommend 26+) + +--- + +## LibreChat Web Application Overview + +### Architecture +- Monorepo: `/api` (Express/Node backend), `/client` (React/Vite frontend), `/packages` (shared libs) +- MongoDB database, optional Redis, optional Meilisearch +- JWT-based auth with refresh tokens + +### Core Features (Full Parity Required) + +#### 1. Authentication & Onboarding +- **Server URL entry** (Android-only: user specifies their LibreChat server) +- Local login (email/password) +- OAuth2 social logins (Google, GitHub, Discord, Facebook, Apple, OpenID, SAML) +- Registration with email verification +- Password reset flow +- Two-factor authentication (TOTP + backup codes) +- Terms of service acceptance + +#### 2. Chat (Primary Experience) +- Conversation list in sidebar (cursor-paginated, sorted by updatedAt) +- Create new conversations +- Send messages, receive streaming AI responses via SSE +- Message tree with parent/child relationships and branching +- Sibling message navigation (e.g., "1/3" switcher for regenerated responses) +- Edit user messages (creates new branch) +- Regenerate AI responses +- Continue incomplete responses +- Stop generation mid-stream +- Fork conversations from any message +- Duplicate conversations +- Markdown rendering with syntax highlighting +- LaTeX/math rendering +- Code blocks with copy button and language badge +- Image display (inline + expandable) +- File attachments in messages +- Tool call display (expandable cards showing input/output) +- Message feedback (thumbs up/down with optional comment) +- Typing/streaming indicator +- Time-based greeting on landing ("Good morning", etc.) +- Custom welcome messages from server config + +#### 3. Model/Endpoint Selection +- Multiple AI providers: OpenAI, Anthropic, Azure, Google, Groq, Mistral, Ollama, custom +- Model selector dropdown with search +- Endpoint icons (branded) +- Model parameters (temperature, top_p, frequency_penalty, etc.) +- Model specs from server config + +#### 4. Agents & Assistants +- Agent marketplace (grid of cards with avatar, name, description, category) +- Agent chat with tool calling +- OpenAI Assistants integration +- Agent/assistant selection UI + +#### 5. File Management +- Upload files (images, PDFs, documents, code) +- Drag-and-drop (not applicable on Android, use file picker) +- File preview in messages +- File download +- Image generation display +- Audio recording for voice input (STT) +- Text-to-speech playback (TTS) + +#### 6. Conversation Management +- Rename conversations (inline) +- Archive/unarchive +- Delete (single + bulk) +- Share (generate public link) +- Export (JSON, markdown) +- Import conversations +- Tags for organization +- Bookmarks/favorites +- Search conversations (full-text via Meilisearch) + +#### 7. Presets +- Save chat configurations as presets +- Load presets +- Delete presets + +#### 8. Prompts Library +- Create/edit/delete prompts +- Share prompts +- Prompt versioning +- @mentions for prompts in chat input +- Prompt variables + +#### 9. User Settings +- Theme (dark/light) +- Language selection (46+ languages) +- Font size +- Speech settings +- Data export +- Account management (profile, delete account) +- Balance/credits display + +#### 10. Artifacts +- Split-pane code preview/editor +- Tabs: Preview, Code, Info +- Live editing + +--- + +## API Endpoints (Key Routes) + +### Authentication +``` +POST /api/auth/login → { token, user } or { twoFAPending, tempToken } +POST /api/auth/register → { status, message } +POST /api/auth/logout → { status } +POST /api/auth/refresh → { token, user } (refresh token in cookies) +POST /api/auth/requestPasswordReset +POST /api/auth/resetPassword +GET /api/auth/2fa/enable → { secret, qrCode } +POST /api/auth/2fa/verify +POST /api/auth/2fa/confirm → { confirmed, backupCodes } +POST /api/auth/2fa/disable +POST /api/auth/2fa/verify-temp → { token, user } +``` + +### Configuration +``` +GET /api/config → startup config (models, features, auth methods, interface config) +GET /api/endpoints → available AI providers and models +``` + +### Conversations +``` +GET /api/convos → { conversations, nextCursor } +GET /api/convos/:conversationId → single conversation +POST /api/convos/update → update title +POST /api/convos/archive → archive/unarchive +DELETE /api/convos → delete conversation(s) +POST /api/convos/fork → fork from message +POST /api/convos/duplicate → duplicate conversation +POST /api/convos/import → import (multipart) +GET /api/convos/gen_title/:conversationId → generated title +``` + +### Messages +``` +GET /api/messages/:conversationId → messages for conversation +GET /api/messages/:conversationId/:messageId +POST /api/messages/:conversationId → save message +PUT /api/messages/:conversationId/:messageId → update message +DELETE /api/messages/:conversationId/:messageId +PUT /api/messages/:conversationId/:messageId/feedback +``` + +### Chat (Streaming) +``` +POST /api/agents/chat → { streamId } (start generation) +GET /api/agents/chat/stream/:streamId?resume=true → SSE stream +POST /api/agents/chat/abort → abort generation +GET /api/agents/chat/active → { activeJobIds } +GET /api/agents/chat/status/:conversationId → job status +``` + +### Files +``` +GET /api/files → user's files +POST /api/files → upload (multipart) +GET /api/files/download/:userId/:file_id +DELETE /api/files → delete file(s) +POST /api/files/speech/stt → speech-to-text +POST /api/files/speech/tts → text-to-speech +``` + +### Other +``` +GET/POST/DELETE /api/presets +GET/POST/PUT/DELETE /api/prompts +GET/POST/DELETE /api/tags +GET/POST/PATCH/DELETE /api/share +GET /api/balance +GET /api/search +GET /api/user +GET /api/banner +``` + +--- + +## Data Models + +### User +- id, name, email, username, avatar, role, provider +- emailVerified, twoFactorEnabled +- favorites, termsAccepted + +### Conversation +- conversationId (UUID), title, user, endpoint, model +- agent_id, assistant_id, tags, isArchived +- Model parameters (temperature, top_p, etc.) + +### Message +- messageId (UUID), conversationId, parentMessageId +- user, sender, text, content (structured parts) +- isCreatedByUser, model, endpoint +- files, attachments, feedback +- error, unfinished, finish_reason, tokenCount + +### File +- file_id, filename, filepath, type, bytes, source +- user, conversationId, messageId + +--- + +## SSE Streaming Protocol + +### Flow +1. POST to `/api/agents/chat` with message payload → returns `{ streamId }` +2. Connect SSE to `GET /api/agents/chat/stream/:streamId` +3. Receive events: message, step, created, attachment, final, sync, error +4. On disconnect: reconnect with `?resume=true` for sync event + +### SSE Event Format +``` +event: message +data: {"type":"content","chunk":"...","status":"streaming"} + +event: message +data: {"type":"tool_call","toolName":"...","input":{...}} + +event: message +data: {"sync":true,"resumeState":{"runSteps":[...],"aggregatedContent":[...]}} +``` + +### Reconnection +- Exponential backoff: 1s, 2s, 4s, 8s... max 30s +- Max 5 retries +- Resume preserves state via sync event + +--- + +## Authentication Details + +### Token Management +- JWT access token in `Authorization: Bearer ` header +- Refresh token stored as HTTP-only cookie (for web; Android should store securely) +- Access token expiry: ~15 minutes +- Refresh token expiry: ~24 hours +- Auto-refresh on 401 response + +### OAuth Flow (Android) +- Open browser/Custom Chrome Tab for OAuth provider +- Callback redirect to app via deep link +- Exchange code for token + +--- + +## UI/UX Reference (from Web App) + +### Theme +- Light: White bg (#fff), text #212121, surface hover #e3e3e3 +- Dark: #0d0d0d bg, text #ececf1, surface hover #424242 +- Material 3 equivalents should be used on Android + +### Key Screens +1. **Server URL Entry** (Android-only onboarding) +2. **Login/Register** with social login buttons +3. **Chat List** (sidebar on web → drawer or dedicated screen on Android) +4. **Chat View** (messages + input) +5. **Landing/New Chat** (greeting + model icon) +6. **Model Selector** (dropdown → bottom sheet on Android) +7. **Settings** (tabbed → Material 3 navigation) +8. **Agent Marketplace** (grid of cards) +9. **Search** (full-text search) +10. **File Viewer/Picker** + +### Navigation Patterns (Android Adaptation) +- Web sidebar → Navigation drawer or bottom navigation +- Web modals → Bottom sheets or new screens +- Web dropdowns → Material 3 menus or bottom sheets +- Web hover actions → Long-press menus or always-visible icons + +### Responsive Behavior +- Single-pane on phones (chat list or chat view, not both) +- Potential dual-pane on tablets +- Bottom navigation for primary actions diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md new file mode 100644 index 0000000..0c8762b --- /dev/null +++ b/KNOWN_ISSUES.md @@ -0,0 +1,38 @@ +# Known Issues + +## Tablet back navigation doesn't dismiss sidebar first + +**Status**: Known issue, to be fixed later + +**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen. + +**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost. + +**Workaround**: Tap the hamburger menu button to toggle the sidebar closed. + +## Chat search: sub-message scroll precision + +**Status**: Requires live debugging session + +**Symptoms**: When navigating search results (prev/next) in the chat search feature, the view scrolls to the correct message but not to the specific text occurrence within long messages. The highlighted orange match can be off-screen if the message is tall. + +**What works**: Match counting per-occurrence, yellow/orange highlighting, dark mode contrast, keyboard dismiss — all work correctly. The only issue is the scroll target precision within a message. + +**Investigation so far** (2 rounds of fixes attempted): +1. **BringIntoViewRequester approach** (`122419c`): Attached a `BringIntoViewRequester` to the `HighlightedTextSegment` containing the focused occurrence. Called `bringIntoView()` after `animateScrollToItem()`. Didn't work because the `LazyColumn` treats each `MessageBubble` as a single item — `bringIntoView()` degrades to `scrollToItem()` and can't scroll within an item. +2. **onGloballyPositioned + animateScrollBy approach** (`c75a76a`): Replaced `BringIntoViewRequester` with a callback chain. The focused `HighlightedTextSegment` reports its pixel coordinates via `Modifier.onGloballyPositioned`. `MessageList` compares the segment's `boundsInRoot()` to the viewport and calls `animateScrollBy()` with the delta. Still not working — likely a timing issue where `onGloballyPositioned` fires before the initial `animateScrollToItem()` completes, or coordinate space mismatches between the segment and the viewport. + +**Key files**: +- `feature/chat/.../components/MessageList.kt` — scroll logic, `pendingFineTuneScroll` flag +- `feature/chat/.../components/MarkdownContent.kt` — `HighlightedTextSegment` with `onGloballyPositioned` +- `feature/chat/.../components/ContentPartRenderer.kt` — forwards `onFocusedOccurrencePositioned` callback +- `feature/chat/.../components/MessageBubble.kt` — forwards callback when `isCurrentSearchMatch` + +**What's needed**: On-device debugging session with Logcat/Timber to trace: +- Whether `onGloballyPositioned` fires at the right time (after `animateScrollToItem` completes) +- The actual coordinate values from `boundsInRoot()` vs viewport bounds +- Whether `animateScrollBy()` is called with the correct delta +- Whether the `pendingFineTuneScroll` guard allows or blocks the fine-tune scroll + +**Workaround**: User can manually scroll within the message to find the orange-highlighted occurrence after the search navigates to the correct message. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0496a22 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# LibreChat Android + +Native Android client for [LibreChat](https://www.librechat.ai/). Connects to any self-hosted LibreChat server — no backend modifications required. + +## Features + +- **Chat** — Real-time streaming (SSE), message branching & sibling navigation, stop/regenerate/continue, markdown with syntax highlighting, LaTeX math rendering, code blocks with copy, image display, file attachments, tool call progress cards +- **Model Selection** — Searchable bottom sheet grouped by endpoint, model comparison mode +- **Agents** — Marketplace with search and categories, MCP server configuration +- **Conversations** — Paginated list with date grouping, tags, search, rename, archive, delete, share, fork, duplicate, export/import +- **Presets & Prompts** — Save/load chat presets, prompts library with @mention insertion +- **Authentication** — Login, registration, forgot password, two-factor (TOTP + backup codes), OAuth (Google, GitHub, Discord, Facebook, Apple, OpenID) +- **Files** — Upload, list, delete, inline image rendering with pinch-to-zoom +- **Voice** — Speech-to-text input, text-to-speech playback (device and server engines) +- **Settings** — Theme (system/light/dark), account management, data controls +- **Tablet** — Adaptive dual-pane layout (600dp+) with persistent sidebar +- **Accessibility** — Semantic headings, content descriptions, 48dp touch targets, live regions + +## Server Setup + +The app works with any standard LibreChat server. During onboarding, you'll enter your server URL (e.g., `https://chat.example.com` or `http://192.168.1.100:3080`). + +### Required Configuration + +Add the following to your LibreChat server's `.env` file: + +```env +# Safety net for native app clients. +# The app sends a browser User-Agent to pass the uaParser middleware, +# but if it ever fails to parse, this prevents ban point accumulation. +NON_BROWSER_VIOLATION_SCORE=0 +``` + +Without this setting, the server's violation system may accumulate ban points against the Android client if the User-Agent check fails, eventually locking the account out. + +### Notes + +- **Registration** — The app respects your server's registration settings. If registration is disabled server-side, only the login form is shown. + +## Building from Source + +**Requirements:** Android Studio, JDK 17+ + +```bash +./gradlew assembleDebug +``` + +The debug APK will be at `app/build/outputs/apk/debug/app-debug.apk`. + +For a release build: + +```bash +./gradlew assembleRelease +``` + +### Tech Stack + +- Jetpack Compose + Compose Navigation +- Hilt (dependency injection) +- Ktor Client with OkHttp engine +- Kotlinx Serialization +- Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens) +- Kotlin 2.1.0, compileSdk 35, minSdk 26 + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/app/CLAUDE.md b/app/CLAUDE.md new file mode 100644 index 0000000..52df94d --- /dev/null +++ b/app/CLAUDE.md @@ -0,0 +1,71 @@ +# App Module + +Single Activity architecture. `MainActivity` is the sole entry point, annotated `@AndroidEntryPoint`. + +## Navigation + +`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`: + +- **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture. +- **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar. + +## Sidebar / Drawer Content + +`DrawerContent` is the rich sidebar matching the web's left panel: +- "New Chat" button at top +- Search bar with debounce filtering +- Conversation items grouped by date (Today, Yesterday, Previous 7 Days, etc.) +- Each item shows: endpoint icon, title, model name, relative time +- Active conversation highlighted with left border indicator and tinted background +- Footer: links to Agents, Files, Settings +- Sign out button at bottom + +## Top-Level Destinations + +Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings. +Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost. + +## Active Conversation Tracking + +`NavHostViewModel.activeConversationId` tracks the currently viewed conversation. Updated automatically from the navigation back stack when the chat route changes. Used by `DrawerContent` to highlight the active conversation. + +## Auth & Session + +- `NavHostViewModel` observes auth state via `isLoggedIn` flow +- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise +- `sessionExpired` flow triggers nav to auth graph with full backstack clear +- Drawer gestures are hidden during auth flow + +## Deep Linking + +- Scheme: `librechat://conversation/{conversationId}` +- Handled in `MainActivity.handleDeepLink()` and forwarded to `LibreChatNavHost` +- `onNewIntent` handles deep links when app is already running + +## Connectivity + +- `ConnectivityObserver` injected into `MainActivity` +- Offline banner animates in/out at top of screen when connection is lost + +## Dependencies + +This module depends on all `:core:*` and all `:feature:*` modules. +It applies convention plugins: `librechat.android.application`, `librechat.android.compose`, `librechat.android.hilt`. + +### Server-Synced Favorites +- `NavHostViewModel` exposes `favorites: StateFlow>` and `toggleFavorite()` +- Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring +- `DrawerContent` shows star icon per conversation (filled = bookmarked) +- Favorites state passed through `LibreChatNavHost` and `TabletLayout` + +### Server Banners +- `NavHostViewModel` fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()` +- Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow>` (session-scoped, not persisted) +- `BannerDisplay` composable shown at top of content in both `LibreChatNavHost` (phone) and `TabletLayout` +- Banner types: "info" (blue), "warning" (amber), "error" (red) — defaults to "info" if type is null +- **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed + +### Preset Navigation +- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt` +- `PresetManagerScreen` accessible from Settings → Chat section +- Navigation wired in both `LibreChatNavHost` and `TabletLayout` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..03ad758 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("librechat.android.application") + id("librechat.android.compose") + id("librechat.android.hilt") +} + +android { + namespace = "com.librechat.android" + + defaultConfig { + applicationId = "com.librechat.android" + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":core:data")) + implementation(project(":core:network")) + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":feature:auth")) + implementation(project(":feature:chat")) + implementation(project(":feature:conversations")) + implementation(project(":feature:settings")) + implementation(project(":feature:agents")) + implementation(project(":feature:files")) + + implementation(libs.activity.compose) + implementation(libs.navigation.compose) + implementation(libs.hilt.navigation.compose) + implementation(libs.compose.material3.wsc) + implementation(libs.bundles.lifecycle) + implementation(libs.coil.compose) + implementation(libs.timber) + + androidTestImplementation(libs.compose.ui.test) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ef145c1 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# LibreChat Android ProGuard Rules + +# Keep Kotlin serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); } +-keep,includedescriptorclasses class com.librechat.android.**$$serializer { *; } +-keepclassmembers class com.librechat.android.** { *** Companion; } +-keepclasseswithmembers class com.librechat.android.** { kotlinx.serialization.KSerializer serializer(...); } + +# Keep Ktor +-keep class io.ktor.** { *; } +-dontwarn io.ktor.** + +# Keep Room entities +-keep class com.librechat.android.core.data.db.entity.** { *; } diff --git a/app/src/androidTest/kotlin/com/librechat/android/AuthFlowTest.kt b/app/src/androidTest/kotlin/com/librechat/android/AuthFlowTest.kt new file mode 100644 index 0000000..d20ec7e --- /dev/null +++ b/app/src/androidTest/kotlin/com/librechat/android/AuthFlowTest.kt @@ -0,0 +1,49 @@ +package com.librechat.android + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import com.librechat.android.core.ui.theme.LibreChatTheme +import com.librechat.android.feature.auth.screen.ServerUrlScreen +import org.junit.Rule +import org.junit.Test + +class AuthFlowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun setUpServerUrlScreen() { + composeTestRule.setContent { + LibreChatTheme { + ServerUrlScreen( + onServerValidated = {}, + ) + } + } + } + + @Test + fun serverUrlScreen_displaysCorrectly() { + setUpServerUrlScreen() + composeTestRule.onNodeWithText("Connect to LibreChat").assertIsDisplayed() + composeTestRule.onNodeWithText("Server URL").assertIsDisplayed() + composeTestRule.onNodeWithText("Connect").assertIsDisplayed() + } + + @Test + fun serverUrlScreen_connectButtonDisabledWhenEmpty() { + setUpServerUrlScreen() + composeTestRule.onNodeWithText("Connect").assertIsNotEnabled() + } + + @Test + fun serverUrlScreen_enablesConnectOnInput() { + setUpServerUrlScreen() + composeTestRule.onNodeWithText("Server URL").performTextInput("https://example.com") + composeTestRule.onNodeWithText("Connect").assertIsEnabled() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5b74bc1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt b/app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt new file mode 100644 index 0000000..43749fb --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt @@ -0,0 +1,86 @@ +package com.librechat.android + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import coil.memory.MemoryCache +import com.librechat.android.core.network.client.TokenManager +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import timber.log.Timber + +@HiltAndroidApp +class LibreChatApplication : Application(), ImageLoaderFactory { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ImageLoaderEntryPoint { + fun tokenManager(): TokenManager + } + + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } + + override fun newImageLoader(): ImageLoader { + val entryPoint = EntryPointAccessors.fromApplication( + this, ImageLoaderEntryPoint::class.java + ) + val tokenManager = entryPoint.tokenManager() + + val okHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val token = runBlocking { tokenManager.getAccessToken() } + val requestBuilder = chain.request().newBuilder() + // The backend's uaParser middleware (ua-parser-js) rejects requests + // without a recognized browser User-Agent with 403. This must match + // the UA string used by the Ktor HttpClient. + .header( + "User-Agent", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + ) + if (token != null) { + requestBuilder.addHeader("Authorization", "Bearer $token") + } + val request = requestBuilder.build() + Timber.d("Coil image request: %s", request.url) + val response = chain.proceed(request) + if (!response.isSuccessful) { + Timber.w( + "Coil image request failed: %s -> %d %s", + request.url, + response.code, + response.message, + ) + } + response + } + .build() + + return ImageLoader.Builder(this) + .okHttpClient(okHttpClient) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("image_cache")) + .maxSizeBytes(250L * 1024 * 1024) // 250 MB + .build() + } + .crossfade(true) + .build() + } +} diff --git a/app/src/main/kotlin/com/librechat/android/MainActivity.kt b/app/src/main/kotlin/com/librechat/android/MainActivity.kt new file mode 100644 index 0000000..ad51518 --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/MainActivity.kt @@ -0,0 +1,192 @@ +package com.librechat.android + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.SideEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import com.librechat.android.core.common.network.ConnectivityObserver +import com.librechat.android.core.data.datastore.ThemeDataStore +import com.librechat.android.core.data.datastore.ThemeMode +import com.librechat.android.core.ui.theme.LibreChatTheme +import com.librechat.android.feature.chat.ShareIntentConsumer +import com.librechat.android.feature.chat.SharedContent +import com.librechat.android.navigation.LibreChatNavHost +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber +import javax.inject.Inject +import androidx.compose.ui.res.stringResource + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject lateinit var connectivityObserver: ConnectivityObserver + @Inject lateinit var themeDataStore: ThemeDataStore + + private var deepLinkUri by mutableStateOf(null) + + /** + * Incremented each time a share intent arrives. + * The NavHost observes this and either: + * - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or + * - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen. + */ + private var shareNavigationTrigger by mutableStateOf(0) + + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + handleIntent(intent) + setContent { + val windowSizeClass = calculateWindowSizeClass(this) + val isConnected by connectivityObserver.isConnected.collectAsStateWithLifecycle(initialValue = true) + val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM) + val darkTheme = when (themeMode) { + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + ThemeMode.SYSTEM -> isSystemInDarkTheme() + } + + // Update system bar icon colors to match the app's resolved theme. + // When light theme: dark icons on light background (isAppearanceLight = true). + // When dark theme: light icons on dark background (isAppearanceLight = false). + // This ensures correct visibility even when the user overrides the system theme. + SideEffect { + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + insetsController.isAppearanceLightStatusBars = !darkTheme + insetsController.isAppearanceLightNavigationBars = !darkTheme + } + + LibreChatTheme(darkTheme = darkTheme) { + Surface(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = !isConnected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(vertical = 6.dp, horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_connection), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + LibreChatNavHost( + windowSizeClass = windowSizeClass, + deepLinkUri = deepLinkUri, + onDeepLinkConsumed = { deepLinkUri = null }, + shareNavigationTrigger = shareNavigationTrigger, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent?) { + if (intent == null) return + + when (intent.action) { + Intent.ACTION_SEND -> handleShareIntent(intent) + Intent.ACTION_SEND_MULTIPLE -> handleShareMultipleIntent(intent) + else -> handleDeepLink(intent) + } + } + + private fun handleDeepLink(intent: Intent) { + intent.data?.let { uri -> + if (uri.scheme == "librechat" && uri.host in KNOWN_DEEP_LINK_HOSTS) { + deepLinkUri = uri + } else if (uri.scheme == "librechat") { + Timber.w("Ignoring deep link with unknown host: %s", uri.host) + } + } + } + + companion object { + /** Hosts accepted by the librechat:// deep link scheme. */ + private val KNOWN_DEEP_LINK_HOSTS = setOf("conversation", "oauth") + + /** MongoDB ObjectID: exactly 24 lowercase hex characters. */ + val CONVERSATION_ID_REGEX = Regex("^[a-f0-9]{24}$") + } + + private fun handleShareIntent(intent: Intent) { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + @Suppress("DEPRECATION") + val sharedUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + + val fileUris = if (sharedUri != null) listOf(sharedUri) else emptyList() + + if (sharedText != null || fileUris.isNotEmpty()) { + Timber.d("Share intent received: text=%s, uris=%d", sharedText != null, fileUris.size) + ShareIntentConsumer.setPendingShare( + SharedContent(text = sharedText, fileUris = fileUris), + ) + shareNavigationTrigger++ + } + } + + private fun handleShareMultipleIntent(intent: Intent) { + @Suppress("DEPRECATION") + val sharedUris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + + if (!sharedUris.isNullOrEmpty()) { + Timber.d("Share multiple intent received: uris=%d", sharedUris.size) + ShareIntentConsumer.setPendingShare( + SharedContent(fileUris = sharedUris), + ) + shareNavigationTrigger++ + } + } +} diff --git a/app/src/main/kotlin/com/librechat/android/navigation/DrawerContent.kt b/app/src/main/kotlin/com/librechat/android/navigation/DrawerContent.kt new file mode 100644 index 0000000..aef6fb1 --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/DrawerContent.kt @@ -0,0 +1,520 @@ +package com.librechat.android.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.ui.components.isMonochromeIcon +import com.librechat.android.core.ui.components.toIconRes +import com.librechat.android.R +import androidx.compose.ui.res.stringResource + +/** + * Lightweight snapshot of fields DrawerConversationItem actually renders. + * Avoids passing the full 28-field Conversation through composition. + */ +@Immutable +data class DrawerConversationDisplayData( + val conversationId: String, + val title: String, + val model: String?, + val endpoint: EModelEndpoint?, + val relativeTime: String, + val isActive: Boolean, + val isFavorite: Boolean, +) + +// Pre-computed shapes to avoid creating new ones per item per frame +private val ItemShape = RoundedCornerShape(8.dp) +private val ActiveIndicatorShape = RoundedCornerShape(2.dp) + +/** + * Stateful DrawerContent that collects its own state from the ViewModel. + * State changes only recompose inside this composable — not the parent + * PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content. + */ +@Composable +fun DrawerContent( + viewModel: NavHostViewModel, + onNewChat: () -> Unit, + onConversationClick: (String) -> Unit, + onSettingsClick: () -> Unit, + onAgentsClick: () -> Unit, + onFilesClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.drawerUiState.collectAsStateWithLifecycle() + DrawerContent( + uiState = uiState, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onNewChat = onNewChat, + onConversationClick = onConversationClick, + onSettingsClick = onSettingsClick, + onAgentsClick = onAgentsClick, + onFilesClick = onFilesClick, + onToggleFavorite = viewModel::toggleFavorite, + onRefresh = viewModel::refreshConversations, + onLoadMore = viewModel::loadMoreConversations, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DrawerContent( + uiState: DrawerUiState, + onSearchQueryChanged: (String) -> Unit, + onNewChat: () -> Unit, + onConversationClick: (String) -> Unit, + onSettingsClick: () -> Unit, + onAgentsClick: () -> Unit, + onFilesClick: () -> Unit, + onToggleFavorite: (String) -> Unit = {}, + onRefresh: () -> Unit = {}, + onLoadMore: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxHeight() + .width(300.dp) + .statusBarsPadding() + .padding(top = 16.dp), + ) { + // "New Chat" button at top + Surface( + onClick = onNewChat, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + shape = ItemShape, + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.new_chat), + style = MaterialTheme.typography.titleSmall, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Search bar + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = onSearchQueryChanged, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.cd_search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + trailingIcon = { + if (uiState.searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChanged("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_clear_search), + modifier = Modifier.size(20.dp), + ) + } + } + }, + placeholder = { + Text( + text = stringResource(R.string.search_conversations_placeholder), + style = MaterialTheme.typography.bodySmall, + ) + }, + singleLine = true, + shape = ItemShape, + textStyle = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Conversation list with favorites section and date groups + val listState = rememberLazyListState() + + // Detect when scrolled near the end to trigger load-more + val shouldLoadMore = remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleItem >= totalItems - 8 && totalItems > 0 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value && uiState.hasMore && !uiState.isLoadingMore) { + onLoadMore() + } + } + + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.weight(1f), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + // Favorites section + if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) { + item(key = "favorites_header") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.favorites), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() }, + ) + } + } + + items( + items = uiState.favoriteConversations, + key = { "fav_${it.conversationId}" }, + contentType = { "conversation" }, + ) { data -> + DrawerConversationItem( + data = data, + onClick = { onConversationClick(data.conversationId) }, + onToggleFavorite = { onToggleFavorite(data.conversationId) }, + ) + } + + item(key = "favorites_divider") { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } + + if (uiState.groupedConversations.isEmpty() && uiState.searchQuery.isNotEmpty()) { + item(key = "empty_search") { + Text( + text = stringResource(R.string.no_conversations_found), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + ) + } + } + + uiState.groupedConversations.forEach { (dateGroup, displayItems) -> + // Date group header + item(key = "header_$dateGroup") { + Text( + text = dateGroup, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 12.dp, + bottom = 4.dp, + ), + ) + } + + // Conversation items in this group + items( + items = displayItems, + key = { it.conversationId }, + contentType = { "conversation" }, + ) { data -> + DrawerConversationItem( + data = data, + onClick = { onConversationClick(data.conversationId) }, + onToggleFavorite = { onToggleFavorite(data.conversationId) }, + ) + } + } + + // Loading indicator at the bottom when loading more + if (uiState.isLoadingMore) { + item(key = "loading_more") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + } + } + } + } + + // Bottom section: divider + footer links + sign out + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + // Footer navigation links + DrawerFooterItem( + icon = Icons.Default.SmartToy, + label = stringResource(R.string.agents), + onClick = onAgentsClick, + ) + DrawerFooterItem( + icon = Icons.Default.Folder, + label = stringResource(R.string.files), + onClick = onFilesClick, + ) + DrawerFooterItem( + icon = Icons.Default.Settings, + label = stringResource(R.string.settings), + onClick = onSettingsClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun DrawerConversationItem( + data: DrawerConversationDisplayData, + onClick: () -> Unit, + onToggleFavorite: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val endpointIconRes = remember(data.endpoint) { + data.endpoint?.toIconRes() + } + + val backgroundColor = if (data.isActive) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + } + + Row( + modifier = modifier + .padding(horizontal = 4.dp, vertical = 1.dp) + .fillMaxWidth() + .background(backgroundColor, ItemShape) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Active indicator (left border simulation) + if (data.isActive) { + Box( + modifier = Modifier + .width(3.dp) + .height(24.dp) + .background(MaterialTheme.colorScheme.primary, ActiveIndicatorShape), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + // Endpoint icon or star for favorites + if (data.isFavorite) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else if (endpointIconRes != null) { + val isMonochrome = data.endpoint?.isMonochromeIcon() == true + Icon( + painter = painterResource(id = endpointIconRes), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (isMonochrome) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + Color.Unspecified + }, + ) + } else { + Icon( + imageVector = Icons.Default.SmartToy, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (data.isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + // Title and metadata + Column(modifier = Modifier.weight(1f)) { + Text( + text = data.title, + style = MaterialTheme.typography.bodyMedium, + color = if (data.isActive) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Model and time on second line + val subtitle = remember(data.model, data.relativeTime) { + buildString { + data.model?.let { model -> + append(model.take(20)) + } + if (data.relativeTime.isNotEmpty()) { + if (isNotEmpty()) append(" \u00B7 ") + append(data.relativeTime) + } + } + } + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Bookmark toggle — lightweight clickable icon instead of IconButton + Icon( + imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder, + contentDescription = if (data.isFavorite) stringResource(R.string.remove_bookmark) else stringResource(R.string.bookmark), + modifier = Modifier + .size(32.dp) + .clickable(onClick = onToggleFavorite) + .padding(8.dp), + tint = if (data.isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +@Composable +private fun DrawerFooterItem( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + diff --git a/app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt b/app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt new file mode 100644 index 0000000..280025d --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt @@ -0,0 +1,428 @@ +package com.librechat.android.navigation + +import android.net.Uri +import com.librechat.android.MainActivity +import timber.log.Timber +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideOut +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.unit.dp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.librechat.android.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE +import com.librechat.android.feature.agents.navigation.agentsGraph +import com.librechat.android.feature.auth.navigation.AUTH_GRAPH_ROUTE +import com.librechat.android.feature.auth.navigation.authGraph +import com.librechat.android.feature.chat.navigation.CHAT_GRAPH_ROUTE +import com.librechat.android.feature.chat.navigation.CHAT_ROUTE +import com.librechat.android.feature.chat.navigation.NEW_CHAT_ROUTE +import com.librechat.android.feature.chat.navigation.chatGraph +import com.librechat.android.feature.chat.navigation.navigateToChat +import com.librechat.android.feature.conversations.navigation.conversationsGraph +import com.librechat.android.feature.files.navigation.filesGraph +import com.librechat.android.feature.settings.navigation.API_KEYS_ROUTE +import com.librechat.android.feature.settings.navigation.PRESET_MANAGER_ROUTE +import com.librechat.android.core.ui.components.BannerDisplay +import com.librechat.android.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_CHAT_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_DATA_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_GENERAL_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_TABBED_ROUTE +import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE +import com.librechat.android.feature.settings.navigation.settingsGraph +import kotlinx.coroutines.launch +import com.librechat.android.R +import androidx.compose.ui.res.stringResource + +/** Maps a [SettingsCategory] to its corresponding navigation route. */ +fun SettingsCategory.toRoute(): String = when (this) { + SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE + SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE + SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE + SettingsCategory.DATA -> SETTINGS_DATA_ROUTE +} + +@Composable +fun LibreChatNavHost( + modifier: Modifier = Modifier, + windowSizeClass: WindowSizeClass? = null, + deepLinkUri: Uri? = null, + onDeepLinkConsumed: () -> Unit = {}, + shareNavigationTrigger: Int = 0, + navHostViewModel: NavHostViewModel = hiltViewModel(), +) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() + + val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || + currentDestination?.parent?.route == AUTH_GRAPH_ROUTE + + // Track active conversation from nav back stack + LaunchedEffect(navBackStackEntry) { + val route = navBackStackEntry?.destination?.route + val conversationId = if (route == CHAT_ROUTE) { + navBackStackEntry?.arguments?.getString("conversationId") + } else { + null + } + navHostViewModel.setActiveConversation(conversationId) + } + + // Handle session expiry + LaunchedEffect(Unit) { + navHostViewModel.sessionExpired.collect { + navController.navigate(AUTH_GRAPH_ROUTE) { + popUpTo(0) { inclusive = true } + } + } + } + + val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE + + val isTablet = windowSizeClass?.widthSizeClass?.let { + it >= WindowWidthSizeClass.Medium + } ?: false + + if (isTablet) { + TabletLayout( + navController = navController, + navHostViewModel = navHostViewModel, + startDestination = startDestination, + isInAuthFlow = isInAuthFlow, + deepLinkUri = deepLinkUri, + onDeepLinkConsumed = onDeepLinkConsumed, + shareNavigationTrigger = shareNavigationTrigger, + modifier = modifier, + ) + } else { + PhoneLayout( + navController = navController, + navHostViewModel = navHostViewModel, + startDestination = startDestination, + isInAuthFlow = isInAuthFlow, + deepLinkUri = deepLinkUri, + onDeepLinkConsumed = onDeepLinkConsumed, + shareNavigationTrigger = shareNavigationTrigger, + modifier = modifier, + ) + } + + // Version mismatch warning dialog (shown over any layout) + val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle() + versionMismatch?.let { mismatch -> + VersionMismatchDialog( + supportedVersion = mismatch.supportedVersion, + backendVersion = mismatch.backendVersion, + onDismiss = navHostViewModel::dismissVersionWarning, + onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently, + ) + } +} + +/** + * Dialog shown when the backend server version does not match the version + * this app was built for. Offers "Dismiss" (session-only) and + * "Don't warn again" (persisted per backend version) options. + */ +@Composable +private fun VersionMismatchDialog( + supportedVersion: String, + backendVersion: String, + onDismiss: () -> Unit, + onDismissPermanently: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.version_mismatch_title), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Text( + text = stringResource(R.string.version_mismatch_message, supportedVersion, backendVersion), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dismiss)) + } + }, + dismissButton = { + TextButton(onClick = onDismissPermanently) { + Text(stringResource(R.string.dont_warn_again)) + } + }, + ) +} + +@Composable +private fun PhoneLayout( + navController: androidx.navigation.NavHostController, + navHostViewModel: NavHostViewModel, + startDestination: String, + isInAuthFlow: Boolean, + deepLinkUri: Uri?, + onDeepLinkConsumed: () -> Unit, + shareNavigationTrigger: Int, + modifier: Modifier = Modifier, +) { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + // Banner state only -- drawer state is collected inside DrawerContent itself + val banners by navHostViewModel.banners.collectAsStateWithLifecycle() + val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() + + // Handle deep links + LaunchedEffect(deepLinkUri) { + deepLinkUri?.let { uri -> + if (uri.scheme == "librechat" && uri.host == "conversation") { + uri.lastPathSegment?.let { conversationId -> + if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { + navController.navigateToChat(conversationId) + } else { + Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId) + } + } + } + onDeepLinkConsumed() + } + } + + // Handle share intent: if already on a chat screen, let the active ChatViewModel + // consume the shared content reactively. Only navigate to new chat when on a + // non-chat screen (e.g. settings, agents, files). + LaunchedEffect(shareNavigationTrigger) { + if (shareNavigationTrigger > 0) { + val currentRoute = navController.currentBackStackEntry?.destination?.route + val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE + if (!isOnChatScreen) { + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + } + // If already on a chat screen, SharedIntentConsumer.shareAvailable + // will notify the active ChatViewModel to consume the content. + } + } + + // Reset sidebar mode to Conversations when the drawer closes + LaunchedEffect(drawerState.isClosed) { + if (drawerState.isClosed) { + navHostViewModel.setSidebarMode(SidebarMode.Conversations) + } + } + + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = !isInAuthFlow, + drawerContent = { + ModalDrawerSheet { + SidebarScaffold( + viewModel = navHostViewModel, + onNewChat = { + scope.launch { drawerState.close() } + // Skip navigation if already on the new chat screen + val currentRoute = navController.currentBackStackEntry?.destination?.route + if (currentRoute != NEW_CHAT_ROUTE) { + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + } + }, + onConversationClick = { conversationId -> + scope.launch { drawerState.close() } + navController.navigateToChat(conversationId) + }, + onSettingsClick = { + scope.launch { drawerState.close() } + navController.navigate(SETTINGS_TABBED_ROUTE) { + launchSingleTop = true + } + }, + onSettingsCategorySelected = { category -> + // Navigate to the settings sub-page but keep the drawer open + navController.navigate(category.toRoute()) { + launchSingleTop = true + } + }, + onAgentsClick = { + scope.launch { drawerState.close() } + navController.navigate(TopLevelDestination.AGENTS.route) { + launchSingleTop = true + } + }, + onFilesClick = { + scope.launch { drawerState.close() } + navController.navigate(TopLevelDestination.FILES.route) { + launchSingleTop = true + } + }, + ) + } + }, + ) { + // No bottom bar -- drawer is the primary navigation + Column(modifier = modifier.fillMaxSize()) { + if (!isInAuthFlow && banners.isNotEmpty()) { + BannerDisplay( + banners = banners, + dismissedIds = dismissedBannerIds, + onDismiss = navHostViewModel::dismissBanner, + modifier = Modifier.padding(top = 4.dp), + ) + } + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(300), + ) + }, + exitTransition = { + fadeOut(animationSpec = tween(150)) + }, + popEnterTransition = { + fadeIn( + initialAlpha = 0.5f, + animationSpec = tween(300, easing = LinearEasing), + ) + scaleIn( + initialScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + ) + }, + popExitTransition = { + slideOut( + targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + ) + }, + ) { + authGraph( + navController = navController, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navController.navigate(CHAT_GRAPH_ROUTE) { + popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } + } + }, + ) + chatGraph( + navController = navController, + onOpenDrawer = { + scope.launch { drawerState.open() } + }, + ) + conversationsGraph( + onConversationClick = { conversationId -> + navController.navigateToChat(conversationId) + }, + onNavigateToArchived = { + navController.navigate("conversations/archived") + }, + onNavigateBackFromArchived = { + navController.popBackStack() + }, + ) + agentsGraph( + onAgentClick = { agentId -> + navController.navigate("agents/$agentId") + }, + onBack = { navController.popBackStack() }, + onStartChat = { agentId -> + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + }, + onCreateAgent = { + navController.navigate(AGENT_EDITOR_CREATE_ROUTE) + }, + onEditAgent = { agentId -> + navController.navigate("agents/editor/$agentId") + }, + ) + filesGraph() + settingsGraph( + onLogout = { + navHostViewModel.logout() + navController.navigate(AUTH_GRAPH_ROUTE) { + popUpTo(0) { inclusive = true } + } + }, + onNavigateBack = { navController.popBackStack() }, + onNavigateToArchived = { + navController.navigate("conversations/archived") { + launchSingleTop = true + } + }, + onNavigateToSharedLinks = { + navController.navigate(SHARED_LINKS_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromSharedLinks = { + navController.popBackStack() + }, + onNavigateToPresets = { + navController.navigate(PRESET_MANAGER_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromPresets = { + navController.popBackStack() + }, + onNavigateToApiKeys = { + navController.navigate(API_KEYS_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromApiKeys = { + navController.popBackStack() + }, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt b/app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt new file mode 100644 index 0000000..6084cca --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt @@ -0,0 +1,449 @@ +package com.librechat.android.navigation + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.BannerRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.VersionCheckResult +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.extensions.toInstantOrNull +import com.librechat.android.core.common.extensions.toRelativeDateGroup +import com.librechat.android.core.model.Banner +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.network.client.TokenManager +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import javax.inject.Inject + +/** + * Combined UI state for the drawer sidebar. A single emission replaces + * 10+ individual StateFlow collections in the parent composable, preventing + * unnecessary recomposition of the entire PhoneLayout/TabletLayout tree. + */ +@Immutable +data class DrawerUiState( + val groupedConversations: List>> = emptyList(), + val favoriteConversations: List = emptyList(), + val searchQuery: String = "", + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = true, +) + +/** + * UI state for a backend version mismatch warning. + */ +@Immutable +data class VersionMismatchState( + val supportedVersion: String, + val backendVersion: String, +) + +@HiltViewModel +class NavHostViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val bannerRepository: BannerRepository, + private val configRepository: ConfigRepository, + private val conversationRepository: ConversationRepository, + private val tokenManager: TokenManager, + private val settingsDataStore: com.librechat.android.core.data.datastore.SettingsDataStore, +) : ViewModel() { + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + /** + * Non-null when a version mismatch has been detected and should be shown to the user. + * Set to null after the user dismisses the dialog (for this session or permanently). + */ + private val _versionMismatch = MutableStateFlow(null) + val versionMismatch: StateFlow = _versionMismatch.asStateFlow() + + private val _recentConversations = MutableStateFlow>(emptyList()) + + /** Conversations grouped by date (internal, feeds into drawerUiState). */ + private val _groupedConversations = MutableStateFlow>>>(emptyList()) + + /** The ID of the currently viewed conversation (for active highlight). */ + private val _activeConversationId = MutableStateFlow(null) + + /** Drawer search query. */ + private val _searchQuery = MutableStateFlow("") + + /** Server-synced bookmarked conversation IDs. */ + private val _favorites = MutableStateFlow>(emptySet()) + + /** Bookmarked/favorite conversations (internal, feeds into drawerUiState). */ + private val _favoriteConversations = MutableStateFlow>(emptyList()) + + /** Pagination cursor for loading more conversations from the server. */ + private var nextCursor: String? = null + + /** Whether there are more conversations to load from the server. */ + private val _hasMore = MutableStateFlow(true) + + /** Whether a load-more request is in progress. */ + private val _isLoadingMore = MutableStateFlow(false) + + /** Whether a pull-to-refresh is in progress. */ + private val _isRefreshing = MutableStateFlow(false) + + /** Active banners from the server, filtered to non-expired. */ + private val _banners = MutableStateFlow>(emptyList()) + val banners: StateFlow> = _banners.asStateFlow() + + /** Banner IDs dismissed by the user during this session. */ + private val _dismissedBannerIds = MutableStateFlow>(emptySet()) + val dismissedBannerIds: StateFlow> = _dismissedBannerIds.asStateFlow() + + val sessionExpired: SharedFlow = tokenManager.sessionExpiredFlow + + /** Persisted sidebar open/close state for tablet mode. + * The initial value is read synchronously so that first composition + * sees the real persisted state instead of a hardcoded default, + * avoiding a visible open-then-close flicker on cold start. */ + val tabletSidebarOpen: StateFlow = settingsDataStore.tabletSidebarOpen + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + runBlocking { settingsDataStore.tabletSidebarOpen.first() }, + ) + + /** Whether the swipe gesture to open/close the tablet sidebar is enabled. */ + val tabletSidebarGestureEnabled: StateFlow = settingsDataStore.tabletSidebarGestureEnabled + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + /** Current sidebar mode: Conversations (default) or Settings. */ + private val _sidebarMode = MutableStateFlow(SidebarMode.Conversations) + val sidebarMode: StateFlow = _sidebarMode.asStateFlow() + + /** Currently selected settings category in the sidebar nav menu. */ + private val _selectedSettingsCategory = MutableStateFlow(null) + val selectedSettingsCategory: StateFlow = _selectedSettingsCategory.asStateFlow() + + fun setSidebarMode(mode: SidebarMode) { + _sidebarMode.value = mode + } + + fun selectSettingsCategory(category: SettingsCategory) { + _selectedSettingsCategory.value = category + } + + private var searchJob: Job? = null + + /** + * Single combined drawer UI state. All display-data mapping (Conversation → + * DrawerConversationDisplayData) happens here in the ViewModel, not in composition. + * Collected inside DrawerContent so state changes only recompose the drawer, + * not the entire PhoneLayout/TabletLayout. + */ + val drawerUiState: StateFlow = combine( + combine(_groupedConversations, _activeConversationId, _favorites, _favoriteConversations, _searchQuery) { grouped, activeId, favIds, favConvos, query -> + DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query) + }, + combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore -> + Triple(refreshing, loadingMore, hasMore) + }, + ) { data, (refreshing, loadingMore, hasMore) -> + DrawerUiState( + groupedConversations = data.grouped.map { (group, convos) -> + group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) } + }, + favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }, + searchQuery = data.query, + isRefreshing = refreshing, + isLoadingMore = loadingMore, + hasMore = hasMore, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState()) + + init { + viewModelScope.launch { + val loggedIn = authRepository.isLoggedIn() + _isLoggedIn.value = loggedIn + // Check backend version on cold start for returning users + if (loggedIn) { + checkBackendVersion() + } + } + observeConversations() + observeBookmarks() + fetchBanners() + loadInitialConversations() + } + + private fun observeConversations() { + viewModelScope.launch { + conversationRepository.observeConversations().collect { result -> + if (result is Result.Success) { + _recentConversations.value = result.data + // Only regroup if not in search mode + if (_searchQuery.value.isBlank()) { + _groupedConversations.value = groupConversationsByDate(result.data) + } + updateFavoriteConversations() + } + } + } + } + + private fun loadInitialConversations() { + viewModelScope.launch { + val result = conversationRepository.loadNextPage(cursor = null) + if (result is Result.Success) { + nextCursor = result.data + _hasMore.value = result.data != null + } + } + } + + fun loadMoreConversations() { + if (_isLoadingMore.value || !_hasMore.value) return + _isLoadingMore.value = true + viewModelScope.launch { + val result = conversationRepository.loadNextPage(cursor = nextCursor) + if (result is Result.Success) { + nextCursor = result.data + _hasMore.value = result.data != null + } else { + Timber.w("Failed to load more conversations") + } + _isLoadingMore.value = false + } + } + + fun onAuthComplete() { + _isLoggedIn.value = true + loadInitialConversations() + fetchBanners() + checkBackendVersion() + } + + fun refreshConversations() { + _isRefreshing.value = true + viewModelScope.launch { + nextCursor = null + _hasMore.value = true + val result = conversationRepository.loadNextPage(cursor = null) + if (result is Result.Success) { + nextCursor = result.data + _hasMore.value = result.data != null + } + _isRefreshing.value = false + } + } + + fun setActiveConversation(conversationId: String?) { + _activeConversationId.value = conversationId + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + searchJob?.cancel() + + if (query.isBlank()) { + // Reset to full grouped list from cached conversations + _groupedConversations.value = groupConversationsByDate(_recentConversations.value) + return + } + + searchJob = viewModelScope.launch { + delay(300) // debounce + val filtered = _recentConversations.value.filter { conversation -> + conversation.title?.contains(query, ignoreCase = true) == true + } + _groupedConversations.value = groupConversationsByDate(filtered) + } + } + + private fun observeBookmarks() { + viewModelScope.launch { + settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds -> + _favorites.value = bookmarkedIds + updateFavoriteConversations() + } + } + } + + fun toggleFavorite(conversationId: String) { + viewModelScope.launch { + settingsDataStore.toggleBookmark(conversationId) + } + } + + private fun updateFavoriteConversations() { + val bookmarkedIds = _favorites.value + val allConversations = _recentConversations.value + _favoriteConversations.value = allConversations.filter { conversation -> + conversation.conversationId in bookmarkedIds || + conversation.tags.any { + it.equals("favorite", ignoreCase = true) || + it.equals("bookmark", ignoreCase = true) + } + } + } + + private fun fetchBanners() { + viewModelScope.launch { + val result = bannerRepository.getBanners() + if (result is Result.Success) { + val now = Instant.now() + _banners.value = result.data.filter { banner -> + val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() } + val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() } + val afterStart = from == null || !now.isBefore(from) + val beforeEnd = to == null || now.isBefore(to) + afterStart && beforeEnd + } + } + } + } + + fun setTabletSidebarOpen(open: Boolean) { + viewModelScope.launch { + settingsDataStore.setTabletSidebarOpen(open) + } + } + + fun dismissBanner(bannerId: String) { + _dismissedBannerIds.value = _dismissedBannerIds.value + bannerId + } + + /** + * Performs an async, non-blocking version check against the backend. + * If the versions are incompatible and the user hasn't dismissed the warning + * for this specific backend version, sets [versionMismatch] to trigger the dialog. + */ + private fun checkBackendVersion() { + viewModelScope.launch { + val result = configRepository.checkBackendVersion() + if (result is Result.Success) { + val checkResult = result.data + val detectedVersion = checkResult.backendVersion + if (!checkResult.isCompatible && detectedVersion != null) { + // Check if the user has already dismissed the warning for this version + val dismissedVersion = settingsDataStore.dismissedVersionWarning.first() + if (dismissedVersion != detectedVersion) { + _versionMismatch.value = VersionMismatchState( + supportedVersion = checkResult.supportedVersion, + backendVersion = detectedVersion, + ) + } else { + Timber.d( + "Version mismatch (backend=%s, supported=%s) but user dismissed for this version", + detectedVersion, + checkResult.supportedVersion, + ) + } + } + } + // On error, silently ignore -- the version check is non-critical + } + } + + /** Dismiss the version mismatch dialog for this session only. */ + fun dismissVersionWarning() { + _versionMismatch.value = null + } + + /** Dismiss the version mismatch dialog and suppress it for this backend version permanently. */ + fun dismissVersionWarningPermanently() { + val backendVersion = _versionMismatch.value?.backendVersion + _versionMismatch.value = null + if (backendVersion != null) { + viewModelScope.launch { + settingsDataStore.setDismissedVersionWarning(backendVersion) + } + } + } + + fun logout() { + viewModelScope.launch { + authRepository.logout() + _isLoggedIn.value = false + _recentConversations.value = emptyList() + _groupedConversations.value = emptyList() + _activeConversationId.value = null + _searchQuery.value = "" + _favorites.value = emptySet() + _favoriteConversations.value = emptyList() + _sidebarMode.value = SidebarMode.Conversations + _selectedSettingsCategory.value = null + } + } + + private fun groupConversationsByDate( + conversations: List, + ): List>> { + if (conversations.isEmpty()) return emptyList() + return conversations + .groupBy { conversation -> + conversation.updatedAt + ?.toInstantOrNull() + ?.toRelativeDateGroup() + ?: "Unknown" + } + .toList() + } +} + +/** Intermediate holder for the 5-argument combine. */ +private data class DrawerDataSnapshot( + val grouped: List>>, + val activeId: String?, + val favIds: Set, + val favConvos: List, + val query: String, +) + +private fun Conversation.toDrawerDisplayData( + activeConversationId: String?, + favoriteIds: Set, +): DrawerConversationDisplayData { + val convId = conversationId ?: "" + return DrawerConversationDisplayData( + conversationId = convId, + title = title ?: "New Chat", + model = model, + endpoint = endpoint, + relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "", + isActive = convId == activeConversationId, + isFavorite = convId in favoriteIds, + ) +} + +private fun Instant.toRelativeTimeString(): String { + val now = Instant.now() + val minutes = ChronoUnit.MINUTES.between(this, now) + val hours = ChronoUnit.HOURS.between(this, now) + val days = ChronoUnit.DAYS.between(this, now) + + return when { + minutes < 1 -> "Just now" + minutes < 60 -> "${minutes}m ago" + hours < 24 -> "${hours}h ago" + days < 7 -> "${days}d ago" + else -> atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("MMM d")) + } +} diff --git a/app/src/main/kotlin/com/librechat/android/navigation/SettingsSidebarContent.kt b/app/src/main/kotlin/com/librechat/android/navigation/SettingsSidebarContent.kt new file mode 100644 index 0000000..8899b6d --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/SettingsSidebarContent.kt @@ -0,0 +1,151 @@ +package com.librechat.android.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.librechat.android.R +import androidx.compose.ui.res.stringResource + +/** + * Settings sidebar content shown when the sidebar mode is [SidebarMode.Settings]. + * Displays a simple vertical list of settings categories. Tapping a category + * navigates to its sub-page in the main content area. + */ +@Composable +fun SettingsSidebarContent( + selectedCategory: SettingsCategory?, + onBackToConversations: () -> Unit, + onCategorySelected: (SettingsCategory) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxHeight() + .width(300.dp) + .statusBarsPadding() + .padding(top = 8.dp), + ) { + // Header with back arrow + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBackToConversations) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back_to_conversations), + ) + } + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .weight(1f) + .semantics { heading() }, + ) + } + + Spacer(modifier = Modifier.padding(top = 8.dp)) + + // Category list + SettingsCategoryItem( + icon = Icons.Default.Settings, + category = SettingsCategory.GENERAL, + isSelected = selectedCategory == SettingsCategory.GENERAL, + onClick = { onCategorySelected(SettingsCategory.GENERAL) }, + ) + SettingsCategoryItem( + icon = Icons.AutoMirrored.Filled.Chat, + category = SettingsCategory.CHAT, + isSelected = selectedCategory == SettingsCategory.CHAT, + onClick = { onCategorySelected(SettingsCategory.CHAT) }, + ) + SettingsCategoryItem( + icon = Icons.Default.AccountCircle, + category = SettingsCategory.ACCOUNT, + isSelected = selectedCategory == SettingsCategory.ACCOUNT, + onClick = { onCategorySelected(SettingsCategory.ACCOUNT) }, + ) + SettingsCategoryItem( + icon = Icons.Default.Storage, + category = SettingsCategory.DATA, + isSelected = selectedCategory == SettingsCategory.DATA, + onClick = { onCategorySelected(SettingsCategory.DATA) }, + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun SettingsCategoryItem( + icon: ImageVector, + category: SettingsCategory, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + } + val contentColor = if (isSelected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = contentColor, + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = category.label, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + ) + } +} diff --git a/app/src/main/kotlin/com/librechat/android/navigation/SidebarMode.kt b/app/src/main/kotlin/com/librechat/android/navigation/SidebarMode.kt new file mode 100644 index 0000000..725f537 --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/SidebarMode.kt @@ -0,0 +1,17 @@ +package com.librechat.android.navigation + +sealed interface SidebarMode { + data object Conversations : SidebarMode + data object Settings : SidebarMode +} + +/** + * Settings categories shown as a navigation menu in the sidebar. + * Each maps to a dedicated settings sub-page in the main content area. + */ +enum class SettingsCategory(val label: String) { + GENERAL("General"), + CHAT("Chat"), + ACCOUNT("Account"), + DATA("Data"), +} diff --git a/app/src/main/kotlin/com/librechat/android/navigation/SidebarScaffold.kt b/app/src/main/kotlin/com/librechat/android/navigation/SidebarScaffold.kt new file mode 100644 index 0000000..8cd61d6 --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/SidebarScaffold.kt @@ -0,0 +1,75 @@ +package com.librechat.android.navigation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +/** + * Sidebar scaffold that switches between Conversations and Settings modes. + * Uses [AnimatedContent] for a smooth horizontal slide transition. + * + * The Settings footer button now navigates directly to the tabbed settings + * screen via [onSettingsClick], rather than switching sidebar mode. + * The mode-switching infrastructure is preserved for potential future use. + */ +@Composable +fun SidebarScaffold( + viewModel: NavHostViewModel, + onNewChat: () -> Unit, + onConversationClick: (String) -> Unit, + onSettingsClick: () -> Unit, + onSettingsCategorySelected: (SettingsCategory) -> Unit, + onAgentsClick: () -> Unit, + onFilesClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val sidebarMode by viewModel.sidebarMode.collectAsStateWithLifecycle() + val selectedCategory by viewModel.selectedSettingsCategory.collectAsStateWithLifecycle() + + AnimatedContent( + targetState = sidebarMode, + modifier = modifier, + transitionSpec = { + if (targetState is SidebarMode.Settings) { + // Slide in from right when switching to Settings + slideInHorizontally { fullWidth -> fullWidth } togetherWith + slideOutHorizontally { fullWidth -> -fullWidth } + } else { + // Slide in from left when switching back to Conversations + slideInHorizontally { fullWidth -> -fullWidth } togetherWith + slideOutHorizontally { fullWidth -> fullWidth } + } + }, + label = "SidebarModeTransition", + ) { mode -> + when (mode) { + is SidebarMode.Conversations -> { + DrawerContent( + viewModel = viewModel, + onNewChat = onNewChat, + onConversationClick = onConversationClick, + onSettingsClick = onSettingsClick, + onAgentsClick = onAgentsClick, + onFilesClick = onFilesClick, + ) + } + is SidebarMode.Settings -> { + SettingsSidebarContent( + selectedCategory = selectedCategory, + onBackToConversations = { + viewModel.setSidebarMode(SidebarMode.Conversations) + }, + onCategorySelected = { category -> + viewModel.selectSettingsCategory(category) + onSettingsCategorySelected(category) + }, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt b/app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt new file mode 100644 index 0000000..9ad5f86 --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt @@ -0,0 +1,442 @@ +package com.librechat.android.navigation + +import android.net.Uri +import com.librechat.android.MainActivity +import timber.log.Timber +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideOut +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.layout.Layout +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import com.librechat.android.core.ui.components.BannerDisplay +import com.librechat.android.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE +import com.librechat.android.feature.agents.navigation.agentsGraph +import com.librechat.android.feature.auth.navigation.AUTH_GRAPH_ROUTE +import com.librechat.android.feature.auth.navigation.authGraph +import com.librechat.android.feature.chat.navigation.CHAT_GRAPH_ROUTE +import com.librechat.android.feature.chat.navigation.CHAT_ROUTE +import com.librechat.android.feature.chat.navigation.NEW_CHAT_ROUTE +import com.librechat.android.feature.chat.navigation.chatGraph +import com.librechat.android.feature.chat.navigation.navigateToChat +import com.librechat.android.feature.conversations.navigation.conversationsGraph +import com.librechat.android.feature.files.navigation.filesGraph +import com.librechat.android.feature.settings.navigation.API_KEYS_ROUTE +import com.librechat.android.feature.settings.navigation.PRESET_MANAGER_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_TABBED_ROUTE +import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE +import com.librechat.android.feature.settings.navigation.settingsGraph +import kotlinx.coroutines.launch + +private val SidebarWidth = 320.dp + +/** Velocity (px/s) above which a fling commits the gesture regardless of position. */ +private const val FLING_VELOCITY_THRESHOLD = 800f + +@Composable +fun TabletLayout( + navController: NavHostController, + navHostViewModel: NavHostViewModel, + startDestination: String, + isInAuthFlow: Boolean, + deepLinkUri: Uri?, + onDeepLinkConsumed: () -> Unit, + shareNavigationTrigger: Int = 0, + modifier: Modifier = Modifier, +) { + // Banner state only -- drawer state is collected inside DrawerContent itself + val banners by navHostViewModel.banners.collectAsStateWithLifecycle() + val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() + + // Persisted sidebar state from DataStore -- single source of truth in the ViewModel. + // The ViewModel's StateFlow initial value is read synchronously from DataStore, + // so the first composition already has the correct persisted state (no flicker). + val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle() + + // Whether swipe gesture is enabled (from settings) + val gestureEnabled by navHostViewModel.tabletSidebarGestureEnabled.collectAsStateWithLifecycle() + + val density = LocalDensity.current + val sidebarWidthPx = with(density) { SidebarWidth.toPx() } + + // Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open. + // During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx. + val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) } + val scope = rememberCoroutineScope() + + // Back press closes sidebar before navigating away + BackHandler(enabled = isSidebarOpen) { + navHostViewModel.setTabletSidebarOpen(false) + } + + // Handle deep links + LaunchedEffect(deepLinkUri) { + deepLinkUri?.let { uri -> + if (uri.scheme == "librechat" && uri.host == "conversation") { + uri.lastPathSegment?.let { conversationId -> + if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { + navController.navigateToChat(conversationId) + } else { + Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId) + } + } + } + onDeepLinkConsumed() + } + } + + // Handle share intent: if already on a chat screen, let the active ChatViewModel + // consume the shared content reactively. Only navigate to new chat when on a + // non-chat screen (e.g. settings, agents, files). + LaunchedEffect(shareNavigationTrigger) { + if (shareNavigationTrigger > 0) { + val currentRoute = navController.currentBackStackEntry?.destination?.route + val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE + if (!isOnChatScreen) { + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + } + } + } + + // Sync: when ViewModel state changes (e.g. hamburger button, back press), + // animate the sidebar to match. + LaunchedEffect(isSidebarOpen) { + val target = if (isSidebarOpen) sidebarWidthPx else 0f + if (sidebarOffset.value != target) { + sidebarOffset.animateTo( + targetValue = target, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + } + } + + if (!isInAuthFlow) { + val swipeModifier = if (gestureEnabled) { + Modifier.pointerInput(Unit) { + val velocityTracker = VelocityTracker() + detectHorizontalDragGestures( + onDragStart = { + velocityTracker.resetTracking() + }, + onDragEnd = { + val velocity = velocityTracker.calculateVelocity().x + val currentOffset = sidebarOffset.value + // Decide target: fling velocity wins, otherwise use 50% threshold + val shouldOpen = when { + velocity > FLING_VELOCITY_THRESHOLD -> true + velocity < -FLING_VELOCITY_THRESHOLD -> false + else -> currentOffset > sidebarWidthPx * 0.5f + } + scope.launch { + sidebarOffset.animateTo( + targetValue = if (shouldOpen) sidebarWidthPx else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + navHostViewModel.setTabletSidebarOpen(shouldOpen) + } + }, + onDragCancel = { + // Snap back to the current committed state + val target = if (isSidebarOpen) sidebarWidthPx else 0f + scope.launch { + sidebarOffset.animateTo( + targetValue = target, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + } + }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + velocityTracker.addPosition( + change.uptimeMillis, + change.position, + ) + scope.launch { + val newOffset = (sidebarOffset.value + dragAmount) + .coerceIn(0f, sidebarWidthPx) + sidebarOffset.snapTo(newOffset) + } + }, + ) + } + } else { + Modifier + } + + Layout( + content = { + // Slot 0: Sidebar -- always 320dp, slides from off-screen left to x=0 + Row(modifier = Modifier.fillMaxHeight()) { + SidebarScaffold( + viewModel = navHostViewModel, + onNewChat = { + // Skip navigation if already on the new chat screen + val currentRoute = navController.currentBackStackEntry?.destination?.route + if (currentRoute != NEW_CHAT_ROUTE) { + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + } + }, + onConversationClick = { conversationId -> + navController.navigateToChat(conversationId) + }, + onSettingsClick = { + navController.navigate(SETTINGS_TABBED_ROUTE) { + launchSingleTop = true + } + }, + onSettingsCategorySelected = { category -> + navController.navigate(category.toRoute()) { + launchSingleTop = true + } + }, + onAgentsClick = { + navController.navigate(TopLevelDestination.AGENTS.route) { + launchSingleTop = true + } + }, + onFilesClick = { + navController.navigate(TopLevelDestination.FILES.route) { + launchSingleTop = true + } + }, + modifier = Modifier + .width(SidebarWidth) + .fillMaxHeight(), + ) + VerticalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + // Slot 1: Main content -- resizes to fill remaining space + MainContent( + navController = navController, + navHostViewModel = navHostViewModel, + startDestination = startDestination, + isInAuthFlow = false, + banners = banners, + dismissedBannerIds = dismissedBannerIds, + onToggleDrawer = { + navHostViewModel.setTabletSidebarOpen(!isSidebarOpen) + }, + ) + }, + modifier = modifier.fillMaxSize().then(swipeModifier).clipToBounds(), + ) { measurables, constraints -> + val sidebarPx = SidebarWidth.roundToPx() + val animatedPx = sidebarOffset.value.toInt() + + // Sidebar: always measured at full 320dp width + val sidebarPlaceable = measurables[0].measure( + constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx), + ) + // Main content: resizes to fill screen minus the visible sidebar portion + val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0) + val mainPlaceable = measurables[1].measure( + constraints.copy(minWidth = mainWidth, maxWidth = mainWidth), + ) + + layout(constraints.maxWidth, constraints.maxHeight) { + // Sidebar slides: -320px (off-screen) -> 0px (fully visible) + sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0) + // Main content starts right after the visible sidebar portion + mainPlaceable.placeRelative(animatedPx, 0) + } + } + } else { + // Auth flow -- no sidebar, just main content + MainContent( + navController = navController, + navHostViewModel = navHostViewModel, + startDestination = startDestination, + isInAuthFlow = true, + banners = banners, + dismissedBannerIds = dismissedBannerIds, + onToggleDrawer = {}, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun MainContent( + navController: NavHostController, + navHostViewModel: NavHostViewModel, + startDestination: String, + isInAuthFlow: Boolean, + banners: List, + dismissedBannerIds: Set, + onToggleDrawer: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + if (!isInAuthFlow && banners.isNotEmpty()) { + BannerDisplay( + banners = banners, + dismissedIds = dismissedBannerIds, + onDismiss = navHostViewModel::dismissBanner, + modifier = Modifier.padding(top = 4.dp), + ) + } + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(300), + ) + }, + exitTransition = { + fadeOut(animationSpec = tween(150)) + }, + popEnterTransition = { + fadeIn( + initialAlpha = 0.5f, + animationSpec = tween(300, easing = LinearEasing), + ) + scaleIn( + initialScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + ) + }, + popExitTransition = { + slideOut( + targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + ) + }, + ) { + authGraph( + navController = navController, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navController.navigate(CHAT_GRAPH_ROUTE) { + popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } + } + }, + ) + chatGraph( + navController = navController, + onOpenDrawer = onToggleDrawer, + ) + conversationsGraph( + onConversationClick = { conversationId -> + navController.navigateToChat(conversationId) + }, + onNavigateToArchived = { + navController.navigate("conversations/archived") + }, + onNavigateBackFromArchived = { + navController.popBackStack() + }, + ) + agentsGraph( + onAgentClick = { agentId -> + navController.navigate("agents/$agentId") + }, + onBack = { navController.popBackStack() }, + onStartChat = { agentId -> + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + }, + onCreateAgent = { + navController.navigate(AGENT_EDITOR_CREATE_ROUTE) + }, + onEditAgent = { agentId -> + navController.navigate("agents/editor/$agentId") + }, + ) + filesGraph() + settingsGraph( + onLogout = { + navHostViewModel.logout() + navController.navigate(AUTH_GRAPH_ROUTE) { + popUpTo(0) { inclusive = true } + } + }, + onNavigateBack = { navController.popBackStack() }, + onNavigateToArchived = { + navController.navigate("conversations/archived") { + launchSingleTop = true + } + }, + onNavigateToSharedLinks = { + navController.navigate(SHARED_LINKS_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromSharedLinks = { + navController.popBackStack() + }, + onNavigateToPresets = { + navController.navigate(PRESET_MANAGER_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromPresets = { + navController.popBackStack() + }, + onNavigateToApiKeys = { + navController.navigate(API_KEYS_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromApiKeys = { + navController.popBackStack() + }, + ) + } + } +} diff --git a/app/src/main/kotlin/com/librechat/android/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/librechat/android/navigation/TopLevelDestination.kt new file mode 100644 index 0000000..bfafee7 --- /dev/null +++ b/app/src/main/kotlin/com/librechat/android/navigation/TopLevelDestination.kt @@ -0,0 +1,28 @@ +package com.librechat.android.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Destinations accessible from the navigation drawer footer. + * The primary navigation is now sidebar-first (drawer with conversation list), + * matching the web frontend pattern. Bottom nav has been removed entirely. + * + * Conversations are integrated into the drawer body (not a separate destination). + * Agents and Files are accessible via drawer footer links. + * Settings are accessed through the sidebar settings nav menu (see [SettingsCategory]). + */ +enum class TopLevelDestination( + val route: String, + val icon: ImageVector, + val label: String, +) { + CHAT("chat_graph", Icons.Default.Chat, "Chat"), + CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"), + AGENTS("agents", Icons.Default.SmartToy, "Agents"), + FILES("files", Icons.Default.Folder, "Files"), +} diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..214f7da492d4d176af704b96e1fa5454bca6df13 GIT binary patch literal 6444 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 a`GU~55&FBf2Kyfe4Iv|;C|)UM=>LD*xJY>b literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..214f7da492d4d176af704b96e1fa5454bca6df13 GIT binary patch literal 6444 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Π@&@HO&dE9qJ@^6X8X$n^!JTA+oo&b7yxqZ+WaQt)iIai zCImV|s+>%rWb-)TawDX^sGCM_tBZv8p<)c@pAxXR&11t`b@F+I(U$C?L9}TWy{5jJ zbdofy7&$!7OD_(j8a-YF(w}>`?-j-++;-xe%=byDater}f)uhqXbuyH`vzQ}Ek-73^QaD{ z7&}Q53HPPv3Grv}dVX>k8M^i}O6VeH_oXm_Xx?jgdPvgz`0>v@DYhpU|B# zuJzu(r<~fTjx!IW&niP>_!ArlBS@`r&*hG6AnV8$LT=Zr`jD+u7`*r_!j`PdJY*EU z4}*2X*UoL_NW<`7d#beNAPzi=`wkI|R*3UCu+mP*NGk4V?k5fcUs;X}jZbm%%n@i( z$+ZlVy>Ya=lpKAu(@Z_3A*j}O( z{PoUJ4%TC`9>n>8);HcJH#jW(aNX+B6g|1hC+k~l=S~p3TZV{g( z0!;>PWi_!R7g2^*__c8kTzU=zSDj68xPr-e5P@$EgR$YhbKBji!~MHQY0oR-P-=~# zebcM1{m|ld@qVf?Y~msp0V`T?v1k5**3`r%Pi6v;DrCXA3(25U&m_Wi)R_>8CYHh! zhJ+mj`fgo~mbO{yhMW%pBavZEPoC>eEppRKLkvYRot~x<1hnirNNGnuCg-DNb{1;7 zJ;YIN^l?C(IaaxrY56%_lTDnNLd_Jl@cc!z6-(H#kqT6Zzl|MAo@jo{G+F`eQXAzj^hR-8L0 zF?bq+sQ|Qc)wtB#&gs9pgizI_+E1wiZUb!y8V$nk5{G_kIex8<%{X4+doW~T)-LK` zX-mLOI|h|%L0}m+F`w=zaLfF5?&>?t3*{=MjC{&q z3m$X+a|@ZV3d++-%ADQ^PHD+yh>E!E3(Hg6PM{lAp67(J@;5JF!J>tP@wl6wlGDSO zY+Pl>cdwxD<_lfrjavgJO7VPE8G7>`KR9D9n?|eLy=REdtVgV3;$nuOd-o9w_inMtL_v+HpxaccKp@l z^!@%S20pWx`rOu}b(Yj9Mvj2Ev)gSuU$yKC4&)W%y4KoV|9>;OsI6h^=mk5)p=CIVX{PQTX!Y9ZoQEF@DZv@ zIv8HF5HE~SK8z6axAS^gRmgMgTYK3Xg%o_5En=efJiSl+l&&p5BQ9j{+(})tw!~Aa zzGOS;K+B^QK7R4(TzUPkIPPl^(oD9_csE6=ea#pC@kYAZTG@KiTKVYr842IWGc_?S+CW(>*4D; zZQ1PPU~LBOA3p+35GoljUpk{m5Lv8jRbA0mZmBi8UjX!P#Bw@KKtP~?M zcng@M$YZr-NJp$K%;VPV9PTXdXI*80jv#Pnosw4T-9=9Q;Z_!Z?*R*{dJy=D|CB-^lwT3-_t_-b9?yc4UgbAA|gL< z!qw8_NEAKgzL9Ly8mO?9^|H`Mh_D?UT*j|`8(mc`YT>&&OjA1xL#Annj?)OgP zHCyH4aHq>Y-M;UJ7T;LP#em||uXDjQkB}Lu5&Ail3?kO)-mN7sX&yRzy13-3)m(bx zuQ7l5T({S|3HGD_G@I6aw!s10;BGyi-_c#%YYy-obATM0S;|WqH?0^93@a)zm#*8& zi1sKK@{UeM4DvjRr2-ubx;XVC3s}D9ELQyba!TE$3B#qwIa%Z7Qt$M?5+()kiXErV z)*YbR<2}4#Ys^y_*D}kHR~N6wmQUI?)@4p*j?PdSt1>b{AQ%ggu_JBt+BTBhz1 zr*p=%2JHg@FgXfh^pDIiWn%hm-GP$I>!Dbxa(q#DakcK80De3_iqURkE&aZF&xW56 zfXN^xUn4elH6V-TRFygAy>7|rtGaLM-@n03U-m@)-A3M!j`xzikMXcPlW6=huKx!N WkE7v@*ysEJ00009x;&s literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..06a1570fda4e74dde814d3047ef5a120bf4ebef1 GIT binary patch literal 6665 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 D@OYH# literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..aff148919dda04e690202d2403a847b361ff5d49 GIT binary patch literal 3652 zcmV-K4!iM*P)RM0BvcWd^0X7DwK!O_x9ycWk1%|YwBm+!no9QGJT0#rSFfdLZ z8B%B)TGO}$LK8X-?ZA|@9g}v_nt=`j*w_$^Z6Men;YVysvMlLd-N){p)A`Qsl_gmi z8^~nXnLCnp?>*=Hf8YQAJ=Nqm;PmK+;p3AhAJ=ai6`!TC;&L) zf!%|KyEfE#^}zcB;D7Mcu#O(nl_egRm$eG5;cqt9u;Z3(`w{@}_4+^n7-O-<;`th- zlr!{rA@4hE_?ImWXi>bJ0`QLWnO_)yIJRgl9#tSOVDM0ty?e$uJXGVzk%j|Pnw6!a zGe_?!MdtM6`N5_JM@B8Vg5s?qLp8MI1LdN2Jn$bF0P(nZ+DqN`;`96Y$%fr*dS)MQ z?jGat;X1XtA+j2S!fH(>7f@=?amMlvwy!v!AfKhH0K?;!1EZD&?Fr9LHV`KbAdU^5 zmuUaL|Lrv%{?1mmJo|IX^%&px39>%fj88TfU{M$)AT*JM@?e>#b`~(pvINR9r_E#A z+Y#k3iDDR8w6A_KSxCZbV(W~5?d1df-lzVVn?Cm#Tb}MGpVf4?XDJnZ0$-u6!NxH* zHW0;v+HI88hL(?5n9D9w{GT@mkznl3N25jw4M+Ex1hCXPx0tKZ03s> zJ;ZbWvy1k&EUm3R%Es8Hq1K?R!L#CbM<91k#}PJ{rE$t^qDI85f~F+^mGODz`2kk1 zeu4)ce9h5vrY!cJ0VIoAlvdpPCqL%T{@`(xiD{da#YPdtiNPB2vPq0}aF^-@2-1zT2^zDsw zmhy-M#CR=HaSXSUeosJnlkcnKndvH_GV)==k{(UDo(MNWlTn7*-5Kt=XB&TX^Rswb zIm(I+?-@XBjLYp0e{&=My8bD8N<~K%iGXq*k0+>-7@im^#;YbEG2X;@)f(DH;8~YC zmiB1&kC?;)6FX2QikaP$p&P+{cOPa>DBoeAKpUwxjj(WDr@lOARUAcaaR9{=`}^zGV9OFn>D zHkst{CPmV$lYnYQLmU8pwMJt}FXfAtqZ&`d z15f>MGlK(zg2wdzu>cJvgW$?y5BV&9%Jk3G8E*))?DjsYOWN}}-Wqg%)-?J~~Mf|LmN zX!c3|ZvLK#+n`iZxGEn#J@LM1!G~I8x#G;66Yqg?OeU|;l?r~X=5l{C{dy?~**lkk z|9Jc@=Y7w6mk2oSE5*S#2kHN5KRG{ei%icliT9hLO+lsJbHWWnOh=yT*>llRhzh_; zBJkn2&&=`T-8Ik#d`Π@&@HO&dE9qJ@^6X8X$n^!JTA+oo&b7yxqZ+WaQt)iIai zCImV|s+>%rWb-)TawDX^sGCM_tBZv8p<)c@pAxXR&11t`b@F+I(U$C?L9}TWy{5jJ zbdofy7&$!7OD_(j8a-YF(w}>`?-j-++;-xe%=byDater}f)uhqXbuyH`vzQ}Ek-73^QaD{ z7&}Q53HPPv3Grv}dVX>k8M^i}O6VeH_oXm_Xx?jgdPvgz`0>v@DYhpU|B# zuJzu(r<~fTjx!IW&niP>_!ArlBS@`r&*hG6AnV8$LT=Zr`jD+u7`*r_!j`PdJY*EU z4}*2X*UoL_NW<`7d#beNAPzi=`wkI|R*3UCu+mP*NGk4V?k5fcUs;X}jZbm%%n@i( z$+ZlVy>Ya=lpKAu(@Z_3A*j}O( z{PoUJ4%TC`9>n>8);HcJH#jW(aNX+B6g|1hC+k~l=S~p3TZV{g( z0!;>PWi_!R7g2^*__c8kTzU=zSDj68xPr-e5P@$EgR$YhbKBji!~MHQY0oR-P-=~# zebcM1{m|ld@qVf?Y~msp0V`T?v1k5**3`r%Pi6v;DrCXA3(25U&m_Wi)R_>8CYHh! zhJ+mj`fgo~mbO{yhMW%pBavZEPoC>eEppRKLkvYRot~x<1hnirNNGnuCg-DNb{1;7 zJ;YIN^l?C(IaaxrY56%_lTDnNLd_Jl@cc!z6-(H#kqT6Zzl|MAo@jo{G+F`eQXAzj^hR-8L0 zF?bq+sQ|Qc)wtB#&gs9pgizI_+E1wiZUb!y8V$nk5{G_kIex8<%{X4+doW~T)-LK` zX-mLOI|h|%L0}m+F`w=zaLfF5?&>?t3*{=MjC{&q z3m$X+a|@ZV3d++-%ADQ^PHD+yh>E!E3(Hg6PM{lAp67(J@;5JF!J>tP@wl6wlGDSO zY+Pl>cdwxD<_lfrjavgJO7VPE8G7>`KR9D9n?|eLy=REdtVgV3;$nuOd-o9w_inMtL_v+HpxaccKp@l z^!@%S20pWx`rOu}b(Yj9Mvj2Ev)gSuU$yKC4&)W%y4KoV|9>;OsI6h^=mk5)p=CIVX{PQTX!Y9ZoQEF@DZv@ zIv8HF5HE~SK8z6axAS^gRmgMgTYK3Xg%o_5En=efJiSl+l&&p5BQ9j{+(})tw!~Aa zzGOS;K+B^QK7R4(TzUPkIPPl^(oD9_csE6=ea#pC@kYAZTG@KiTKVYr842IWGc_?S+CW(>*4D; zZQ1PPU~LBOA3p+35GoljUpk{m5Lv8jRbA0mZmBi8UjX!P#Bw@KKtP~?M zcng@M$YZr-NJp$K%;VPV9PTXdXI*80jv#Pnosw4T-9=9Q;Z_!Z?*R*{dJy=D|CB-^lwT3-_t_-b9?yc4UgbAA|gL< z!qw8_NEAKgzL9Ly8mO?9^|H`Mh_D?UT*j|`8(mc`YT>&&OjA1xL#Annj?)OgP zHCyH4aHq>Y-M;UJ7T;LP#em||uXDjQkB}Lu5&Ail3?kO)-mN7sX&yRzy13-3)m(bx zuQ7l5T({S|3HGD_G@I6aw!s10;BGyi-_c#%YYy-obATM0S;|WqH?0^93@a)zm#*8& zi1sKK@{UeM4DvjRr2-ubx;XVC3s}D9ELQyba!TE$3B#qwIa%Z7Qt$M?5+()kiXErV z)*YbR<2}4#Ys^y_*D}kHR~N6wmQUI?)@4p*j?PdSt1>b{AQ%ggu_JBt+BTBhz1 zr*p=%2JHg@FgXfh^pDIiWn%hm-GP$I>!Dbxa(q#DakcK80De3_iqURkE&aZF&xW56 zfXN^xUn4elH6V-TRFygAy>7|rtGaLM-@n03U-m@)-A3M!j`xzikMXcPlW6=huKx!N WkE7v@*ysEJ00009x;&s literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d948b2060d5ba793fa0839f1fc8469bb49055528 GIT binary patch literal 9243 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

Zv-}_A}R>9lI^d=kj5m` z5EXc^Uxvi{4AnF{!q=pEz=$Vt-pmAZe;NtD+>}R>%#VIrPC2ms_YvQ|d6Q%&-j?(&w z;Q2LMG1cvM&4LVn+A~`USV!iEvY)iaiqOw|9_d}(5L7eJPP0<{Vn8S{q$mS4N~1=Y z=lbZ{H4@d`g9E@QLbhiBf%N!8OwK=QMn>QK#26CF;tE6=5?L9~gEitR(?@xn#(3Cl z#f(P-VcQbJp2P>*WBBllIAinE4LNuuAj6P^F+VmE-Mw)7hS-#iTk0I6WR)2bN!!wn zn1Zru5vUq~;l3OKcuF7@?0h8<%rG_$r+Gni^8(``4LF)d93Q6NMrYI?INF&Fq494J&h?|4F~h-lNSIyn-lj;UJ7 zsu+@p{P6Q=`pPy{2H5-;=VSQPX`K7tbI}P}___{ZoQT$bPe@$9uofv>pf4ZbXYb^Z ziR%s_jhQh&$vyo@?dakTy0HFSsoq?ciZ0JljcEqs^OUS6KN+X(w5eJ}1s$zgp-P4{ z9tgsig1n2Z*PVmC=goyPRDkV!7&>_d3ey|-Jqn^qNjHfRKfsdKI4+r~sOQhN=CF3a zMcR}ejSnCI;x;ntx4|A!eM4hA@+Q^{Ru00%+UBYTAevyR%;T#AA<7=CkfxU2BZ0(o zIrLt9D!MK|6|vzQ^L4WSekOq(pFJCHTP;VyM)Ro~#+4G+El}E@3nYH<)({dDc6GT2NG|A@P^n1o` zQh|khLUf-M`h@_2W5dqo(Z6^$wtVqyrRiFD6!}_y5re1Bs9U=U&Ty84u@V(F_hs7X z^=T8Bvp^|O8g&f~s{C0E1p5WR45!BSdL)F+{XeF1R0)LWO_fIe{$m9}0hI4y+b!oH zyPyS5!G+|)x=t~hdN22mXoqJ51HO-orl+xVs$xEmZp~t4Uja4bd`qFU8bywPlgpuT z&0BghaJkc}(CZCY7mfMn7}$Bf#j=nfJ9eGB#N4QQBJ&kT8AQE-$Y zji#yLaS_UCafQo8p}hfHZaSOIOITPpW`^%nN4)wq_4QDH&!=bCVL_d$(fwr02>!j_ zMaE=3LkFz$KLDCmy}{jbVF$#x>@1Rls%FlD?x1d-l0s`+W^zps<%67rgU(ZC!v}T3 z?ZcED07J5U51X$$3%Oa1+~L6Ofkt={p@+qG3c7*-CpN}#^DzzV^!K^}zP)|`2})I% z^TKl^f`o&_u0GVR-UK8ac9@~CCt=QlsRt}Pp#|}T%`&To4OLf?r1b&-%vwB)N5GgN zINH(f2ZV6H1z3dw2F{p`ojMcvP$!Y;>GM2X-cgGS+f>GL&0qoF z+cJpyxZr5mfMtG2_=$lbG(Eo#(jo01FUM022$4(cG&@~~+B0jCF`WGV=~`rB@&K^( zT+SIw=AyZy1p#%X5eJWI_^1fE5(s4vZ3#G@iya?-ANtSeL@Z0%AE|PgexFF22@w~{ z?;leW!`J3DhjX=VTfY}Wo}O?KNlhr~86Zf;(DcF^NOt!DaTRmyf8t0bhNNg}`A7(u z0r^EAt<&pp{E}%}W|Ig2eG;`LCTkLy|B(|=6uEFom+>OSeS$!

LRcR1&+cTm-8? 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

Zv-}_A}R>9lI^d=kj5m` z5EXc^Uxvi{4AnF{!q=pEz=$Vt-pmAZe;NtD+>}R>%#VIrPC2ms_YvQ|d6Q%&-j?(&w z;Q2LMG1cvM&4LVn+A~`USV!iEvY)iaiqOw|9_d}(5L7eJPP0<{Vn8S{q$mS4N~1=Y z=lbZ{H4@d`g9E@QLbhiBf%N!8OwK=QMn>QK#26CF;tE6=5?L9~gEitR(?@xn#(3Cl z#f(P-VcQbJp2P>*WBBllIAinE4LNuuAj6P^F+VmE-Mw)7hS-#iTk0I6WR)2bN!!wn zn1Zru5vUq~;l3OKcuF7@?0h8<%rG_$r+Gni^8(``4LF)d93Q6NMrYI?INF&Fq494J&h?|4F~h-lNSIyn-lj;UJ7 zsu+@p{P6Q=`pPy{2H5-;=VSQPX`K7tbI}P}___{ZoQT$bPe@$9uofv>pf4ZbXYb^Z ziR%s_jhQh&$vyo@?dakTy0HFSsoq?ciZ0JljcEqs^OUS6KN+X(w5eJ}1s$zgp-P4{ z9tgsig1n2Z*PVmC=goyPRDkV!7&>_d3ey|-Jqn^qNjHfRKfsdKI4+r~sOQhN=CF3a zMcR}ejSnCI;x;ntx4|A!eM4hA@+Q^{Ru00%+UBYTAevyR%;T#AA<7=CkfxU2BZ0(o zIrLt9D!MK|6|vzQ^L4WSekOq(pFJCHTP;VyM)Ro~#+4G+El}E@3nYH<)({dDc6GT2NG|A@P^n1o` zQh|khLUf-M`h@_2W5dqo(Z6^$wtVqyrRiFD6!}_y5re1Bs9U=U&Ty84u@V(F_hs7X z^=T8Bvp^|O8g&f~s{C0E1p5WR45!BSdL)F+{XeF1R0)LWO_fIe{$m9}0hI4y+b!oH zyPyS5!G+|)x=t~hdN22mXoqJ51HO-orl+xVs$xEmZp~t4Uja4bd`qFU8bywPlgpuT z&0BghaJkc}(CZCY7mfMn7}$Bf#j=nfJ9eGB#N4QQBJ&kT8AQE-$Y zji#yLaS_UCafQo8p}hfHZaSOIOITPpW`^%nN4)wq_4QDH&!=bCVL_d$(fwr02>!j_ zMaE=3LkFz$KLDCmy}{jbVF$#x>@1Rls%FlD?x1d-l0s`+W^zps<%67rgU(ZC!v}T3 z?ZcED07J5U51X$$3%Oa1+~L6Ofkt={p@+qG3c7*-CpN}#^DzzV^!K^}zP)|`2})I% z^TKl^f`o&_u0GVR-UK8ac9@~CCt=QlsRt}Pp#|}T%`&To4OLf?r1b&-%vwB)N5GgN zINH(f2ZV6H1z3dw2F{p`ojMcvP$!Y;>GM2X-cgGS+f>GL&0qoF z+cJpyxZr5mfMtG2_=$lbG(Eo#(jo01FUM022$4(cG&@~~+B0jCF`WGV=~`rB@&K^( zT+SIw=AyZy1p#%X5eJWI_^1fE5(s4vZ3#G@iya?-ANtSeL@Z0%AE|PgexFF22@w~{ z?;leW!`J3DhjX=VTfY}Wo}O?KNlhr~86Zf;(DcF^NOt!DaTRmyf8t0bhNNg}`A7(u z0r^EAt<&pp{E}%}W|Ig2eG;`LCTkLy|B(|=6uEFom+>OSeS$!

LRcR1&+cTm-8? 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

8Bya-i(54$6 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..2bd947b77ba66c5e45bf6bea109056fd7d3b1e2b GIT binary patch literal 16248 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

8Bya-i(54$6 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..13cfd4db41fb86ae0ceef0d0bf516cf091cba0b9 GIT binary patch literal 44651 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 bw_fl)F&XQ`ngy7U5D#T}4Y@iQtEm406i}EN literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..9d4ca150b1fa3b4257bab6dde950b19436df97f2 GIT binary patch literal 10925 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..7ca5425 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,10 @@ + + + + + #0D0D0D + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..df7e054 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1A0A3E + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f645fe7 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,31 @@ + + + LibreChat + + + No internet connection + + + New chat + Favorites + No conversations found + Remove bookmark + Bookmark + Agents + Files + Settings + + + Search + Clear search + Back to conversations + + + Search conversations\u2026 + + + Backend Version Mismatch + This app was built for LibreChat v%1$s, but your server is running v%2$s. Some features may not work correctly. + Dismiss + Don\'t warn again + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d4a50ad --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + + #FFFFFF + diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..0c46556 --- /dev/null +++ b/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..7742e97 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/build-logic/CLAUDE.md b/build-logic/CLAUDE.md new file mode 100644 index 0000000..33b13b8 --- /dev/null +++ b/build-logic/CLAUDE.md @@ -0,0 +1,37 @@ +# Build Logic + +Convention plugins that apply consistent Gradle configuration across all modules. + +## Convention Plugins (7 total) + +| Plugin ID | Class | What it does | +|-----------|-------|-------------| +| `librechat.android.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 | +| `librechat.android.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config | +| `librechat.android.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 | +| `librechat.android.hilt` | `AndroidHiltConventionPlugin` | Hilt + KSP annotation processing | +| `librechat.android.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + hilt + serialization | +| `librechat.android.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config | +| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency | + +## Critical: apply() Signature + +Convention plugin `apply()` MUST use: +```kotlin +override fun apply(target: Project) { + with(target) { ... } +} +``` +Do NOT use `override fun apply(target: Project) = with(target) { ... }` -- that returns +the `with` result instead of `Unit`, which causes a Gradle error. + +## Version Catalog + +All dependency versions live in `gradle/libs.versions.toml`. Never hardcode versions in +build.gradle.kts files. Use `libs.` accessors (e.g., `libs.hilt.android`, `libs.ktor.client.core`). + +## Feature Module Convention + +Feature modules use `id("librechat.android.feature")` which auto-applies library, compose, +hilt, and serialization. They only need to add feature-specific dependencies. +Feature modules depend on `:core:*` only, never on other feature modules. diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 0000000..16685bb --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + compileOnly(libs.android.gradlePlugin) + compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.compose.gradlePlugin) + compileOnly(libs.ksp.gradlePlugin) + compileOnly(libs.room.gradlePlugin) +} + +gradlePlugin { + plugins { + register("androidApplication") { + id = "librechat.android.application" + implementationClass = "AndroidApplicationConventionPlugin" + } + register("androidLibrary") { + id = "librechat.android.library" + implementationClass = "AndroidLibraryConventionPlugin" + } + register("androidCompose") { + id = "librechat.android.compose" + implementationClass = "AndroidComposeConventionPlugin" + } + register("androidHilt") { + id = "librechat.android.hilt" + implementationClass = "AndroidHiltConventionPlugin" + } + register("androidFeature") { + id = "librechat.android.feature" + implementationClass = "AndroidFeatureConventionPlugin" + } + register("androidRoom") { + id = "librechat.android.room" + implementationClass = "AndroidRoomConventionPlugin" + } + register("kotlinSerialization") { + id = "librechat.kotlin.serialization" + implementationClass = "KotlinSerializationConventionPlugin" + } + } +} diff --git a/build-logic/convention/gradle.properties b/build-logic/convention/gradle.properties new file mode 100644 index 0000000..f5f7436 --- /dev/null +++ b/build-logic/convention/gradle.properties @@ -0,0 +1,3 @@ +# build-logic is a standalone project; mirror root settings +org.gradle.parallel=true +org.gradle.caching=true diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt new file mode 100644 index 0000000..09e884b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -0,0 +1,46 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.application") + pluginManager.apply("org.jetbrains.kotlin.android") + + extensions.configure { + compileSdk = 35 + defaultConfig { + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17 + targetCompatibility = org.gradle.api.JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + buildTypes { + release { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + } + + dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get()) + dependencies.add("testImplementation", libs.findBundle("testing").get()) + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt new file mode 100644 index 0000000..dab5822 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt @@ -0,0 +1,59 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +class AndroidComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + + // Stability configuration — marks core model classes as @Stable so Compose + // can skip recomposition when parameters haven't changed. + val stabilityConfig = rootProject.file("compose-stability.conf") + if (stabilityConfig.exists()) { + tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.addAll( + "-P", "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${stabilityConfig.absolutePath}", + ) + } + } + } + + // Compose compiler metrics/reports (opt-in via -PenableComposeCompilerReports=true) + if (providers.gradleProperty("enableComposeCompilerReports").orNull == "true") { + val reportsDir = layout.buildDirectory.dir("compose_metrics").get().asFile.absolutePath + tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.addAll( + "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$reportsDir", + "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$reportsDir", + ) + } + } + } + + extensions.findByType(com.android.build.gradle.LibraryExtension::class.java)?.apply { + buildFeatures.compose = true + } + extensions.findByType(com.android.build.api.dsl.ApplicationExtension::class.java)?.apply { + buildFeatures.compose = true + } + + dependencies { + val bom = libs.findLibrary("compose-bom").get() + add("implementation", platform(bom)) + add("implementation", libs.findBundle("compose").get()) + add("debugImplementation", libs.findLibrary("compose-ui-tooling").get()) + add("androidTestImplementation", platform(bom)) + add("androidTestImplementation", libs.findLibrary("compose-ui-test").get()) + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt new file mode 100644 index 0000000..1392ff1 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -0,0 +1,27 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class AndroidFeatureConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("librechat.android.library") + pluginManager.apply("librechat.android.compose") + pluginManager.apply("librechat.android.hilt") + + dependencies { + add("implementation", project(":core:ui")) + add("implementation", project(":core:data")) + add("implementation", project(":core:model")) + add("implementation", project(":core:common")) + add("implementation", libs.findLibrary("hilt-navigation-compose").get()) + add("implementation", libs.findLibrary("navigation-compose").get()) + add("implementation", libs.findBundle("lifecycle").get()) + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt new file mode 100644 index 0000000..ecf2b6d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -0,0 +1,21 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class AndroidHiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.dagger.hilt.android") + pluginManager.apply("com.google.devtools.ksp") + + dependencies { + add("implementation", libs.findLibrary("hilt-android").get()) + add("ksp", libs.findLibrary("hilt-compiler").get()) + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt new file mode 100644 index 0000000..32b2695 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,36 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.library") + pluginManager.apply("org.jetbrains.kotlin.android") + + extensions.configure { + compileSdk = 35 + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17 + targetCompatibility = org.gradle.api.JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + testOptions { + unitTests.isReturnDefaultValues = true + } + } + + dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get()) + dependencies.add("testImplementation", libs.findBundle("testing").get()) + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt new file mode 100644 index 0000000..1337a42 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -0,0 +1,27 @@ +import androidx.room.gradle.RoomExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidRoomConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("androidx.room") + pluginManager.apply("com.google.devtools.ksp") + + extensions.configure { + schemaDirectory("$projectDir/schemas") + } + + dependencies { + add("implementation", libs.findBundle("room").get()) + add("ksp", libs.findLibrary("room-compiler").get()) + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/KotlinSerializationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KotlinSerializationConventionPlugin.kt new file mode 100644 index 0000000..f92e173 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KotlinSerializationConventionPlugin.kt @@ -0,0 +1,19 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class KotlinSerializationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.plugin.serialization") + + dependencies { + add("implementation", libs.findLibrary("kotlinx-serialization-json").get()) + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties new file mode 100644 index 0000000..4f996f1 --- /dev/null +++ b/build-logic/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.parallel=true +org.gradle.caching=true diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..875164f --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,15 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..fbf2d97 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.room) apply false +} diff --git a/compose-stability.conf b/compose-stability.conf new file mode 100644 index 0000000..3164ae6 --- /dev/null +++ b/compose-stability.conf @@ -0,0 +1,44 @@ +// Compose Compiler Stability Configuration +// Classes listed here are treated as @Stable by the Compose compiler, +// enabling skip optimizations for composable parameters of these types. +// See: https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file + +// Core model classes (all are data classes with val-only fields, never mutated after creation) +com.librechat.android.core.model.Message +com.librechat.android.core.model.Conversation +com.librechat.android.core.model.MessageContentPart +com.librechat.android.core.model.ToolCall +com.librechat.android.core.model.ToolCallFunction +com.librechat.android.core.model.FileObject +com.librechat.android.core.model.FileReference +com.librechat.android.core.model.Attachment +com.librechat.android.core.model.Feedback +com.librechat.android.core.model.EndpointConfig +com.librechat.android.core.model.Preset +com.librechat.android.core.model.ConversationTag +com.librechat.android.core.model.Agent +com.librechat.android.core.model.PromptGroup +com.librechat.android.core.model.Prompt +com.librechat.android.core.model.mcp.McpServer +com.librechat.android.core.model.mcp.McpTool +com.librechat.android.core.model.mcp.McpApiKeyConfig +com.librechat.android.core.model.mcp.McpOAuthConfig +com.librechat.android.core.model.mcp.McpConnectionStatusResponse +com.librechat.android.core.model.mcp.McpServerStatus +com.librechat.android.core.model.Banner +com.librechat.android.core.model.speech.TtsVoice +com.librechat.android.core.model.ApiKey +com.librechat.android.core.model.SharedLink +com.librechat.android.core.model.AgentTool +com.librechat.android.core.model.AgentAction +com.librechat.android.core.model.AgentCategory +com.librechat.android.core.model.UserFavorite +com.librechat.android.core.model.Memory +com.librechat.android.core.model.StartupConfig +com.librechat.android.core.model.ModelSpecs +com.librechat.android.core.model.InterfaceConfig + +// Kotlin stdlib collections (we only use immutable instances in practice) +kotlin.collections.List +kotlin.collections.Map +kotlin.collections.Set diff --git a/core/common/CLAUDE.md b/core/common/CLAUDE.md new file mode 100644 index 0000000..87f9aa5 --- /dev/null +++ b/core/common/CLAUDE.md @@ -0,0 +1,41 @@ +# core:common + +Pure Kotlin utilities shared by all modules. This is the lowest layer -- no other `:core:*` module is a dependency. + +## What This Module Provides + +- **Result sealed class** (`result/Result.kt`): `Success`, `Error(exception, message)`, `Loading`. Used by repositories and ViewModels to propagate outcomes. +- **Dispatcher DI** (`di/DispatcherModule.kt`): `@Dispatcher(IO)`, `@Dispatcher(Default)`, `@Dispatcher(Main)` qualifiers for Hilt injection. Always inject dispatchers -- never hardcode `Dispatchers.IO`. +- **CoroutineScope DI** (`di/CoroutineScopeModule.kt`): `@ApplicationScope` for work that outlives ViewModels. +- **Extensions** (`extensions/`): `StringExt`, `DateExt`, `FlowExt` (includes `retryWithBackoff`, `throttleFirst`). +- **ConnectivityObserver**: Wraps Android `ConnectivityManager.NetworkCallback` to detect network changes. Used by SSE reconnection logic. + +## safeApiCall Pattern + +All repository implementations use this to wrap network calls: + +```kotlin +suspend fun safeApiCall(block: suspend () -> T): Result = + try { + Result.Success(block()) + } catch (e: ClientRequestException) { // 4xx + Result.Error(e, parseErrorMessage(e.response.bodyAsText())) + } catch (e: ServerResponseException) { // 5xx + Result.Error(e, "Server error. Please try again.") + } catch (e: HttpRequestTimeoutException) { + Result.Error(e, "Request timed out.") + } catch (e: IOException) { + Result.Error(e, "Network unavailable. Check your connection.") + } catch (e: SerializationException) { + Result.Error(e, "Unexpected response format.") + } +``` + +This lives here (not in `:core:network`) because repositories in `:core:data` call it. + +## Rules + +- **Pure Kotlin preferred.** Minimal Android dependencies (only what ConnectivityObserver and Hilt require). +- **No network or data dependencies.** This module must not depend on `:core:network`, `:core:data`, or `:core:model`. +- Dependencies: `coroutines-core`, `coroutines-android`, Hilt. +- Convention plugin: `librechat.android.library` + `librechat.android.hilt`. diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 0000000..af8c2eb --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("librechat.android.library") + id("librechat.android.hilt") +} + +android { + namespace = "com.librechat.android.core.common" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt new file mode 100644 index 0000000..cf719b8 --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt @@ -0,0 +1,75 @@ +package com.librechat.android.core.common + +/** + * Backend version compatibility constants and comparison utilities. + * + * The LibreChat backend does not currently expose a version endpoint. + * The version is determined by: + * 1. A `version` field in the `/api/config` response (if the backend adds one in the future) + * 2. Parsing the `customFooter` field for a `LibreChat vX.Y.Z` pattern + * (when customFooter is null, the web frontend displays the default version from Constants.VERSION) + * + * If the version cannot be determined, the check is silently skipped. + */ +object BackendVersion { + + /** + * The LibreChat backend version this app was built and tested against. + * Matches the VERSION constant from the official LibreChat repo's + * `packages/data-provider/src/config.ts` and `package.json`. + */ + const val SUPPORTED_BACKEND_VERSION = "0.8.2" + + /** + * Represents a parsed semantic version (major.minor.patch). + */ + data class SemanticVersion( + val major: Int, + val minor: Int, + val patch: Int, + ) { + override fun toString(): String = "$major.$minor.$patch" + } + + /** + * Parses a version string like "0.8.2", "v0.8.2", "0.8", or "v0.8" into a [SemanticVersion]. + * Returns null if the string cannot be parsed. + */ + fun parse(version: String): SemanticVersion? { + val cleaned = version.trimStart('v', 'V').trim() + val parts = cleaned.split('.') + if (parts.size < 2) return null + val major = parts[0].toIntOrNull() ?: return null + val minor = parts[1].toIntOrNull() ?: return null + val patch = if (parts.size >= 3) parts[2].toIntOrNull() ?: 0 else 0 + return SemanticVersion(major, minor, patch) + } + + /** + * Checks whether two versions are compatible. + * A mismatch in major or minor version is considered incompatible. + * Patch differences are ignored (considered compatible). + * + * @return true if the versions are compatible (same major and minor), false otherwise. + * Returns true if either version cannot be parsed (fail-open). + */ + fun isCompatible(supported: String, actual: String): Boolean { + val supportedVersion = parse(supported) ?: return true + val actualVersion = parse(actual) ?: return true + return supportedVersion.major == actualVersion.major && + supportedVersion.minor == actualVersion.minor + } + + /** + * Extracts a version string from a LibreChat customFooter value. + * The default footer format is: `[LibreChat vX.Y.Z](https://librechat.ai) - ...` + * Also handles variations like `LibreChat v0.8.2` without markdown links. + * + * @return The extracted version string (without 'v' prefix), or null if not found. + */ + fun extractVersionFromFooter(footer: String?): String? { + if (footer == null) return null + val regex = Regex("""LibreChat\s+v?(\d+\.\d+(?:\.\d+)?)""", RegexOption.IGNORE_CASE) + return regex.find(footer)?.groupValues?.get(1) + } +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt new file mode 100644 index 0000000..a124591 --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.common + +object EndpointConstants { + const val AGENTS = "agents" +} + +object ToolConstants { + const val WEB_SEARCH = "web_search" + const val CODE_INTERPRETER = "code_interpreter" + const val FILE_SEARCH = "file_search" + const val EXECUTE_CODE = "execute_code" +} + +object ChatLayoutConstants { + const val THREAD = "thread" + const val TWO_SIDED = "two_sided" +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/di/CoroutineScopeModule.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/di/CoroutineScopeModule.kt new file mode 100644 index 0000000..7f04517 --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/di/CoroutineScopeModule.kt @@ -0,0 +1,26 @@ +package com.librechat.android.core.common.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineScopeModule { + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope( + @Dispatcher(LibreChatDispatchers.Default) dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/di/DispatcherModule.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/di/DispatcherModule.kt new file mode 100644 index 0000000..ed5301d --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/di/DispatcherModule.kt @@ -0,0 +1,35 @@ +package com.librechat.android.core.common.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class Dispatcher(val dispatcher: LibreChatDispatchers) + +enum class LibreChatDispatchers { + IO, + Default, + Main, +} + +@Module +@InstallIn(SingletonComponent::class) +object DispatcherModule { + @Provides + @Dispatcher(LibreChatDispatchers.IO) + fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Dispatcher(LibreChatDispatchers.Default) + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @Dispatcher(LibreChatDispatchers.Main) + fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/DateExt.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/DateExt.kt new file mode 100644 index 0000000..8cb51fa --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/DateExt.kt @@ -0,0 +1,26 @@ +package com.librechat.android.core.common.extensions + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +fun String.toInstantOrNull(): Instant? = + try { Instant.parse(this) } catch (_: Exception) { null } + +fun Instant.toRelativeDateGroup(): String { + val today = LocalDate.now() + val date = atZone(ZoneId.systemDefault()).toLocalDate() + val daysBetween = ChronoUnit.DAYS.between(date, today) + return when { + daysBetween == 0L -> "Today" + daysBetween == 1L -> "Yesterday" + daysBetween <= 7L -> "Previous 7 Days" + daysBetween <= 30L -> "Previous 30 Days" + else -> date.format(DateTimeFormatter.ofPattern("MMMM yyyy")) + } +} + +fun Long.toRelativeDateGroup(): String = + Instant.ofEpochMilli(this).toRelativeDateGroup() diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt new file mode 100644 index 0000000..c22480e --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt @@ -0,0 +1,31 @@ +package com.librechat.android.core.common.extensions + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.retryWhen +import kotlin.math.pow + +fun Flow.retryWithBackoff( + maxRetries: Int = 3, + initialDelay: Long = 1000L, + maxDelay: Long = 30000L, + factor: Double = 2.0, +): Flow = retryWhen { cause, attempt -> + if (attempt >= maxRetries) return@retryWhen false + val delayMs = (initialDelay * factor.pow(attempt.toDouble())).toLong().coerceAtMost(maxDelay) + delay(delayMs) + true +} + +fun Flow.throttleFirst(periodMillis: Long): Flow = flow { + var lastTime = 0L + collect { value -> + val currentTime = System.currentTimeMillis() + if (currentTime - lastTime >= periodMillis) { + lastTime = currentTime + emit(value) + } + } +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/StringExt.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/StringExt.kt new file mode 100644 index 0000000..9ce2459 --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/StringExt.kt @@ -0,0 +1,16 @@ +package com.librechat.android.core.common.extensions + +fun String.trimTrailingSlash(): String = trimEnd('/') + +fun String.isValidUrl(): Boolean = + startsWith("http://") || startsWith("https://") + +fun String.ensureHttps(): String = when { + startsWith("https://") -> this + startsWith("http://") -> replaceFirst("http://", "https://") + else -> "https://$this" +} + +fun String.truncate(maxLength: Int, ellipsis: String = "..."): String = + if (length <= maxLength) this + else take(maxLength - ellipsis.length) + ellipsis diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt new file mode 100644 index 0000000..73f89b6 --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt @@ -0,0 +1,62 @@ +package com.librechat.android.core.common.network + +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConnectivityObserver @Inject constructor( + @ApplicationContext private val context: Context, +) { + // Permission is declared in app module's AndroidManifest.xml + @SuppressLint("MissingPermission") + val isConnected: Flow = callbackFlow { + val connectivityManager = context.getSystemService(ConnectivityManager::class.java) + + // Emit current state + val currentNetwork = connectivityManager.activeNetwork + val currentCapabilities = connectivityManager.getNetworkCapabilities(currentNetwork) + trySend(currentCapabilities.hasInternet()) + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + trySend(networkCapabilities.hasInternet()) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() + + private fun NetworkCapabilities?.hasInternet(): Boolean { + return this?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/result/Result.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/result/Result.kt new file mode 100644 index 0000000..c88fc5b --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/result/Result.kt @@ -0,0 +1,25 @@ +package com.librechat.android.core.common.result + +sealed interface Result { + data class Success(val data: T) : Result + data class Error(val exception: Throwable? = null, val message: String? = null) : Result + data object Loading : Result +} + +fun Result.isSuccess(): Boolean = this is Result.Success +fun Result.isError(): Boolean = this is Result.Error +fun Result.isLoading(): Boolean = this is Result.Loading + +fun Result.getOrNull(): T? = (this as? Result.Success)?.data +fun Result.getOrThrow(): T = when (this) { + is Result.Success -> data + is Result.Error -> throw exception ?: IllegalStateException(message ?: "Unknown error") + is Result.Loading -> throw IllegalStateException("Result is still loading") +} + +suspend fun safeApiCall(block: suspend () -> T): Result = + try { + Result.Success(block()) + } catch (e: Exception) { + Result.Error(e, e.message ?: "An unexpected error occurred") + } diff --git a/core/common/src/test/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt b/core/common/src/test/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt new file mode 100644 index 0000000..280cd15 --- /dev/null +++ b/core/common/src/test/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt @@ -0,0 +1,86 @@ +package com.librechat.android.core.common.result + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SafeApiCallTest { + + @Test + fun `safeApiCall returns Success on successful block`() = runTest { + val result = safeApiCall { "hello" } + assertThat(result).isInstanceOf(Result.Success::class.java) + assertThat((result as Result.Success).data).isEqualTo("hello") + } + + @Test + fun `safeApiCall returns Error on exception`() = runTest { + val result = safeApiCall { throw RuntimeException("boom") } + assertThat(result).isInstanceOf(Result.Error::class.java) + val error = result as Result.Error + assertThat(error.message).isEqualTo("boom") + assertThat(error.exception).isInstanceOf(RuntimeException::class.java) + } + + @Test + fun `isSuccess returns true for Success`() { + val result: Result = Result.Success("data") + assertThat(result.isSuccess()).isTrue() + assertThat(result.isError()).isFalse() + assertThat(result.isLoading()).isFalse() + } + + @Test + fun `isError returns true for Error`() { + val result: Result = Result.Error(message = "fail") + assertThat(result.isError()).isTrue() + assertThat(result.isSuccess()).isFalse() + } + + @Test + fun `isLoading returns true for Loading`() { + val result: Result = Result.Loading + assertThat(result.isLoading()).isTrue() + } + + @Test + fun `getOrNull returns data for Success`() { + val result: Result = Result.Success("data") + assertThat(result.getOrNull()).isEqualTo("data") + } + + @Test + fun `getOrNull returns null for Error`() { + val result: Result = Result.Error(message = "fail") + assertThat(result.getOrNull()).isNull() + } + + @Test + fun `getOrThrow returns data for Success`() { + val result: Result = Result.Success("data") + assertThat(result.getOrThrow()).isEqualTo("data") + } + + @Test + fun `getOrThrow throws for Error with exception`() { + val original = IllegalArgumentException("bad arg") + val result: Result = Result.Error(original, "bad arg") + try { + result.getOrThrow() + assertThat(false).isTrue() // Should not reach here + } catch (e: Exception) { + assertThat(e).isSameInstanceAs(original) + } + } + + @Test + fun `getOrThrow throws for Loading`() { + val result: Result = Result.Loading + try { + result.getOrThrow() + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).isEqualTo("Result is still loading") + } + } +} diff --git a/core/common/src/test/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt b/core/common/src/test/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt new file mode 100644 index 0000000..ab71728 --- /dev/null +++ b/core/common/src/test/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt @@ -0,0 +1,23 @@ +package com.librechat.android.core.common.test + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class TestDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/core/data/CLAUDE.md b/core/data/CLAUDE.md new file mode 100644 index 0000000..386fee1 --- /dev/null +++ b/core/data/CLAUDE.md @@ -0,0 +1,74 @@ +# core:data + +Room database, DataStore preferences, EncryptedSharedPreferences for tokens, and all repository implementations. This is the coordination layer between network and local storage. + +## What This Module Provides + +- **Room database** (`db/LibreChatDatabase.kt`): 6 entities, 6 DAOs, version 1, `exportSchema = true`. +- **Entities**: `ConversationEntity`, `MessageEntity`, `FileEntity`, `AgentEntity`, `PresetEntity`, `ConversationTagEntity`. Complex fields (lists, nested objects) stored as JSON strings via `Converters`. +- **DAOs**: `ConversationDao`, `MessageDao`, `FileDao`, `AgentDao`, `PresetDao`, `ConversationTagDao`. Read methods return `Flow` for reactive observation. +- **DataStore**: `ServerDataStore` (server URL prefs), `SettingsDataStore` (user preferences). +- **Token storage** (`datastore/TokenDataStore.kt`): Implements `TokenManager` from `:core:network` using `EncryptedSharedPreferences` with AES-256. Uses `Mutex` to ensure only one refresh runs at a time when multiple 401s arrive concurrently. +- **Repositories** (`repository/`): Interface + Impl for each domain: `AuthRepository`, `ConversationRepository`, `MessageRepository`, `ChatRepository`, `FileRepository`, `AgentRepository`, `PresetRepository`, `PromptRepository`, `TagRepository`, `ShareRepository`, `ConfigRepository`, `UserRepository`, `SettingsRepository`. +- **Entity mappers** (`db/mapper/EntityMapper.kt`): Convert between Room entities and domain models. +- **Sync manager** (`sync/ConversationSyncManager.kt`): Orchestrates cache invalidation. + +## Key Patterns + +### Repository: Interface + Impl + +```kotlin +interface ConversationRepository { + fun observeConversations(...): Flow>> + suspend fun updateTitle(id: String, title: String): Result +} +``` + +All impls are `@Inject constructor(api, dao, mapper, @Dispatcher(IO) dispatcher)`. + +### Read-Through Cache + +1. Emit cached data from Room immediately (first page). +2. Fetch fresh data from network via API service. +3. Upsert into Room. The Room `Flow` auto-emits the updated list. +4. On network error, the cached data remains visible; error is surfaced separately. + +### Token Refresh with Mutex + +```kotlin +private val refreshMutex = Mutex() +override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { + // Single refresh attempt, all concurrent callers wait on this lock +} +``` + +Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken Keystore implementations. On `KeyStoreException`, clear tokens and force re-login. + +### DataModule Bindings + +`DataModule` is a Hilt `@Module` that `@Binds` repository interfaces to their implementations and `@Provides` the Room database, DAOs, and DataStore instances. + +## Room TypeConverters + +`Converters.kt` handles JSON serialization for complex Room fields: `List`, `MessageContentPart` lists, `Feedback`, `FileReference` lists, etc. Uses the same `Json` instance from the Hilt graph. + +## Rules + +- Dependencies: `:core:network`, `:core:model`, `:core:common`, Room, DataStore, security-crypto, Ktor, kotlinx-serialization, Timber, Hilt. +- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.android.room` + `librechat.kotlin.serialization`. +- Repositories must use `safeApiCall` from `:core:common` for all network calls. +- Entities are internal to this module -- feature modules work with domain models from `:core:model`. +- All DAO read methods that the UI observes should return `Flow`, not suspend functions. + +### New Repositories (Round 2) +- `MemoryRepository` / `MemoryRepositoryImpl` — wraps `MemoriesApi` for memory CRUD + preferences +- `McpRepository` / `McpRepositoryImpl` — wraps `McpApi` for server CRUD, tools, connection status, reinitialize +- Both bound in `DataModule.kt` via `@Binds` +- Both use `safeApiCall` — no local caching (server is sole source of truth for memories and MCP) +- **Gotcha**: Memory delete/update use `key` as identifier (not a separate ID field) +- **Gotcha**: MCP server operations use `serverName` as identifier + +### New Repositories (Round 3) +- `BannerRepository` / `BannerRepositoryImpl` — wraps `BannerApi` for fetching server banners +- `AgentRepository.getAgentsPaginated()` — server-side paginated agent fetch, maps response to `PaginatedAgents` domain model +- Both bound in `DataModule.kt` via `@Binds`, both use `safeApiCall`, no local caching diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..483ab51 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("librechat.android.library") + id("librechat.android.hilt") + id("librechat.android.room") + id("librechat.kotlin.serialization") +} + +android { + namespace = "com.librechat.android.core.data" +} + +dependencies { + implementation(project(":core:network")) + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(libs.bundles.room) + implementation(libs.datastore.preferences) + implementation(libs.security.crypto) + implementation(libs.bundles.ktor) + implementation(libs.kotlinx.serialization.json) + implementation(libs.timber) +} diff --git a/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/1.json b/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/1.json new file mode 100644 index 0000000..91a8466 --- /dev/null +++ b/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/1.json @@ -0,0 +1,643 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "8b1491672b4ea5147356685f2beb6908", + "entities": [ + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endpointType", + "columnName": "endpointType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "greeting", + "columnName": "greeting", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "modelParams", + "columnName": "modelParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId" + ] + }, + "indices": [ + { + "name": "index_conversations_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_conversations_isArchived", + "unique": false, + "columnNames": [ + "isArchived" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived` ON `${TABLE_NAME}` (`isArchived`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parentMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCreatedByUser", + "columnName": "isCreatedByUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unfinished", + "columnName": "unfinished", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "finishReason", + "columnName": "finishReason", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tokenCount", + "columnName": "tokenCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "feedback", + "columnName": "feedback", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "files", + "columnName": "files", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_parentMessageId", + "unique": false, + "columnNames": [ + "parentMessageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)" + }, + { + "name": "index_messages_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filepath", + "columnName": "filepath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [ + { + "name": "index_files_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_files_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "agents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPromoted", + "columnName": "isPromoted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationStarters", + "columnName": "conversationStarters", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tools", + "columnName": "tools", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "presets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))", + "fields": [ + { + "fieldPath": "presetId", + "columnName": "presetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "params", + "columnName": "params", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "presetId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_conversation_tags_tag_user", + "unique": true, + "columnNames": [ + "tag", + "user" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b1491672b4ea5147356685f2beb6908')" + ] + } +} \ No newline at end of file diff --git a/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/2.json b/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/2.json new file mode 100644 index 0000000..7d59665 --- /dev/null +++ b/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/2.json @@ -0,0 +1,675 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "8da545f3037e030769e5a13adc88efaf", + "entities": [ + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endpointType", + "columnName": "endpointType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "greeting", + "columnName": "greeting", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "modelParams", + "columnName": "modelParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId" + ] + }, + "indices": [ + { + "name": "index_conversations_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_conversations_isArchived", + "unique": false, + "columnNames": [ + "isArchived" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived` ON `${TABLE_NAME}` (`isArchived`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parentMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCreatedByUser", + "columnName": "isCreatedByUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unfinished", + "columnName": "unfinished", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "finishReason", + "columnName": "finishReason", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tokenCount", + "columnName": "tokenCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "feedback", + "columnName": "feedback", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "files", + "columnName": "files", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_parentMessageId", + "unique": false, + "columnNames": [ + "parentMessageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)" + }, + { + "name": "index_messages_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filepath", + "columnName": "filepath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [ + { + "name": "index_files_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_files_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "agents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPromoted", + "columnName": "isPromoted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationStarters", + "columnName": "conversationStarters", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tools", + "columnName": "tools", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "presets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))", + "fields": [ + { + "fieldPath": "presetId", + "columnName": "presetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "params", + "columnName": "params", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "presetId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_conversation_tags_tag_user", + "unique": true, + "columnNames": [ + "tag", + "user" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "drafts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversation_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8da545f3037e030769e5a13adc88efaf')" + ] + } +} \ No newline at end of file diff --git a/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/3.json b/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/3.json new file mode 100644 index 0000000..6334eac --- /dev/null +++ b/core/data/schemas/com.librechat.android.core.data.db.LibreChatDatabase/3.json @@ -0,0 +1,668 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "b4117797915eafc13b5bd956677f83bf", + "entities": [ + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endpointType", + "columnName": "endpointType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "greeting", + "columnName": "greeting", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "modelParams", + "columnName": "modelParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId" + ] + }, + "indices": [ + { + "name": "index_conversations_isArchived_updatedAt", + "unique": false, + "columnNames": [ + "isArchived", + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived_updatedAt` ON `${TABLE_NAME}` (`isArchived`, `updatedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parentMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCreatedByUser", + "columnName": "isCreatedByUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unfinished", + "columnName": "unfinished", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "finishReason", + "columnName": "finishReason", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tokenCount", + "columnName": "tokenCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "feedback", + "columnName": "feedback", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "files", + "columnName": "files", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_messages_parentMessageId", + "unique": false, + "columnNames": [ + "parentMessageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)" + }, + { + "name": "index_messages_conversationId_createdAt", + "unique": false, + "columnNames": [ + "conversationId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filepath", + "columnName": "filepath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [ + { + "name": "index_files_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_files_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "agents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPromoted", + "columnName": "isPromoted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationStarters", + "columnName": "conversationStarters", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tools", + "columnName": "tools", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "presets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))", + "fields": [ + { + "fieldPath": "presetId", + "columnName": "presetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endpoint", + "columnName": "endpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "params", + "columnName": "params", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "presetId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "conversation_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_conversation_tags_tag_user", + "unique": true, + "columnNames": [ + "tag", + "user" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "drafts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversation_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4117797915eafc13b5bd956677f83bf')" + ] + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt new file mode 100644 index 0000000..a2aa7dd --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt @@ -0,0 +1,99 @@ +package com.librechat.android.core.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.librechat.android.core.model.EndpointConfig +import com.librechat.android.core.model.StartupConfig +import kotlinx.coroutines.flow.first +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConfigCacheDataStore @Inject constructor( + private val dataStore: DataStore, + private val json: Json, +) { + + suspend fun saveStartupConfig(config: StartupConfig) { + try { + val serialized = json.encodeToString(StartupConfig.serializer(), config) + dataStore.edit { prefs -> prefs[KEY_STARTUP_CONFIG] = serialized } + } catch (e: Exception) { + Timber.w(e, "Failed to cache startup config") + } + } + + suspend fun loadStartupConfig(): StartupConfig? { + return try { + val prefs = dataStore.data.first() + val serialized = prefs[KEY_STARTUP_CONFIG] ?: return null + json.decodeFromString(StartupConfig.serializer(), serialized) + } catch (e: Exception) { + Timber.w(e, "Failed to load cached startup config") + null + } + } + + suspend fun saveEndpointConfigs(endpoints: Map) { + try { + val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer()) + val serialized = json.encodeToString(serializer, endpoints) + dataStore.edit { prefs -> prefs[KEY_ENDPOINT_CONFIGS] = serialized } + } catch (e: Exception) { + Timber.w(e, "Failed to cache endpoint configs") + } + } + + suspend fun loadEndpointConfigs(): Map? { + return try { + val prefs = dataStore.data.first() + val serialized = prefs[KEY_ENDPOINT_CONFIGS] ?: return null + val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer()) + json.decodeFromString(serializer, serialized) + } catch (e: Exception) { + Timber.w(e, "Failed to load cached endpoint configs") + null + } + } + + suspend fun saveAvailableModels(models: Map>) { + try { + val serializer = MapSerializer( + String.serializer(), + ListSerializer(String.serializer()), + ) + val serialized = json.encodeToString(serializer, models) + dataStore.edit { prefs -> prefs[KEY_AVAILABLE_MODELS] = serialized } + } catch (e: Exception) { + Timber.w(e, "Failed to cache available models") + } + } + + suspend fun loadAvailableModels(): Map>? { + return try { + val prefs = dataStore.data.first() + val serialized = prefs[KEY_AVAILABLE_MODELS] ?: return null + val serializer = MapSerializer( + String.serializer(), + ListSerializer(String.serializer()), + ) + json.decodeFromString(serializer, serialized) + } catch (e: Exception) { + Timber.w(e, "Failed to load cached available models") + null + } + } + + companion object { + private val KEY_STARTUP_CONFIG = stringPreferencesKey("cached_startup_config") + private val KEY_ENDPOINT_CONFIGS = stringPreferencesKey("cached_endpoint_configs") + private val KEY_AVAILABLE_MODELS = stringPreferencesKey("cached_available_models") + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt new file mode 100644 index 0000000..0d947db --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt @@ -0,0 +1,54 @@ +package com.librechat.android.core.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.librechat.android.core.network.client.ServerUrlProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ServerDataStore @Inject constructor( + private val dataStore: DataStore, +) : ServerUrlProvider { + + private val _currentUrl = MutableStateFlow("") + val currentUrlFlow: Flow = _currentUrl + + init { + // Load the persisted URL synchronously so getBaseUrl() is ready + // immediately after construction. This is a singleton created once + // at app startup, so a brief block on Dispatchers.IO is acceptable. + _currentUrl.value = runBlocking(Dispatchers.IO) { + dataStore.data + .map { prefs -> prefs[KEY_SERVER_URL].orEmpty() } + .first() + } + } + + override fun getBaseUrl(): String = _currentUrl.value + + fun hasServerUrl(): Flow = + dataStore.data.map { prefs -> + !prefs[KEY_SERVER_URL].isNullOrBlank() + } + + suspend fun setServerUrl(url: String) { + val trimmed = url.trimEnd('/') + _currentUrl.value = trimmed + dataStore.edit { prefs -> + prefs[KEY_SERVER_URL] = trimmed + } + } + + companion object { + private val KEY_SERVER_URL = stringPreferencesKey("server_url") + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt new file mode 100644 index 0000000..ae51502 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt @@ -0,0 +1,390 @@ +package com.librechat.android.core.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +enum class LatexRenderer { + NATIVE, KATEX; + + companion object { + fun fromString(value: String?): LatexRenderer = when (value) { + "native" -> NATIVE + else -> KATEX + } + } + + fun toStorageString(): String = when (this) { + NATIVE -> "native" + KATEX -> "katex" + } +} + +enum class ChatFontSize { + SMALL, MEDIUM, LARGE; + + companion object { + fun fromString(value: String?): ChatFontSize = when (value) { + "small" -> SMALL + "large" -> LARGE + else -> MEDIUM + } + } + + fun toStorageString(): String = when (this) { + SMALL -> "small" + MEDIUM -> "medium" + LARGE -> "large" + } +} + +@Singleton +class SettingsDataStore @Inject constructor( + private val dataStore: DataStore, +) { + val latexRenderer: Flow = dataStore.data.map { prefs -> + LatexRenderer.fromString(prefs[KEY_LATEX_RENDERER]) + } + + val chatFontSize: Flow = dataStore.data.map { prefs -> + ChatFontSize.fromString(prefs[KEY_CHAT_FONT_SIZE]) + } + + val autoScrollEnabled: Flow = dataStore.data.map { prefs -> + prefs[KEY_AUTO_SCROLL_ENABLED] ?: true + } + + val showThinkingBlocks: Flow = dataStore.data.map { prefs -> + prefs[KEY_SHOW_THINKING_BLOCKS] ?: true + } + + val autoReadEnabled: Flow = dataStore.data.map { prefs -> + prefs[KEY_AUTO_READ_ENABLED] ?: false + } + + val showImageDescriptions: Flow = dataStore.data.map { prefs -> + prefs[KEY_SHOW_IMAGE_DESCRIPTIONS] ?: false + } + + val selectedVoiceId: Flow = dataStore.data.map { prefs -> + prefs[KEY_SELECTED_VOICE_ID] + } + + val lastUsedEndpoint: Flow = dataStore.data.map { prefs -> + prefs[KEY_LAST_USED_ENDPOINT] + } + + val lastUsedModel: Flow = dataStore.data.map { prefs -> + prefs[KEY_LAST_USED_MODEL] + } + + val dismissKeyboardOnSend: Flow = dataStore.data.map { prefs -> + prefs[KEY_DISMISS_KEYBOARD_ON_SEND] ?: true + } + + val ttsSource: Flow = dataStore.data.map { prefs -> + prefs[KEY_TTS_SOURCE] ?: "device" + } + + val ttsSpeechRate: Flow = dataStore.data.map { prefs -> + prefs[KEY_TTS_SPEECH_RATE] ?: 1.0f + } + + val ttsPitch: Flow = dataStore.data.map { prefs -> + prefs[KEY_TTS_PITCH] ?: 1.0f + } + + val ttsVoiceName: Flow = dataStore.data.map { prefs -> + prefs[KEY_TTS_VOICE_NAME] ?: "" + } + + val bookmarkedConversationIds: Flow> = dataStore.data.map { prefs -> + prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet() + } + + val tabletSidebarOpen: Flow = dataStore.data.map { prefs -> + prefs[KEY_TABLET_SIDEBAR_OPEN] ?: true + } + + val tabletSidebarGestureEnabled: Flow = dataStore.data.map { prefs -> + prefs[KEY_TABLET_SIDEBAR_GESTURE_ENABLED] ?: true + } + + val autoSendAfterStt: Flow = dataStore.data.map { prefs -> + prefs[KEY_AUTO_SEND_AFTER_STT] ?: false + } + + val sttEngine: Flow = dataStore.data.map { prefs -> + prefs[KEY_STT_ENGINE] ?: "" + } + + val sttLanguage: Flow = dataStore.data.map { prefs -> + prefs[KEY_STT_LANGUAGE] ?: "" + } + + val ttsEngine: Flow = dataStore.data.map { prefs -> + prefs[KEY_TTS_ENGINE] ?: "" + } + + val ttsVoice: Flow = dataStore.data.map { prefs -> + prefs[KEY_TTS_VOICE] ?: "" + } + + val ttsCaching: Flow = dataStore.data.map { prefs -> + prefs[KEY_TTS_CACHING] ?: true + } + + val chatLayoutStyle: Flow = dataStore.data.map { prefs -> + prefs[KEY_CHAT_LAYOUT_STYLE] ?: "thread" + } + + val showAvatars: Flow = dataStore.data.map { prefs -> + prefs[KEY_SHOW_AVATARS] ?: true + } + + val showBubbles: Flow = dataStore.data.map { prefs -> + prefs[KEY_SHOW_BUBBLES] ?: false + } + + val selectedMcpServers: Flow> = dataStore.data.map { prefs -> + prefs[KEY_SELECTED_MCP_SERVERS]?.split(",")?.filter { it.isNotBlank() }?.toSet() ?: emptySet() + } + + val enabledTools: Flow> = dataStore.data.map { prefs -> + prefs[KEY_ENABLED_TOOLS]?.split(",")?.filter { it.isNotBlank() }?.toSet() ?: emptySet() + } + + /** + * The backend version for which the user dismissed the version mismatch warning. + * When the backend updates to a new version, the dialog will appear again. + */ + val dismissedVersionWarning: Flow = dataStore.data.map { prefs -> + prefs[KEY_DISMISSED_VERSION_WARNING] + } + + suspend fun setLatexRenderer(renderer: LatexRenderer) { + dataStore.edit { prefs -> + prefs[KEY_LATEX_RENDERER] = renderer.toStorageString() + } + } + + suspend fun setChatFontSize(size: ChatFontSize) { + dataStore.edit { prefs -> + prefs[KEY_CHAT_FONT_SIZE] = size.toStorageString() + } + } + + suspend fun setAutoScrollEnabled(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_AUTO_SCROLL_ENABLED] = enabled + } + } + + suspend fun setShowThinkingBlocks(show: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_SHOW_THINKING_BLOCKS] = show + } + } + + suspend fun setAutoReadEnabled(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_AUTO_READ_ENABLED] = enabled + } + } + + suspend fun setShowImageDescriptions(show: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_SHOW_IMAGE_DESCRIPTIONS] = show + } + } + + suspend fun setDismissKeyboardOnSend(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_DISMISS_KEYBOARD_ON_SEND] = enabled + } + } + + suspend fun setSelectedVoiceId(voiceId: String) { + dataStore.edit { prefs -> + prefs[KEY_SELECTED_VOICE_ID] = voiceId + } + } + + suspend fun setLastUsedModel(endpoint: String, model: String) { + dataStore.edit { prefs -> + prefs[KEY_LAST_USED_ENDPOINT] = endpoint + prefs[KEY_LAST_USED_MODEL] = model + } + } + + suspend fun setTtsSource(source: String) { + dataStore.edit { prefs -> + prefs[KEY_TTS_SOURCE] = source + } + } + + suspend fun setTtsSpeechRate(rate: Float) { + dataStore.edit { prefs -> + prefs[KEY_TTS_SPEECH_RATE] = rate + } + } + + suspend fun setTtsPitch(pitch: Float) { + dataStore.edit { prefs -> + prefs[KEY_TTS_PITCH] = pitch + } + } + + suspend fun setTtsVoiceName(voiceName: String) { + dataStore.edit { prefs -> + prefs[KEY_TTS_VOICE_NAME] = voiceName + } + } + + suspend fun setTabletSidebarOpen(open: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_TABLET_SIDEBAR_OPEN] = open + } + } + + suspend fun setTabletSidebarGestureEnabled(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_TABLET_SIDEBAR_GESTURE_ENABLED] = enabled + } + } + + suspend fun setAutoSendAfterStt(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_AUTO_SEND_AFTER_STT] = enabled + } + } + + suspend fun setSttEngine(engine: String) { + dataStore.edit { prefs -> + prefs[KEY_STT_ENGINE] = engine + } + } + + suspend fun setSttLanguage(language: String) { + dataStore.edit { prefs -> + prefs[KEY_STT_LANGUAGE] = language + } + } + + suspend fun setTtsEngine(engine: String) { + dataStore.edit { prefs -> + prefs[KEY_TTS_ENGINE] = engine + } + } + + suspend fun setTtsVoice(voice: String) { + dataStore.edit { prefs -> + prefs[KEY_TTS_VOICE] = voice + } + } + + suspend fun setTtsCaching(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_TTS_CACHING] = enabled + } + } + + suspend fun setChatLayoutStyle(style: String) { + dataStore.edit { prefs -> + prefs[KEY_CHAT_LAYOUT_STYLE] = style + } + } + + suspend fun setShowAvatars(show: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_SHOW_AVATARS] = show + } + } + + suspend fun setShowBubbles(show: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_SHOW_BUBBLES] = show + } + } + + /** + * Saves the backend version for which the user chose "Don't warn again". + * If the backend later updates to a different version, the warning will reappear. + */ + suspend fun setDismissedVersionWarning(version: String) { + dataStore.edit { prefs -> + prefs[KEY_DISMISSED_VERSION_WARNING] = version + } + } + + suspend fun setSelectedMcpServers(servers: Set) { + dataStore.edit { prefs -> + if (servers.isEmpty()) { + prefs.remove(KEY_SELECTED_MCP_SERVERS) + } else { + prefs[KEY_SELECTED_MCP_SERVERS] = servers.joinToString(",") + } + } + } + + suspend fun setEnabledTools(tools: Set) { + dataStore.edit { prefs -> + if (tools.isEmpty()) { + prefs.remove(KEY_ENABLED_TOOLS) + } else { + prefs[KEY_ENABLED_TOOLS] = tools.joinToString(",") + } + } + } + + suspend fun toggleBookmark(conversationId: String) { + dataStore.edit { prefs -> + val current = prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet() + prefs[KEY_BOOKMARKED_CONVERSATIONS] = if (conversationId in current) { + current - conversationId + } else { + current + conversationId + } + } + } + + companion object { + private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer") + private val KEY_CHAT_FONT_SIZE = stringPreferencesKey("chat_font_size") + private val KEY_AUTO_SCROLL_ENABLED = booleanPreferencesKey("auto_scroll_enabled") + private val KEY_SHOW_THINKING_BLOCKS = booleanPreferencesKey("show_thinking_blocks") + private val KEY_AUTO_READ_ENABLED = booleanPreferencesKey("auto_read_enabled") + private val KEY_SHOW_IMAGE_DESCRIPTIONS = booleanPreferencesKey("show_image_descriptions") + private val KEY_SELECTED_VOICE_ID = stringPreferencesKey("selected_voice_id") + private val KEY_LAST_USED_ENDPOINT = stringPreferencesKey("last_used_endpoint") + private val KEY_LAST_USED_MODEL = stringPreferencesKey("last_used_model") + private val KEY_DISMISS_KEYBOARD_ON_SEND = booleanPreferencesKey("dismiss_keyboard_on_send") + private val KEY_TTS_SOURCE = stringPreferencesKey("tts_source") + private val KEY_TTS_SPEECH_RATE = floatPreferencesKey("tts_speech_rate") + private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch") + private val KEY_TTS_VOICE_NAME = stringPreferencesKey("tts_voice_name") + private val KEY_BOOKMARKED_CONVERSATIONS = stringSetPreferencesKey("bookmarked_conversations") + private val KEY_TABLET_SIDEBAR_OPEN = booleanPreferencesKey("tablet_sidebar_open") + private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled") + private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt") + private val KEY_STT_ENGINE = stringPreferencesKey("stt_engine") + private val KEY_STT_LANGUAGE = stringPreferencesKey("stt_language") + private val KEY_TTS_ENGINE = stringPreferencesKey("tts_engine") + private val KEY_TTS_VOICE = stringPreferencesKey("tts_voice") + private val KEY_TTS_CACHING = booleanPreferencesKey("tts_caching") + private val KEY_CHAT_LAYOUT_STYLE = stringPreferencesKey("chat_layout_style") + private val KEY_SHOW_AVATARS = booleanPreferencesKey("show_avatars") + private val KEY_SHOW_BUBBLES = booleanPreferencesKey("show_bubbles") + private val KEY_DISMISSED_VERSION_WARNING = stringPreferencesKey("dismissed_version_warning") + private val KEY_SELECTED_MCP_SERVERS = stringPreferencesKey("selected_mcp_servers") + private val KEY_ENABLED_TOOLS = stringPreferencesKey("enabled_tools") + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt new file mode 100644 index 0000000..ffe011d --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt @@ -0,0 +1,42 @@ +package com.librechat.android.core.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +enum class ThemeMode { + SYSTEM, LIGHT, DARK +} + +@Singleton +class ThemeDataStore @Inject constructor( + private val dataStore: DataStore, +) { + + val themeMode: Flow = dataStore.data.map { prefs -> + when (prefs[KEY_THEME]) { + "light" -> ThemeMode.LIGHT + "dark" -> ThemeMode.DARK + else -> ThemeMode.SYSTEM + } + } + + suspend fun setThemeMode(mode: ThemeMode) { + dataStore.edit { prefs -> + prefs[KEY_THEME] = when (mode) { + ThemeMode.SYSTEM -> "system" + ThemeMode.LIGHT -> "light" + ThemeMode.DARK -> "dark" + } + } + } + + companion object { + private val KEY_THEME = stringPreferencesKey("theme_mode") + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt new file mode 100644 index 0000000..0621a03 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt @@ -0,0 +1,148 @@ +package com.librechat.android.core.data.datastore + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.librechat.android.core.model.response.RefreshResponse +import com.librechat.android.core.network.client.CookieHelper +import com.librechat.android.core.network.client.SecureTokenStorage +import com.librechat.android.core.network.client.TokenManager +import com.librechat.android.core.network.di.RefreshClient +import dagger.hilt.android.qualifiers.ApplicationContext +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import java.io.IOException +import java.security.KeyStoreException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenDataStore @Inject constructor( + @ApplicationContext context: Context, + @RefreshClient private val refreshClient: dagger.Lazy, +) : TokenManager, SecureTokenStorage { + + private val masterKey: MasterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + /** Volatile in-memory cache for fast access-token reads. */ + @Volatile + private var cachedAccessToken: String? = prefs.getString(KEY_ACCESS_TOKEN, null) + + private val refreshMutex = Mutex() + + private val _sessionExpired = MutableSharedFlow(extraBufferCapacity = 1) + override val sessionExpiredFlow: SharedFlow = _sessionExpired.asSharedFlow() + + // --- TokenManager --- + + override suspend fun getAccessToken(): String? = cachedAccessToken + + override suspend fun setTokens(accessToken: String, refreshToken: String) { + cachedAccessToken = accessToken + prefs.edit() + .putString(KEY_ACCESS_TOKEN, accessToken) + .putString(KEY_REFRESH_TOKEN, refreshToken) + .apply() + } + + override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { + // Re-check cached token: another coroutine may have already refreshed while we waited + // on the mutex. If the cached token differs from what triggered the 401, skip refresh. + val storedRefreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) + if (storedRefreshToken.isNullOrBlank()) { + Timber.w("No refresh token available") + return false + } + + return try { + // Send refresh token via both Cookie header and request body. + // The LibreChat backend reads from req.cookies.refreshToken first, + // falling back to req.body.refreshToken. Sending both ensures + // compatibility across backend versions. + val httpResponse: HttpResponse = refreshClient.get() + .post("/api/auth/refresh") { + header("Cookie", "refreshToken=$storedRefreshToken") + setBody(mapOf("refreshToken" to storedRefreshToken)) + } + + val body: RefreshResponse = httpResponse.body() + + // The backend may rotate the refresh token via Set-Cookie. + // If a new refresh token is present, persist it; otherwise keep the current one. + val newRefreshToken = CookieHelper.extractRefreshToken(httpResponse.headers) + ?: storedRefreshToken + + setTokens(body.token, newRefreshToken) + Timber.d("Token refreshed successfully") + true + } catch (e: KeyStoreException) { + Timber.e(e, "Keystore corruption—clearing tokens") + clearTokens() + false + } catch (e: IOException) { + Timber.w(e, "Network error during token refresh") + false + } catch (e: ClientRequestException) { + // 401/403 means session is truly expired + Timber.w(e, "Auth error during token refresh (status=${e.response.status})") + clearTokens() + false + } catch (e: Exception) { + Timber.e(e, "Unexpected error during token refresh") + false + } + } + + override suspend fun clearTokens() { + cachedAccessToken = null + prefs.edit() + .remove(KEY_ACCESS_TOKEN) + .remove(KEY_REFRESH_TOKEN) + .apply() + } + + override fun emitSessionExpired() { + _sessionExpired.tryEmit(Unit) + } + + // --- SecureTokenStorage --- + + override suspend fun getRefreshToken(): String? = + prefs.getString(KEY_REFRESH_TOKEN, null) + + override suspend fun storeTokens(accessToken: String, refreshToken: String) { + setTokens(accessToken, refreshToken) + } + + override suspend fun clearAll() { + clearTokens() + } + + companion object { + private const val PREFS_NAME = "librechat_tokens" + private const val KEY_ACCESS_TOKEN = "access_token" + private const val KEY_REFRESH_TOKEN = "refresh_token" + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt new file mode 100644 index 0000000..3eed4f4 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt @@ -0,0 +1,49 @@ +package com.librechat.android.core.data.db + +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.librechat.android.core.data.db.converter.Converters +import com.librechat.android.core.data.db.dao.AgentDao +import com.librechat.android.core.data.db.dao.ConversationDao +import com.librechat.android.core.data.db.dao.ConversationTagDao +import com.librechat.android.core.data.db.dao.DraftDao +import com.librechat.android.core.data.db.dao.FileDao +import com.librechat.android.core.data.db.dao.MessageDao +import com.librechat.android.core.data.db.dao.PresetDao +import com.librechat.android.core.data.db.entity.AgentEntity +import com.librechat.android.core.data.db.entity.ConversationEntity +import com.librechat.android.core.data.db.entity.ConversationTagEntity +import com.librechat.android.core.data.db.entity.DraftEntity +import com.librechat.android.core.data.db.entity.FileEntity +import com.librechat.android.core.data.db.entity.MessageEntity +import com.librechat.android.core.data.db.entity.PresetEntity + +@Database( + entities = [ + ConversationEntity::class, + MessageEntity::class, + FileEntity::class, + AgentEntity::class, + PresetEntity::class, + ConversationTagEntity::class, + DraftEntity::class, + ], + version = 3, + exportSchema = true, + autoMigrations = [ + AutoMigration(from = 1, to = 2), + AutoMigration(from = 2, to = 3), + ], +) +@TypeConverters(Converters::class) +abstract class LibreChatDatabase : RoomDatabase() { + abstract fun conversationDao(): ConversationDao + abstract fun messageDao(): MessageDao + abstract fun fileDao(): FileDao + abstract fun agentDao(): AgentDao + abstract fun presetDao(): PresetDao + abstract fun conversationTagDao(): ConversationTagDao + abstract fun draftDao(): DraftDao +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/converter/Converters.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/converter/Converters.kt new file mode 100644 index 0000000..560cc61 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/converter/Converters.kt @@ -0,0 +1,16 @@ +package com.librechat.android.core.data.db.converter + +import androidx.room.TypeConverter +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class Converters { + private val json = Json { ignoreUnknownKeys = true } + + @TypeConverter + fun fromStringList(value: List): String = json.encodeToString(value) + + @TypeConverter + fun toStringList(value: String): List = + try { json.decodeFromString(value) } catch (_: Exception) { emptyList() } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt new file mode 100644 index 0000000..9b841f7 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt @@ -0,0 +1,25 @@ +package com.librechat.android.core.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.librechat.android.core.data.db.entity.AgentEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AgentDao { + @Query("SELECT * FROM agents ORDER BY isPromoted DESC, name ASC") + fun getAll(): Flow> + + @Query("SELECT * FROM agents WHERE id = :id") + suspend fun getById(id: String): AgentEntity? + + @Upsert + suspend fun upsert(agent: AgentEntity) + + @Upsert + suspend fun upsertAll(agents: List) + + @Query("DELETE FROM agents") + suspend fun deleteAll() +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt new file mode 100644 index 0000000..fed3bbf --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt @@ -0,0 +1,43 @@ +package com.librechat.android.core.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.librechat.android.core.data.db.entity.ConversationEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConversationDao { + @Query("SELECT * FROM conversations WHERE user = :userId AND isArchived = :isArchived ORDER BY updatedAt DESC LIMIT :limit") + fun getConversations(userId: String, isArchived: Boolean = false, limit: Int = 25): Flow> + + @Query("SELECT * FROM conversations WHERE isArchived = :isArchived ORDER BY updatedAt DESC") + fun getAllConversations(isArchived: Boolean = false): Flow> + + @Query("SELECT * FROM conversations WHERE conversationId = :id") + suspend fun getById(id: String): ConversationEntity? + + @Query("SELECT * FROM conversations WHERE conversationId = :id") + fun observeById(id: String): Flow + + @Upsert + suspend fun upsert(conversation: ConversationEntity) + + @Upsert + suspend fun upsertAll(conversations: List) + + @Query("DELETE FROM conversations WHERE conversationId = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM conversations WHERE user = :userId") + suspend fun deleteAllForUser(userId: String) + + @Query("UPDATE conversations SET title = :title, updatedAt = :updatedAt WHERE conversationId = :id") + suspend fun updateTitle(id: String, title: String, updatedAt: Long) + + @Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id") + suspend fun updateArchived(id: String, isArchived: Boolean, updatedAt: Long) + + @Query("DELETE FROM conversations") + suspend fun deleteAll() +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt new file mode 100644 index 0000000..3b69e08 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.librechat.android.core.data.db.entity.ConversationTagEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConversationTagDao { + @Query("SELECT * FROM conversation_tags WHERE user = :userId ORDER BY position ASC") + fun getTagsForUser(userId: String): Flow> + + @Upsert + suspend fun upsert(tag: ConversationTagEntity) + + @Upsert + suspend fun upsertAll(tags: List) + + @Query("DELETE FROM conversation_tags WHERE id = :id") + suspend fun deleteById(id: Long) +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt new file mode 100644 index 0000000..e08d0c0 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.librechat.android.core.data.db.entity.DraftEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface DraftDao { + @Query("SELECT * FROM drafts WHERE conversation_id = :conversationId") + suspend fun getDraft(conversationId: String): DraftEntity? + + @Upsert + suspend fun upsertDraft(draft: DraftEntity) + + @Query("DELETE FROM drafts WHERE conversation_id = :conversationId") + suspend fun deleteDraft(conversationId: String) + + @Query("SELECT * FROM drafts ORDER BY updated_at DESC") + fun observeAllDrafts(): Flow> +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt new file mode 100644 index 0000000..0c342e6 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt @@ -0,0 +1,25 @@ +package com.librechat.android.core.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.librechat.android.core.data.db.entity.FileEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface FileDao { + @Query("SELECT * FROM files WHERE user = :userId ORDER BY createdAt DESC") + fun getFilesForUser(userId: String): Flow> + + @Query("SELECT * FROM files WHERE fileId = :fileId") + suspend fun getById(fileId: String): FileEntity? + + @Upsert + suspend fun upsert(file: FileEntity) + + @Upsert + suspend fun upsertAll(files: List) + + @Query("DELETE FROM files WHERE fileId = :fileId") + suspend fun deleteById(fileId: String) +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt new file mode 100644 index 0000000..f383ff0 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt @@ -0,0 +1,43 @@ +package com.librechat.android.core.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.librechat.android.core.data.db.entity.MessageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface MessageDao { + @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC") + fun getMessagesForConversation(conversationId: String): Flow> + + @Query("SELECT * FROM messages WHERE messageId = :messageId") + suspend fun getById(messageId: String): MessageEntity? + + @Query("SELECT * FROM messages WHERE conversationId = :conversationId AND parentMessageId = :parentMessageId ORDER BY createdAt ASC") + suspend fun getSiblings(conversationId: String, parentMessageId: String): List + + @Upsert + suspend fun upsert(message: MessageEntity) + + @Upsert + suspend fun upsertAll(messages: List) + + @Query("DELETE FROM messages WHERE messageId = :messageId") + suspend fun deleteById(messageId: String) + + @Query("DELETE FROM messages WHERE conversationId = :conversationId") + suspend fun deleteAllForConversation(conversationId: String) + + @androidx.room.Transaction + suspend fun replaceAllForConversation(conversationId: String, messages: List) { + deleteAllForConversation(conversationId) + upsertAll(messages) + } + + @Query("UPDATE messages SET feedback = :feedback WHERE messageId = :messageId") + suspend fun updateFeedback(messageId: String, feedback: String?) + + @Query("UPDATE messages SET text = :text, content = NULL WHERE messageId = :messageId") + suspend fun updateText(messageId: String, text: String) +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt new file mode 100644 index 0000000..44229bd --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.librechat.android.core.data.db.entity.PresetEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface PresetDao { + @Query("SELECT * FROM presets ORDER BY `order` ASC, title ASC") + fun getAll(): Flow> + + @Query("SELECT * FROM presets WHERE presetId = :presetId") + suspend fun getById(presetId: String): PresetEntity? + + @Upsert + suspend fun upsert(preset: PresetEntity) + + @Query("DELETE FROM presets WHERE presetId = :presetId") + suspend fun deleteById(presetId: String) +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt new file mode 100644 index 0000000..928de3b --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt @@ -0,0 +1,21 @@ +package com.librechat.android.core.data.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "agents") +data class AgentEntity( + @PrimaryKey + val id: String, + val name: String?, + val description: String?, + val avatar: String?, + val provider: String, + val model: String, + val category: String?, + val authorName: String?, + val isPromoted: Boolean = false, + val conversationStarters: String?, + val tools: String?, + val updatedAt: Long, +) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt new file mode 100644 index 0000000..54e438a --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt @@ -0,0 +1,30 @@ +package com.librechat.android.core.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "conversations", + indices = [ + Index(value = ["isArchived", "updatedAt"]), + ], +) +data class ConversationEntity( + @PrimaryKey + val conversationId: String, + val title: String, + val user: String, + val endpoint: String?, + val endpointType: String?, + val model: String?, + val agentId: String?, + val isArchived: Boolean = false, + val tags: String, + val iconURL: String?, + val greeting: String?, + val modelParams: String?, + val createdAt: Long, + val updatedAt: Long, + val lastSyncedAt: Long = 0, +) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt new file mode 100644 index 0000000..25eef05 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt @@ -0,0 +1,21 @@ +package com.librechat.android.core.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "conversation_tags", + indices = [Index(value = ["tag", "user"], unique = true)], +) +data class ConversationTagEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val tag: String, + val user: String, + val description: String?, + val count: Int = 0, + val position: Int = 0, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt new file mode 100644 index 0000000..1550282 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt @@ -0,0 +1,16 @@ +package com.librechat.android.core.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "drafts") +data class DraftEntity( + @PrimaryKey + @ColumnInfo(name = "conversation_id") + val conversationId: String, + @ColumnInfo(name = "text") + val text: String, + @ColumnInfo(name = "updated_at") + val updatedAt: Long = System.currentTimeMillis(), +) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt new file mode 100644 index 0000000..adb0048 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt @@ -0,0 +1,30 @@ +package com.librechat.android.core.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "files", + indices = [ + Index("conversationId"), + Index("messageId"), + ], +) +data class FileEntity( + @PrimaryKey + val fileId: String, + val user: String, + val conversationId: String?, + val messageId: String?, + val filename: String, + val filepath: String, + val type: String, + val bytes: Long, + val source: String, + val width: Int?, + val height: Int?, + val createdAt: Long, + val updatedAt: Long, + val localPath: String? = null, +) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt new file mode 100644 index 0000000..497acdf --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt @@ -0,0 +1,37 @@ +package com.librechat.android.core.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "messages", + indices = [ + Index("conversationId"), + Index("parentMessageId"), + Index(value = ["conversationId", "createdAt"]), + ], +) +data class MessageEntity( + @PrimaryKey + val messageId: String, + val conversationId: String, + val parentMessageId: String?, + val sender: String?, + val text: String?, + val content: String?, + val isCreatedByUser: Boolean, + val model: String?, + val endpoint: String?, + val iconURL: String?, + val unfinished: Boolean = false, + val error: Boolean = false, + val finishReason: String?, + val tokenCount: Int?, + val feedback: String?, + val files: String?, + val attachments: String?, + val metadata: String?, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt new file mode 100644 index 0000000..ff081a1 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.data.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "presets") +data class PresetEntity( + @PrimaryKey + val presetId: String, + val title: String, + val endpoint: String?, + val model: String?, + val isDefault: Boolean = false, + val order: Int?, + val params: String?, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/di/DataModule.kt new file mode 100644 index 0000000..b275ce0 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/di/DataModule.kt @@ -0,0 +1,173 @@ +package com.librechat.android.core.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.datastore.TokenDataStore +import com.librechat.android.core.data.repository.AgentRepository +import com.librechat.android.core.data.repository.AgentRepositoryImpl +import com.librechat.android.core.data.repository.ApiKeyRepository +import com.librechat.android.core.data.repository.ApiKeyRepositoryImpl +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.BalanceRepository +import com.librechat.android.core.data.repository.BalanceRepositoryImpl +import com.librechat.android.core.data.repository.BannerRepository +import com.librechat.android.core.data.repository.BannerRepositoryImpl +import com.librechat.android.core.data.repository.SearchRepository +import com.librechat.android.core.data.repository.SearchRepositoryImpl +import com.librechat.android.core.data.repository.TagRepository +import com.librechat.android.core.data.repository.TagRepositoryImpl +import com.librechat.android.core.data.repository.AuthRepositoryImpl +import com.librechat.android.core.data.repository.ChatRepository +import com.librechat.android.core.data.repository.ChatRepositoryImpl +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.data.repository.ConfigRepositoryImpl +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.ConversationRepositoryImpl +import com.librechat.android.core.data.repository.DraftRepository +import com.librechat.android.core.data.repository.DraftRepositoryImpl +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.data.repository.FileRepositoryImpl +import com.librechat.android.core.data.repository.KeyRepository +import com.librechat.android.core.data.repository.KeyRepositoryImpl +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.data.repository.McpRepositoryImpl +import com.librechat.android.core.data.repository.MemoryRepository +import com.librechat.android.core.data.repository.MemoryRepositoryImpl +import com.librechat.android.core.data.repository.MessageRepository +import com.librechat.android.core.data.repository.MessageRepositoryImpl +import com.librechat.android.core.data.repository.PresetRepository +import com.librechat.android.core.data.repository.PresetRepositoryImpl +import com.librechat.android.core.data.repository.PromptRepository +import com.librechat.android.core.data.repository.PromptRepositoryImpl +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.core.data.repository.ShareRepositoryImpl +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.core.data.repository.SpeechRepositoryImpl +import com.librechat.android.core.data.repository.UserRepository +import com.librechat.android.core.data.repository.UserRepositoryImpl +import com.librechat.android.core.network.client.SecureTokenStorage +import com.librechat.android.core.network.client.ServerUrlProvider +import com.librechat.android.core.network.client.TokenManager +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +private val Context.settingsDataStore: DataStore by preferencesDataStore( + name = "librechat_settings", +) + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + + @Binds + @Singleton + abstract fun bindTokenManager(impl: TokenDataStore): TokenManager + + @Binds + @Singleton + abstract fun bindSecureTokenStorage(impl: TokenDataStore): SecureTokenStorage + + @Binds + @Singleton + abstract fun bindServerUrlProvider(impl: ServerDataStore): ServerUrlProvider + + @Binds + @Singleton + abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + abstract fun bindBalanceRepository(impl: BalanceRepositoryImpl): BalanceRepository + + @Binds + @Singleton + abstract fun bindConfigRepository(impl: ConfigRepositoryImpl): ConfigRepository + + @Binds + @Singleton + abstract fun bindConversationRepository(impl: ConversationRepositoryImpl): ConversationRepository + + @Binds + @Singleton + abstract fun bindDraftRepository(impl: DraftRepositoryImpl): DraftRepository + + @Binds + @Singleton + abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository + + @Binds + @Singleton + abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository + + @Binds + @Singleton + abstract fun bindAgentRepository(impl: AgentRepositoryImpl): AgentRepository + + @Binds + @Singleton + abstract fun bindTagRepository(impl: TagRepositoryImpl): TagRepository + + @Binds + @Singleton + abstract fun bindSearchRepository(impl: SearchRepositoryImpl): SearchRepository + + @Binds + @Singleton + abstract fun bindFileRepository(impl: FileRepositoryImpl): FileRepository + + @Binds + @Singleton + abstract fun bindKeyRepository(impl: KeyRepositoryImpl): KeyRepository + + @Binds + @Singleton + abstract fun bindApiKeyRepository(impl: ApiKeyRepositoryImpl): ApiKeyRepository + + @Binds + @Singleton + abstract fun bindMcpRepository(impl: McpRepositoryImpl): McpRepository + + @Binds + @Singleton + abstract fun bindMemoryRepository(impl: MemoryRepositoryImpl): MemoryRepository + + @Binds + @Singleton + abstract fun bindPresetRepository(impl: PresetRepositoryImpl): PresetRepository + + @Binds + @Singleton + abstract fun bindPromptRepository(impl: PromptRepositoryImpl): PromptRepository + + @Binds + @Singleton + abstract fun bindShareRepository(impl: ShareRepositoryImpl): ShareRepository + + @Binds + @Singleton + abstract fun bindSpeechRepository(impl: SpeechRepositoryImpl): SpeechRepository + + @Binds + @Singleton + abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository + + @Binds + @Singleton + abstract fun bindBannerRepository(impl: BannerRepositoryImpl): BannerRepository + + companion object { + @Provides + @Singleton + fun provideDataStore( + @ApplicationContext context: Context, + ): DataStore = context.settingsDataStore + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/di/DatabaseModule.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/di/DatabaseModule.kt new file mode 100644 index 0000000..0b70dfa --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/di/DatabaseModule.kt @@ -0,0 +1,40 @@ +package com.librechat.android.core.data.di + +import android.content.Context +import androidx.room.Room +import com.librechat.android.core.data.db.LibreChatDatabase +import com.librechat.android.core.data.db.dao.AgentDao +import com.librechat.android.core.data.db.dao.ConversationDao +import com.librechat.android.core.data.db.dao.ConversationTagDao +import com.librechat.android.core.data.db.dao.DraftDao +import com.librechat.android.core.data.db.dao.FileDao +import com.librechat.android.core.data.db.dao.MessageDao +import com.librechat.android.core.data.db.dao.PresetDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): LibreChatDatabase = + Room.databaseBuilder( + context, + LibreChatDatabase::class.java, + "librechat.db", + ).build() + + @Provides fun provideConversationDao(db: LibreChatDatabase): ConversationDao = db.conversationDao() + @Provides fun provideMessageDao(db: LibreChatDatabase): MessageDao = db.messageDao() + @Provides fun provideFileDao(db: LibreChatDatabase): FileDao = db.fileDao() + @Provides fun provideAgentDao(db: LibreChatDatabase): AgentDao = db.agentDao() + @Provides fun providePresetDao(db: LibreChatDatabase): PresetDao = db.presetDao() + @Provides fun provideConversationTagDao(db: LibreChatDatabase): ConversationTagDao = db.conversationTagDao() + @Provides fun provideDraftDao(db: LibreChatDatabase): DraftDao = db.draftDao() +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt new file mode 100644 index 0000000..c2f1c2d --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt @@ -0,0 +1,64 @@ +package com.librechat.android.core.data.mapper + +import com.librechat.android.core.data.db.entity.ConversationEntity +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.EModelEndpoint +import kotlinx.serialization.json.Json + +private val json = Json { ignoreUnknownKeys = true } + +fun Conversation.toEntity(): ConversationEntity = ConversationEntity( + conversationId = conversationId ?: "", + title = title ?: "New Chat", + user = user ?: "", + endpoint = endpoint?.name, + endpointType = endpointType?.name, + model = model, + agentId = agentId, + isArchived = isArchived, + tags = json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.serializer()), tags), + iconURL = iconURL, + greeting = greeting, + modelParams = null, + createdAt = parseTimestamp(createdAt), + updatedAt = parseTimestamp(updatedAt), +) + +fun ConversationEntity.toModel(): Conversation = Conversation( + conversationId = conversationId, + title = title, + user = user, + endpoint = endpoint?.let { name -> + EModelEndpoint.entries.find { it.name == name } + }, + endpointType = endpointType?.let { name -> + EModelEndpoint.entries.find { it.name == name } + }, + model = model, + agentId = agentId, + isArchived = isArchived, + tags = try { + json.decodeFromString>(tags) + } catch (_: Exception) { + emptyList() + }, + iconURL = iconURL, + greeting = greeting, + createdAt = formatTimestamp(createdAt), + updatedAt = formatTimestamp(updatedAt), +) + +fun List.toModels(): List = map { it.toModel() } + +private fun parseTimestamp(dateString: String?): Long { + if (dateString == null) return System.currentTimeMillis() + return try { + java.time.Instant.parse(dateString).toEpochMilli() + } catch (_: Exception) { + System.currentTimeMillis() + } +} + +private fun formatTimestamp(epochMillis: Long): String { + return java.time.Instant.ofEpochMilli(epochMillis).toString() +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt new file mode 100644 index 0000000..8fe8ba3 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt @@ -0,0 +1,84 @@ +package com.librechat.android.core.data.mapper + +import com.librechat.android.core.data.db.entity.MessageEntity +import com.librechat.android.core.model.Feedback +import com.librechat.android.core.model.FileReference +import com.librechat.android.core.model.Attachment +import com.librechat.android.core.model.Message +import com.librechat.android.core.model.MessageContentPart +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +private val json = Json { ignoreUnknownKeys = true } + +fun Message.toEntity(): MessageEntity = MessageEntity( + messageId = messageId, + conversationId = conversationId, + parentMessageId = parentMessageId, + sender = sender, + text = text, + content = content?.let { json.encodeToString(ListSerializer(MessageContentPart.serializer()), it) }, + isCreatedByUser = isCreatedByUser, + model = model, + endpoint = endpoint, + iconURL = iconURL, + unfinished = unfinished, + error = error, + finishReason = finishReason, + tokenCount = tokenCount, + feedback = feedback?.let { json.encodeToString(Feedback.serializer(), it) }, + files = files?.let { json.encodeToString(ListSerializer(FileReference.serializer()), it) }, + attachments = attachments?.let { json.encodeToString(ListSerializer(Attachment.serializer()), it) }, + metadata = metadata?.toString(), + createdAt = parseTimestamp(createdAt), + updatedAt = parseTimestamp(updatedAt), +) + +fun MessageEntity.toModel(): Message = Message( + messageId = messageId, + conversationId = conversationId, + parentMessageId = parentMessageId, + sender = sender, + text = text ?: "", + content = content?.let { + try { json.decodeFromString>(it) } catch (_: Exception) { null } + }, + isCreatedByUser = isCreatedByUser, + model = model, + endpoint = endpoint, + iconURL = iconURL, + unfinished = unfinished, + error = error, + finishReason = finishReason, + tokenCount = tokenCount, + feedback = feedback?.let { + try { json.decodeFromString(it) } catch (_: Exception) { null } + }, + files = files?.let { + try { json.decodeFromString>(it) } catch (_: Exception) { null } + }, + attachments = attachments?.let { + try { json.decodeFromString>(it) } catch (_: Exception) { null } + }, + metadata = metadata?.let { + try { json.decodeFromString(it) } catch (_: Exception) { null } + }, + createdAt = formatTimestamp(createdAt), + updatedAt = formatTimestamp(updatedAt), +) + +fun List.toModels(): List = map { it.toModel() } + +private fun parseTimestamp(dateString: String?): Long { + if (dateString == null) return System.currentTimeMillis() + return try { + java.time.Instant.parse(dateString).toEpochMilli() + } catch (_: Exception) { + System.currentTimeMillis() + } +} + +private fun formatTimestamp(epochMillis: Long): String { + return java.time.Instant.ofEpochMilli(epochMillis).toString() +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt new file mode 100644 index 0000000..8a138d6 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt @@ -0,0 +1,59 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Agent +import com.librechat.android.core.model.AgentAction +import com.librechat.android.core.model.AgentCategory +import com.librechat.android.core.model.AgentExpanded +import com.librechat.android.core.model.AgentTool +import com.librechat.android.core.model.PaginatedAgents +import com.librechat.android.core.model.ToolAuthStatus +import com.librechat.android.core.model.ToolCallRecord +import com.librechat.android.core.model.ToolCallResult +import com.librechat.android.core.model.request.CreateActionRequest +import com.librechat.android.core.model.request.CreateAgentRequest +import com.librechat.android.core.model.request.RevertAgentRequest +import com.librechat.android.core.model.request.ToolCallRequest +import com.librechat.android.core.model.request.UpdateAgentRequest + +interface AgentRepository { + + // Agent CRUD + suspend fun getAgents(category: String? = null): Result> + suspend fun getAgentsPaginated( + page: Int, + limit: Int, + search: String? = null, + category: String? = null, + ): Result + suspend fun getAgent(id: String): Result + /** + * Fetches complete agent data for editing via the /expanded endpoint. + * Unlike [getAgent] which returns limited view-only fields, this method + * returns the full agent document including instructions, tools, category, + * conversation_starters, model_parameters, and all other configuration. + * Never served from cache -- always fetches fresh from the server. + */ + suspend fun getAgentForEditing(id: String): Result + suspend fun createAgent(request: CreateAgentRequest): Result + suspend fun updateAgent(id: String, request: UpdateAgentRequest): Result + suspend fun deleteAgent(id: String): Result + suspend fun duplicateAgent(id: String): Result + suspend fun revertAgent(id: String, request: RevertAgentRequest): Result + suspend fun getAgentExpanded(id: String): Result + suspend fun getAgentCategories(): Result> + + // Avatar + suspend fun uploadAgentAvatar(id: String, imageBytes: ByteArray, mimeType: String): Result + + // Actions + suspend fun getAgentActions(): Result> + suspend fun addOrUpdateAction(agentId: String, request: CreateActionRequest): Result> + suspend fun deleteAction(agentId: String, actionId: String): Result + + // Tools + suspend fun getAvailableTools(): Result> + suspend fun getToolCalls(): Result> + suspend fun getToolAuthStatus(toolId: String): Result + suspend fun callTool(toolId: String, request: ToolCallRequest): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt new file mode 100644 index 0000000..34f54ca --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt @@ -0,0 +1,208 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.Agent +import com.librechat.android.core.model.AgentAction +import com.librechat.android.core.model.AgentCategory +import com.librechat.android.core.model.AgentExpanded +import com.librechat.android.core.model.AgentTool +import com.librechat.android.core.model.PaginatedAgents +import com.librechat.android.core.model.ToolAuthStatus +import com.librechat.android.core.model.ToolCallRecord +import com.librechat.android.core.model.ToolCallResult +import com.librechat.android.core.model.request.CreateActionRequest +import kotlinx.serialization.json.Json +import com.librechat.android.core.model.request.CreateAgentRequest +import com.librechat.android.core.model.request.RevertAgentRequest +import com.librechat.android.core.model.request.ToolCallRequest +import com.librechat.android.core.model.request.UpdateAgentRequest +import com.librechat.android.core.network.api.AgentsApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AgentRepositoryImpl @Inject constructor( + private val agentsApi: AgentsApi, +) : AgentRepository { + + private var cachedAgents: List? = null + + // --- Agent CRUD --- + + override suspend fun getAgents(category: String?): Result> { + return safeApiCall { + if (category == null && cachedAgents != null) { + return@safeApiCall cachedAgents!! + } + val agents = agentsApi.getAgents(category).data + if (category == null) { + cachedAgents = agents + } + agents + } + } + + override suspend fun getAgentsPaginated( + page: Int, + limit: Int, + search: String?, + category: String?, + ): Result { + return safeApiCall { + val response = agentsApi.getAgentsPaginated( + pageNumber = page, + pageSize = limit, + search = search, + category = category, + ) + PaginatedAgents( + agents = response.data, + hasMore = response.hasMore, + total = response.data.size, + ) + } + } + + override suspend fun getAgent(id: String): Result { + return safeApiCall { + cachedAgents?.find { it.id == id }?.let { return@safeApiCall it } + agentsApi.getAgent(id) + } + } + + override suspend fun getAgentForEditing(id: String): Result { + // Always fetch from server via /expanded endpoint -- never use cache. + // The cache and standard getAgent endpoint only have basic view fields. + return safeApiCall { + agentsApi.getAgentForEditing(id) + } + } + + override suspend fun createAgent(request: CreateAgentRequest): Result { + return safeApiCall { + val agent = agentsApi.createAgent(request) + invalidateCache() + agent + } + } + + override suspend fun updateAgent(id: String, request: UpdateAgentRequest): Result { + return safeApiCall { + val agent = agentsApi.updateAgent(id, request) + invalidateCache() + agent + } + } + + override suspend fun deleteAgent(id: String): Result { + return safeApiCall { + agentsApi.deleteAgent(id) + invalidateCache() + } + } + + override suspend fun duplicateAgent(id: String): Result { + return safeApiCall { + val agent = agentsApi.duplicateAgent(id) + invalidateCache() + agent + } + } + + override suspend fun revertAgent(id: String, request: RevertAgentRequest): Result { + return safeApiCall { + val agent = agentsApi.revertAgent(id, request) + invalidateCache() + agent + } + } + + override suspend fun getAgentExpanded(id: String): Result { + return safeApiCall { + agentsApi.getAgentExpanded(id) + } + } + + override suspend fun getAgentCategories(): Result> { + return safeApiCall { + agentsApi.getAgentCategories() + } + } + + // --- Avatar --- + + override suspend fun uploadAgentAvatar( + id: String, + imageBytes: ByteArray, + mimeType: String, + ): Result { + return safeApiCall { + val agent = agentsApi.uploadAgentAvatar(id, imageBytes, mimeType) + invalidateCache() + agent + } + } + + // --- Actions --- + + override suspend fun getAgentActions(): Result> { + return safeApiCall { + agentsApi.getAgentActions() + } + } + + override suspend fun addOrUpdateAction( + agentId: String, + request: CreateActionRequest, + ): Result> { + val json = Json { ignoreUnknownKeys = true; isLenient = true; coerceInputValues = true } + return safeApiCall { + val jsonArray = agentsApi.addOrUpdateAction(agentId, request) + // Response is [Agent, Action] JsonArray + if (jsonArray.size < 2) { + throw IllegalStateException("Expected array of 2 elements for agent action response, got ${jsonArray.size}") + } + val agent = json.decodeFromJsonElement(Agent.serializer(), jsonArray[0]) + val action = json.decodeFromJsonElement(AgentAction.serializer(), jsonArray[1]) + invalidateCache() + Pair(agent, action) + } + } + + override suspend fun deleteAction(agentId: String, actionId: String): Result { + return safeApiCall { + agentsApi.deleteAction(agentId, actionId) + } + } + + // --- Tools --- + + override suspend fun getAvailableTools(): Result> { + return safeApiCall { + agentsApi.getAvailableTools() + } + } + + override suspend fun getToolCalls(): Result> { + return safeApiCall { + agentsApi.getToolCalls() + } + } + + override suspend fun getToolAuthStatus(toolId: String): Result { + return safeApiCall { + agentsApi.getToolAuthStatus(toolId) + } + } + + override suspend fun callTool(toolId: String, request: ToolCallRequest): Result { + return safeApiCall { + agentsApi.callTool(toolId, request) + } + } + + private fun invalidateCache() { + cachedAgents = null + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt new file mode 100644 index 0000000..5d859a1 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.ApiKey +import com.librechat.android.core.model.request.CreateApiKeyRequest + +interface ApiKeyRepository { + suspend fun createApiKey(request: CreateApiKeyRequest): Result + suspend fun listApiKeys(): Result> + suspend fun getApiKey(id: String): Result + suspend fun deleteApiKey(id: String): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt new file mode 100644 index 0000000..4ab5e19 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt @@ -0,0 +1,31 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.ApiKey +import com.librechat.android.core.model.request.CreateApiKeyRequest +import com.librechat.android.core.network.api.ApiKeysApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ApiKeyRepositoryImpl @Inject constructor( + private val apiKeysApi: ApiKeysApi, +) : ApiKeyRepository { + + override suspend fun createApiKey(request: CreateApiKeyRequest): Result = safeApiCall { + apiKeysApi.createApiKey(request) + } + + override suspend fun listApiKeys(): Result> = safeApiCall { + apiKeysApi.listApiKeys() + } + + override suspend fun getApiKey(id: String): Result = safeApiCall { + apiKeysApi.getApiKey(id) + } + + override suspend fun deleteApiKey(id: String): Result = safeApiCall { + apiKeysApi.deleteApiKey(id) + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt new file mode 100644 index 0000000..e52b684 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.LoginOutcome +import com.librechat.android.core.model.User +import com.librechat.android.core.model.response.TwoFactorSetupResponse + +interface AuthRepository { + suspend fun login(email: String, password: String): Result + suspend fun loginWithOAuthToken(refreshToken: String): Result + suspend fun verifyTwoFactor(tempToken: String, code: String): Result + suspend fun verifyTempToken(tempToken: String, code: String): Result + suspend fun register(name: String, email: String, username: String, password: String): Result + suspend fun logout(): Result + suspend fun isLoggedIn(): Boolean + suspend fun enableTwoFactor(): Result + suspend fun confirmTwoFactor(code: String): Result + suspend fun disableTwoFactor(code: String): Result + suspend fun regenerateBackupCodes(): Result + suspend fun requestPasswordReset(email: String): Result + suspend fun resetPassword(userId: String, token: String, password: String, confirmPassword: String): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..66a4cb9 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,172 @@ +package com.librechat.android.core.data.repository + +import android.content.Context +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.LoginOutcome +import com.librechat.android.core.model.User +import com.librechat.android.core.model.response.TwoFactorSetupResponse +import com.librechat.android.core.network.api.AuthApi +import com.librechat.android.core.network.api.UserApi +import com.librechat.android.core.network.client.TokenManager +import dagger.hilt.android.qualifiers.ApplicationContext +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthRepositoryImpl @Inject constructor( + private val authApi: AuthApi, + private val userApi: UserApi, + private val tokenManager: TokenManager, + @ApplicationContext private val context: Context, +) : AuthRepository { + + override suspend fun login(email: String, password: String): Result { + return safeApiCall { + val result = authApi.login(email, password) + if (result.response.twoFactorRequired && result.response.tempToken != null) { + LoginOutcome.TwoFactorRequired( + result.response.tempToken + ?: throw IllegalStateException("Server indicated 2FA required but tempToken was null"), + ) + } else { + tokenManager.setTokens( + accessToken = result.response.token ?: "", + refreshToken = result.refreshToken ?: "", + ) + LoginOutcome.Success( + result.response.user + ?: throw IllegalStateException("Login succeeded but user was null in response"), + ) + } + } + } + + override suspend fun loginWithOAuthToken(refreshToken: String): Result { + return safeApiCall { + // Use the refresh token to get an access token from the server + val result = authApi.refresh(refreshToken) + // If the backend rotated the refresh token, use the new one; + // otherwise keep the original OAuth-provided token. + val effectiveRefreshToken = result.newRefreshToken ?: refreshToken + tokenManager.setTokens( + accessToken = result.response.token, + refreshToken = effectiveRefreshToken, + ) + // Fetch the user profile + userApi.getUser() + } + } + + /** + * Verify 2FA during login using temporary token. + * Uses /api/auth/2fa/verify-temp which expects { tempToken, token, backupCode? }. + */ + override suspend fun verifyTwoFactor(tempToken: String, code: String): Result { + return safeApiCall { + val result = authApi.verifyTempToken(tempToken = tempToken, totpCode = code) + tokenManager.setTokens( + accessToken = result.response.token ?: "", + refreshToken = result.refreshToken ?: "", + ) + result.response.user ?: throw IllegalStateException("No user in 2FA response") + } + } + + override suspend fun verifyTempToken(tempToken: String, code: String): Result { + return safeApiCall { + val result = authApi.verifyTempToken(tempToken = tempToken, totpCode = code) + tokenManager.setTokens( + accessToken = result.response.token ?: "", + refreshToken = result.refreshToken ?: "", + ) + result.response.user ?: throw IllegalStateException("No user in 2FA temp response") + } + } + + override suspend fun register( + name: String, + email: String, + username: String, + password: String, + ): Result { + return safeApiCall { + authApi.register(name, email, username, password, password) + } + } + + override suspend fun logout(): Result { + return safeApiCall { + try { + authApi.logout() + } finally { + tokenManager.clearTokens() + clearSessionCaches() + } + } + } + + /** + * Clears cached images and temporary files from the previous session. + * This prevents data from one user's session persisting after logout. + */ + private fun clearSessionCaches() { + try { + // Coil image cache (memory cache clears naturally as composables leave composition) + File(context.cacheDir, "image_cache").deleteRecursively() + // Temporary artifact files + File(context.cacheDir, "artifacts").deleteRecursively() + // Shared image files + File(context.cacheDir, "shared_images").deleteRecursively() + } catch (e: Exception) { + Timber.w(e, "Failed to clear session caches on logout") + } + } + + override suspend fun isLoggedIn(): Boolean { + return tokenManager.getAccessToken() != null + } + + override suspend fun enableTwoFactor(): Result { + return safeApiCall { + authApi.enableTwoFactor() + } + } + + override suspend fun confirmTwoFactor(code: String): Result { + return safeApiCall { + authApi.confirmTwoFactor(code) + } + } + + override suspend fun disableTwoFactor(code: String): Result { + return safeApiCall { + authApi.disableTwoFactor(code) + } + } + + override suspend fun regenerateBackupCodes(): Result { + return safeApiCall { + authApi.regenerateBackupCodes() + } + } + + override suspend fun requestPasswordReset(email: String): Result { + return safeApiCall { + authApi.requestPasswordReset(email) + } + } + + override suspend fun resetPassword( + userId: String, + token: String, + password: String, + confirmPassword: String, + ): Result { + return safeApiCall { + authApi.resetPassword(userId, token, password, confirmPassword) + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt new file mode 100644 index 0000000..f088584 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Balance + +interface BalanceRepository { + suspend fun getBalance(): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt new file mode 100644 index 0000000..5f9009e --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.Balance +import com.librechat.android.core.network.api.BalanceApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BalanceRepositoryImpl @Inject constructor( + private val balanceApi: BalanceApi, +) : BalanceRepository { + + override suspend fun getBalance(): Result = safeApiCall { + balanceApi.getBalance() + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt new file mode 100644 index 0000000..26a919d --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Banner + +/** Fetches server-configured banners for display in the app. */ +interface BannerRepository { + suspend fun getBanners(): Result> +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt new file mode 100644 index 0000000..1fd869b --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.Banner +import com.librechat.android.core.network.api.BannerApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BannerRepositoryImpl @Inject constructor( + private val bannerApi: BannerApi, +) : BannerRepository { + + override suspend fun getBanners(): Result> = + safeApiCall { bannerApi.getBanners() } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt new file mode 100644 index 0000000..6cd8daf --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt @@ -0,0 +1,60 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.model.FileReference +import com.librechat.android.core.model.request.AddedConversation +import com.librechat.android.core.model.request.ChatRequest +import com.librechat.android.core.model.request.EphemeralAgent +import com.librechat.android.core.model.request.NO_PARENT +import timber.log.Timber + +object ChatPayloadBuilder { + + fun build( + text: String, + conversationId: String?, + endpoint: String, + model: String?, + parentMessageId: String? = null, + agentId: String? = null, + overrideParentMessageId: String? = null, + responseMessageId: String? = null, + isEdited: Boolean = false, + isRegenerate: Boolean = false, + isContinued: Boolean = false, + webSearch: Boolean = false, + files: List? = null, + addedConvo: AddedConversation? = null, + ephemeralAgent: EphemeralAgent? = null, + ): ChatRequest { + val resolvedEndpoint = try { + EModelEndpoint.valueOf(endpoint.uppercase()) + } catch (_: IllegalArgumentException) { + EModelEndpoint.AGENTS + } + + val resolvedParentMessageId = parentMessageId ?: NO_PARENT + + Timber.d("[Comparison] ChatPayloadBuilder.build: addedConvo=%s, endpoint=%s, model=%s, agentId=%s", + if (addedConvo != null) "present(endpoint=${addedConvo.endpoint}, model=${addedConvo.model}, agentId=${addedConvo.agentId})" else "null", + resolvedEndpoint, model, agentId) + + return ChatRequest( + text = text, + conversationId = conversationId, + parentMessageId = resolvedParentMessageId, + endpoint = resolvedEndpoint, + model = model, + agentId = agentId, + overrideParentMessageId = overrideParentMessageId, + responseMessageId = responseMessageId, + isEdited = isEdited, + isRegenerate = isRegenerate, + isContinued = isContinued, + webSearch = if (webSearch) true else null, + files = files?.takeIf { it.isNotEmpty() }, + addedConvo = addedConvo, + ephemeralAgent = ephemeralAgent, + ) + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt new file mode 100644 index 0000000..e2df28d --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt @@ -0,0 +1,32 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.FileReference +import com.librechat.android.core.model.StreamEvent +import com.librechat.android.core.model.request.AddedConversation +import com.librechat.android.core.model.request.EphemeralAgent +import com.librechat.android.core.model.response.ChatStatusResponse +import kotlinx.coroutines.flow.Flow + +interface ChatRepository { + fun startChat( + text: String, + conversationId: String?, + endpoint: String, + model: String?, + parentMessageId: String? = null, + agentId: String? = null, + overrideParentMessageId: String? = null, + responseMessageId: String? = null, + isEdited: Boolean = false, + isRegenerate: Boolean = false, + isContinued: Boolean = false, + webSearch: Boolean = false, + files: List? = null, + addedConvo: AddedConversation? = null, + ephemeralAgent: EphemeralAgent? = null, + ): Flow + suspend fun abortChat(streamId: String): Result + suspend fun checkStreamStatus(conversationId: String): ChatStatusResponse + fun resumeStream(conversationId: String): Flow +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt new file mode 100644 index 0000000..563a160 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt @@ -0,0 +1,96 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.FileReference +import com.librechat.android.core.model.StreamEvent +import com.librechat.android.core.model.request.AddedConversation +import com.librechat.android.core.model.request.ChatAbortRequest +import com.librechat.android.core.model.request.ChatRequest +import com.librechat.android.core.model.request.EphemeralAgent +import com.librechat.android.core.model.response.ChatStatusResponse +import com.librechat.android.core.network.api.ChatApi +import com.librechat.android.core.network.di.StreamingClient +import com.librechat.android.core.network.sse.SseClient +import io.ktor.client.HttpClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatRepositoryImpl @Inject constructor( + private val chatApi: ChatApi, + private val sseClient: SseClient, + @StreamingClient private val sseHttpClient: HttpClient, +) : ChatRepository { + + override fun startChat( + text: String, + conversationId: String?, + endpoint: String, + model: String?, + parentMessageId: String?, + agentId: String?, + overrideParentMessageId: String?, + responseMessageId: String?, + isEdited: Boolean, + isRegenerate: Boolean, + isContinued: Boolean, + webSearch: Boolean, + files: List?, + addedConvo: AddedConversation?, + ephemeralAgent: EphemeralAgent?, + ): Flow = flow { + // Phase 1: POST to start the chat - get back a streamId (= conversationId) + val request = ChatPayloadBuilder.build( + text = text, + conversationId = conversationId, + endpoint = endpoint, + model = model, + parentMessageId = parentMessageId, + agentId = agentId, + overrideParentMessageId = overrideParentMessageId, + responseMessageId = responseMessageId, + isEdited = isEdited, + isRegenerate = isRegenerate, + isContinued = isContinued, + webSearch = webSearch, + files = files, + addedConvo = addedConvo, + ephemeralAgent = ephemeralAgent, + ) + + Timber.d("[Comparison] startChat: POST addedConvo=%s, endpoint=%s", if (addedConvo != null) "present" else "null", endpoint) + + val startResponse = chatApi.startChat(endpoint, request) + val streamId = startResponse.conversationId + + // Emit a Created event from the POST response so the ViewModel + // knows the conversationId before any SSE events arrive. + emit(StreamEvent.Created( + conversationId = streamId, + messageId = "", + parentMessageId = "", + )) + + // Phase 2: GET the SSE stream using the streamId + val streamUrl = "api/agents/chat/stream/$streamId" + emitAll(sseClient.connect(sseHttpClient, streamUrl)) + } + + override suspend fun abortChat(streamId: String): Result = safeApiCall { + chatApi.abortChat(streamId) + } + + override suspend fun checkStreamStatus(conversationId: String): ChatStatusResponse { + return chatApi.getChatStatus(conversationId) + } + + override fun resumeStream(conversationId: String): Flow = flow { + val streamUrl = "api/agents/chat/stream/$conversationId" + emitAll(sseClient.connect(sseHttpClient, streamUrl, resume = true)) + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt new file mode 100644 index 0000000..5168b55 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt @@ -0,0 +1,38 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.EndpointConfig +import com.librechat.android.core.model.StartupConfig +import kotlinx.coroutines.flow.StateFlow + +/** + * Result of a backend version compatibility check. + */ +data class VersionCheckResult( + /** The version detected on the backend, or null if it could not be determined. */ + val backendVersion: String?, + /** The version this app was built for. */ + val supportedVersion: String, + /** Whether the versions are compatible (same major.minor). */ + val isCompatible: Boolean, +) + +interface ConfigRepository { + val startupConfig: StateFlow + val endpointConfigs: StateFlow> + val availableModels: StateFlow>> + suspend fun validateServerUrl(url: String): Result + suspend fun fetchStartupConfig(): Result + suspend fun fetchEndpoints(): Result> + suspend fun fetchModels(): Result>> + + /** + * Checks the backend version against this app's supported version. + * Returns a [VersionCheckResult] on success, or an error if the check fails. + * + * The check is best-effort: if the backend does not expose its version, + * the result will have [VersionCheckResult.backendVersion] = null and + * [VersionCheckResult.isCompatible] = true (fail-open). + */ + suspend fun checkBackendVersion(): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt new file mode 100644 index 0000000..b1ba4b8 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt @@ -0,0 +1,189 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.BackendVersion +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.data.datastore.ConfigCacheDataStore +import com.librechat.android.core.model.EndpointConfig +import com.librechat.android.core.model.StartupConfig +import com.librechat.android.core.network.api.ConfigApi +import com.librechat.android.core.network.client.ApiException +import io.ktor.client.plugins.HttpRequestTimeoutException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerializationException +import timber.log.Timber +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConfigRepositoryImpl @Inject constructor( + private val configApi: ConfigApi, + private val configCache: ConfigCacheDataStore, +) : ConfigRepository { + + private val _startupConfig = MutableStateFlow(null) + override val startupConfig: StateFlow = _startupConfig.asStateFlow() + + private val _endpointConfigs = MutableStateFlow>(emptyMap()) + override val endpointConfigs: StateFlow> = _endpointConfigs.asStateFlow() + + private val _availableModels = MutableStateFlow>>(emptyMap()) + override val availableModels: StateFlow>> = _availableModels.asStateFlow() + + override suspend fun validateServerUrl(url: String): Result { + return try { + val config = configApi.getStartupConfig() + if (!isValidLibreChatConfig(config)) { + Result.Error(message = "This doesn't appear to be a LibreChat server") + } else { + _startupConfig.value = config + configCache.saveStartupConfig(config) + Result.Success(config) + } + } catch (e: SerializationException) { + Result.Error(e, "This doesn't appear to be a LibreChat server") + } catch (e: ApiException) { + val message = when (e.statusCode) { + 404 -> "This doesn't appear to be a LibreChat server" + in 500..599 -> "Server error. Please try again later." + else -> e.message ?: "Could not connect to server" + } + Result.Error(e, message) + } catch (e: HttpRequestTimeoutException) { + Result.Error(e, "Connection timed out. Check the URL and try again.") + } catch (e: IOException) { + Result.Error(e, "Could not reach the server. Check the URL and your connection.") + } catch (e: Exception) { + Result.Error(e, "This doesn't appear to be a LibreChat server") + } + } + + /** + * Validates that the config response contains fields specific to LibreChat. + * The `serverDomain` and `instanceProjectId` fields are distinctive to LibreChat's + * /api/config endpoint and unlikely to appear in arbitrary JSON APIs. + */ + private fun isValidLibreChatConfig(config: StartupConfig): Boolean { + return config.serverDomain.isNotBlank() || config.instanceProjectId != null + } + + override suspend fun fetchStartupConfig(): Result { + // Emit cached value first if state is empty + if (_startupConfig.value == null) { + configCache.loadStartupConfig()?.let { cached -> + _startupConfig.value = cached + } + } + + val result = safeApiCall { + val config = configApi.getStartupConfig() + _startupConfig.value = config + configCache.saveStartupConfig(config) + config + } + + // On network failure, return cached data if available + if (result is Result.Error) { + val cached = _startupConfig.value + if (cached != null) { + Timber.d("Using cached startup config (network unavailable)") + return Result.Success(cached) + } + } + return result + } + + override suspend fun fetchEndpoints(): Result> { + if (_endpointConfigs.value.isEmpty()) { + configCache.loadEndpointConfigs()?.let { cached -> + _endpointConfigs.value = cached + } + } + + val result = safeApiCall { + val endpoints = configApi.getEndpoints() + _endpointConfigs.value = endpoints + configCache.saveEndpointConfigs(endpoints) + endpoints + } + + if (result is Result.Error) { + val cached = _endpointConfigs.value + if (cached.isNotEmpty()) { + Timber.d("Using cached endpoint configs (network unavailable)") + return Result.Success(cached) + } + } + return result + } + + override suspend fun fetchModels(): Result>> { + if (_availableModels.value.isEmpty()) { + configCache.loadAvailableModels()?.let { cached -> + _availableModels.value = cached + } + } + + val result = safeApiCall { + val models = configApi.getModels() + _availableModels.value = models + configCache.saveAvailableModels(models) + models + } + + if (result is Result.Error) { + val cached = _availableModels.value + if (cached.isNotEmpty()) { + Timber.d("Using cached models (network unavailable)") + return Result.Success(cached) + } + } + return result + } + + /** + * Checks the backend version against the supported version. + * + * Version detection strategy (in order): + * 1. The `version` field in the startup config (future-proof: the backend may add this) + * 2. Parsing the `customFooter` field for a "LibreChat vX.Y.Z" pattern + * + * If neither source provides a version, the check passes (fail-open) with + * [VersionCheckResult.backendVersion] = null and [VersionCheckResult.isCompatible] = true. + */ + override suspend fun checkBackendVersion(): Result { + return safeApiCall { + // Ensure we have the startup config (use cached if available) + val config = _startupConfig.value ?: run { + val fetchResult = fetchStartupConfig() + (fetchResult as? Result.Success)?.data + } + + val supported = BackendVersion.SUPPORTED_BACKEND_VERSION + + // Strategy 1: Check for explicit version field + val detectedVersion = config?.version?.trimStart('v', 'V') + // Strategy 2: Parse customFooter for version pattern + ?: BackendVersion.extractVersionFromFooter(config?.customFooter) + + if (detectedVersion != null) { + Timber.d("Backend version detected: %s (supported: %s)", detectedVersion, supported) + VersionCheckResult( + backendVersion = detectedVersion, + supportedVersion = supported, + isCompatible = BackendVersion.isCompatible(supported, detectedVersion), + ) + } else { + Timber.d("Backend version could not be determined, skipping check") + VersionCheckResult( + backendVersion = null, + supportedVersion = supported, + isCompatible = true, + ) + } + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt new file mode 100644 index 0000000..ffd27e6 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt @@ -0,0 +1,33 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Conversation +import kotlinx.coroutines.flow.Flow + +interface ConversationRepository { + fun observeConversations(isArchived: Boolean = false): Flow>> + suspend fun loadNextPage( + cursor: String?, + tags: List? = null, + search: String? = null, + sortBy: String? = null, + sortDirection: String? = null, + isArchived: Boolean = false, + ): Result + suspend fun getConversation(id: String): Result + suspend fun updateTitle(id: String, title: String): Result + suspend fun generateTitle(conversationId: String): Result + suspend fun archive(id: String, isArchived: Boolean): Result + suspend fun delete(id: String): Result + suspend fun forkConversation( + conversationId: String, + messageId: String, + option: String? = null, + splitAtTarget: Boolean? = null, + latestMessageId: String? = null, + ): Result + suspend fun duplicateConversation(conversationId: String, title: String?): Result + suspend fun importConversation(jsonContent: String): Result + suspend fun deleteAll(): Result + suspend fun saveConversation(conversation: Conversation) +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt new file mode 100644 index 0000000..73adadb --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt @@ -0,0 +1,170 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.data.db.dao.ConversationDao +import com.librechat.android.core.data.mapper.toEntity +import com.librechat.android.core.data.mapper.toModel +import com.librechat.android.core.data.mapper.toModels +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.request.ForkConversationRequest +import com.librechat.android.core.network.api.ConversationsApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConversationRepositoryImpl @Inject constructor( + private val conversationsApi: ConversationsApi, + private val conversationDao: ConversationDao, +) : ConversationRepository { + + override fun observeConversations(isArchived: Boolean): Flow>> { + return conversationDao.getAllConversations( + isArchived = isArchived, + ).map { entities -> + Result.Success(entities.toModels()) as Result> + } + } + + override suspend fun loadNextPage( + cursor: String?, + tags: List?, + search: String?, + sortBy: String?, + sortDirection: String?, + isArchived: Boolean, + ): Result { + return safeApiCall { + val response = conversationsApi.getConversations( + cursor = cursor, + isArchived = isArchived, + tags = tags, + search = search, + sortBy = sortBy, + sortDirection = sortDirection, + ) + // Cache conversations locally + val entities = response.conversations.map { it.toEntity() } + conversationDao.upsertAll(entities) + response.nextCursor + } + } + + override suspend fun getConversation(id: String): Result { + // Try local cache first + val cached = conversationDao.getById(id) + if (cached != null) return Result.Success(cached.toModel()) + + // Fetch from network + return safeApiCall { + val conversation = conversationsApi.getConversation(id) + conversationDao.upsert(conversation.toEntity()) + conversation + } + } + + override suspend fun updateTitle(id: String, title: String): Result { + return safeApiCall { + val updated = conversationsApi.updateTitle(id, title) + conversationDao.updateTitle(id, title, System.currentTimeMillis()) + updated + } + } + + override suspend fun generateTitle(conversationId: String): Result { + return safeApiCall { + val response = conversationsApi.generateTitle(conversationId) + // Update the local cache with the generated title + conversationDao.updateTitle(conversationId, response.title, System.currentTimeMillis()) + response.title + } + } + + override suspend fun archive(id: String, isArchived: Boolean): Result { + return safeApiCall { + val updated = conversationsApi.archive(id, isArchived) + conversationDao.updateArchived(id, isArchived, System.currentTimeMillis()) + updated + } + } + + override suspend fun delete(id: String): Result { + return safeApiCall { + conversationsApi.deleteConversation(id) + conversationDao.deleteById(id) + } + } + + override suspend fun forkConversation( + conversationId: String, + messageId: String, + option: String?, + splitAtTarget: Boolean?, + latestMessageId: String?, + ): Result { + return safeApiCall { + val response = conversationsApi.forkConversation( + ForkConversationRequest( + conversationId = conversationId, + messageId = messageId, + option = option, + splitAtTarget = splitAtTarget, + latestMessageId = latestMessageId, + ), + ) + val conversation = response.conversation + conversation.conversationId?.let { conversationDao.upsert(conversation.toEntity()) } + conversation + } + } + + override suspend fun duplicateConversation( + conversationId: String, + title: String?, + ): Result { + return safeApiCall { + val duplicated = conversationsApi.duplicateConversation(conversationId, title) + duplicated.conversationId?.let { conversationDao.upsert(duplicated.toEntity()) } + duplicated + } + } + + override suspend fun importConversation(jsonContent: String): Result { + return safeApiCall { + val fileBytes = jsonContent.toByteArray(Charsets.UTF_8) + val imported = conversationsApi.importConversations( + fileBytes = fileBytes, + filename = "conversation.json", + contentType = "application/json", + ) + imported.conversationId?.let { conversationDao.upsert(imported.toEntity()) } + imported + } + } + + override suspend fun deleteAll(): Result { + return safeApiCall { + conversationsApi.deleteAllConversations() + conversationDao.deleteAll() + } + } + + override suspend fun saveConversation(conversation: Conversation) { + val id = conversation.conversationId ?: return + if (id.isBlank()) return + conversationDao.upsert(conversation.toEntity()) + } + + suspend fun refreshConversations() { + try { + val response = conversationsApi.getConversations() + val entities = response.conversations.map { it.toEntity() } + conversationDao.upsertAll(entities) + } catch (e: Exception) { + Timber.w(e, "Failed to refresh conversations") + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt new file mode 100644 index 0000000..9c96d6a --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.data.repository + +import kotlinx.coroutines.flow.Flow + +interface DraftRepository { + suspend fun getDraft(conversationId: String): String? + suspend fun saveDraft(conversationId: String, text: String) + suspend fun deleteDraft(conversationId: String) + fun observeAllDrafts(): Flow> +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt new file mode 100644 index 0000000..51a7197 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt @@ -0,0 +1,76 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.di.Dispatcher +import com.librechat.android.core.common.di.LibreChatDispatchers +import com.librechat.android.core.data.db.dao.DraftDao +import com.librechat.android.core.data.db.entity.DraftEntity +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DraftRepositoryImpl @Inject constructor( + private val draftDao: DraftDao, + @Dispatcher(LibreChatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : DraftRepository { + + private val cache = ConcurrentHashMap() + private val debounceJobs = ConcurrentHashMap() + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + + override suspend fun getDraft(conversationId: String): String? { + // Check cache first for fast reads + cache[conversationId]?.let { return it } + // Fallback to Room + val entity = draftDao.getDraft(conversationId) + val text = entity?.text + if (text != null) { + cache[conversationId] = text + } + return text + } + + override suspend fun saveDraft(conversationId: String, text: String) { + if (text.isBlank()) { + deleteDraft(conversationId) + return + } + // Update cache immediately for fast reads + cache[conversationId] = text + // Schedule debounced write to Room + debounceJobs[conversationId]?.cancel() + debounceJobs[conversationId] = scope.launch { + delay(DEBOUNCE_MS) + draftDao.upsertDraft( + DraftEntity( + conversationId = conversationId, + text = text, + ), + ) + debounceJobs.remove(conversationId) + } + } + + override suspend fun deleteDraft(conversationId: String) { + cache.remove(conversationId) + debounceJobs.remove(conversationId)?.cancel() + draftDao.deleteDraft(conversationId) + } + + override fun observeAllDrafts(): Flow> = + draftDao.observeAllDrafts().map { entities -> + entities.associate { it.conversationId to it.text } + } + + companion object { + private const val DEBOUNCE_MS = 500L + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepository.kt new file mode 100644 index 0000000..44451e2 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepository.kt @@ -0,0 +1,26 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.FileObject +import com.librechat.android.core.model.request.DeleteFileEntry + +interface FileRepository { + suspend fun getFiles(): Result> + suspend fun uploadFile(bytes: ByteArray, filename: String, type: String): Result + suspend fun uploadFile( + bytes: ByteArray, + filename: String, + type: String, + fileId: String? = null, + endpoint: String? = null, + model: String? = null, + agentId: String? = null, + messageFile: Boolean? = null, + width: Int? = null, + height: Int? = null, + ): Result + suspend fun deleteFiles(files: List): Result + suspend fun downloadFile(userId: String, fileId: String): Result + suspend fun getAgentFiles(agentId: String): Result> + suspend fun downloadCode(sessionId: String, fileId: String): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt new file mode 100644 index 0000000..a187319 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt @@ -0,0 +1,72 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.FileObject +import com.librechat.android.core.model.request.DeleteFileEntry +import com.librechat.android.core.model.request.DeleteFilesRequest +import com.librechat.android.core.network.api.FilesApi +import com.librechat.android.core.network.api.FilesExtApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FileRepositoryImpl @Inject constructor( + private val filesApi: FilesApi, + private val filesExtApi: FilesExtApi, +) : FileRepository { + + override suspend fun getFiles(): Result> = + safeApiCall { filesApi.getFiles() } + + override suspend fun uploadFile(bytes: ByteArray, filename: String, type: String): Result = + safeApiCall { + filesApi.uploadFile( + bytes = bytes, + filename = filename, + type = type, + // file_id and endpoint are required by the backend; provide defaults + fileId = java.util.UUID.randomUUID().toString(), + endpoint = "agents", + ) + } + + override suspend fun uploadFile( + bytes: ByteArray, + filename: String, + type: String, + fileId: String?, + endpoint: String?, + model: String?, + agentId: String?, + messageFile: Boolean?, + width: Int?, + height: Int?, + ): Result = + safeApiCall { + filesApi.uploadFile( + bytes = bytes, + filename = filename, + type = type, + fileId = fileId ?: java.util.UUID.randomUUID().toString(), + endpoint = endpoint, + model = model, + agentId = agentId, + messageFile = messageFile, + width = width, + height = height, + ) + } + + override suspend fun deleteFiles(files: List): Result = + safeApiCall { filesApi.deleteFiles(DeleteFilesRequest(files = files)) } + + override suspend fun downloadFile(userId: String, fileId: String): Result = + safeApiCall { filesApi.downloadFile(userId, fileId) } + + override suspend fun getAgentFiles(agentId: String): Result> = + safeApiCall { filesExtApi.getAgentFiles(agentId) } + + override suspend fun downloadCode(sessionId: String, fileId: String): Result = + safeApiCall { filesExtApi.downloadCode(sessionId, fileId) } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt new file mode 100644 index 0000000..5dee4af --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.UserKey +import com.librechat.android.core.model.request.UpdateKeyRequest + +interface KeyRepository { + suspend fun getKeyExpiry(): Result> + suspend fun updateKey(request: UpdateKeyRequest): Result + suspend fun deleteKey(name: String): Result + suspend fun deleteAllKeys(): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt new file mode 100644 index 0000000..57845a9 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt @@ -0,0 +1,31 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.UserKey +import com.librechat.android.core.model.request.UpdateKeyRequest +import com.librechat.android.core.network.api.KeysApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class KeyRepositoryImpl @Inject constructor( + private val keysApi: KeysApi, +) : KeyRepository { + + override suspend fun getKeyExpiry(): Result> = safeApiCall { + keysApi.getKeyExpiry() + } + + override suspend fun updateKey(request: UpdateKeyRequest): Result = safeApiCall { + keysApi.updateKey(request) + } + + override suspend fun deleteKey(name: String): Result = safeApiCall { + keysApi.deleteKey(name) + } + + override suspend fun deleteAllKeys(): Result = safeApiCall { + keysApi.deleteAllKeys() + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepository.kt new file mode 100644 index 0000000..7bbcbb1 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepository.kt @@ -0,0 +1,27 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.mcp.McpApiKeyConfig +import com.librechat.android.core.model.mcp.McpOAuthConfig +import com.librechat.android.core.model.mcp.McpReinitializeResponse +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerStatus +import com.librechat.android.core.model.mcp.McpServerType +import com.librechat.android.core.model.mcp.McpTool + +/** Repository for MCP server management. Uses serverName as unique identifier for operations. */ +interface McpRepository { + suspend fun listServers(): Result> + suspend fun createServer( + name: String, + description: String? = null, + url: String, + type: McpServerType, + apiKey: McpApiKeyConfig? = null, + oauth: McpOAuthConfig? = null, + ): Result + suspend fun deleteServer(serverName: String): Result + suspend fun reinitialize(serverName: String): Result + suspend fun getTools(): Result> + suspend fun getConnectionStatus(): Result> +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt new file mode 100644 index 0000000..4240e0f --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt @@ -0,0 +1,45 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.mcp.McpApiKeyConfig +import com.librechat.android.core.model.mcp.McpOAuthConfig +import com.librechat.android.core.model.mcp.McpReinitializeResponse +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerStatus +import com.librechat.android.core.model.mcp.McpServerType +import com.librechat.android.core.model.mcp.McpTool +import com.librechat.android.core.network.api.McpApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class McpRepositoryImpl @Inject constructor( + private val mcpApi: McpApi, +) : McpRepository { + + override suspend fun listServers(): Result> = + safeApiCall { mcpApi.listServers() } + + override suspend fun createServer( + name: String, + description: String?, + url: String, + type: McpServerType, + apiKey: McpApiKeyConfig?, + oauth: McpOAuthConfig?, + ): Result = + safeApiCall { mcpApi.createServer(name, description, url, type, apiKey, oauth) } + + override suspend fun deleteServer(serverName: String): Result = + safeApiCall { mcpApi.deleteServer(serverName) } + + override suspend fun reinitialize(serverName: String): Result = + safeApiCall { mcpApi.reinitialize(serverName) } + + override suspend fun getTools(): Result> = + safeApiCall { mcpApi.getTools() } + + override suspend fun getConnectionStatus(): Result> = + safeApiCall { mcpApi.getConnectionStatus() } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt new file mode 100644 index 0000000..07ce201 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Memory +import com.librechat.android.core.model.MemoryPreferences +import com.librechat.android.core.model.request.CreateMemoryRequest +import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest +import com.librechat.android.core.model.request.UpdateMemoryRequest + +/** Repository for user memory CRUD operations. Uses memory key as unique identifier. */ +interface MemoryRepository { + suspend fun getMemories(): Result> + suspend fun createMemory(request: CreateMemoryRequest): Result + suspend fun updatePreferences(request: UpdateMemoryPreferencesRequest): Result + suspend fun updateMemory(key: String, request: UpdateMemoryRequest): Result + suspend fun deleteMemory(key: String): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt new file mode 100644 index 0000000..72f0d1a --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.Memory +import com.librechat.android.core.model.MemoryPreferences +import com.librechat.android.core.model.request.CreateMemoryRequest +import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest +import com.librechat.android.core.model.request.UpdateMemoryRequest +import com.librechat.android.core.network.api.MemoriesApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MemoryRepositoryImpl @Inject constructor( + private val memoriesApi: MemoriesApi, +) : MemoryRepository { + + override suspend fun getMemories(): Result> = + safeApiCall { memoriesApi.getMemories() } + + override suspend fun createMemory(request: CreateMemoryRequest): Result = + safeApiCall { memoriesApi.createMemory(request) } + + override suspend fun updatePreferences(request: UpdateMemoryPreferencesRequest): Result = + safeApiCall { memoriesApi.updatePreferences(request) } + + override suspend fun updateMemory(key: String, request: UpdateMemoryRequest): Result = + safeApiCall { memoriesApi.updateMemory(key, request) } + + override suspend fun deleteMemory(key: String): Result = + safeApiCall { memoriesApi.deleteMemory(key) } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt new file mode 100644 index 0000000..e5be81c --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt @@ -0,0 +1,14 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Message +import kotlinx.coroutines.flow.Flow + +interface MessageRepository { + fun observeMessages(conversationId: String): Flow> + suspend fun getMessages(conversationId: String): Result> + suspend fun refreshMessages(conversationId: String): Result> + suspend fun updateFeedback(conversationId: String, messageId: String, feedback: String?): Result + suspend fun updateMessageText(conversationId: String, messageId: String, text: String): Result + suspend fun branchMessage(conversationId: String, messageId: String, agentId: String? = null): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt new file mode 100644 index 0000000..2394149 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt @@ -0,0 +1,98 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.data.db.dao.MessageDao +import com.librechat.android.core.data.mapper.toEntity +import com.librechat.android.core.data.mapper.toModels +import com.librechat.android.core.model.Message +import com.librechat.android.core.model.request.BranchMessageRequest +import com.librechat.android.core.model.request.UpdateMessageRequest +import com.librechat.android.core.network.api.MessagesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MessageRepositoryImpl @Inject constructor( + private val messagesApi: MessagesApi, + private val messageDao: MessageDao, +) : MessageRepository { + + override fun observeMessages(conversationId: String): Flow> { + return messageDao.getMessagesForConversation(conversationId) + .map { entities -> entities.toModels() } + } + + override suspend fun getMessages(conversationId: String): Result> { + val result = safeApiCall { + val messages = messagesApi.getMessages(conversationId) + // Cache locally + val entities = messages.map { it.toEntity() } + messageDao.upsertAll(entities) + messages + } + + // On network failure, fall back to cached messages + if (result is Result.Error) { + val cached = messageDao.getMessagesForConversation(conversationId).first() + if (cached.isNotEmpty()) { + Timber.d("Using cached messages for $conversationId (network unavailable)") + return Result.Success(cached.toModels()) + } + } + return result + } + + override suspend fun refreshMessages(conversationId: String): Result> { + return safeApiCall { + val messages = messagesApi.getMessages(conversationId) + val entities = messages.map { it.toEntity() } + // Full replace: delete stale cache then insert fresh server data + messageDao.replaceAllForConversation(conversationId, entities) + messages + } + } + + override suspend fun updateFeedback( + conversationId: String, + messageId: String, + feedback: String?, + ): Result { + return safeApiCall { + messagesApi.updateFeedback(conversationId, messageId, feedback) + // Update local cache + messageDao.updateFeedback(messageId, feedback) + } + } + + override suspend fun updateMessageText( + conversationId: String, + messageId: String, + text: String, + ): Result { + return safeApiCall { + messagesApi.updateMessage(conversationId, messageId, UpdateMessageRequest(text = text)) + messageDao.updateText(messageId, text) + } + } + + override suspend fun branchMessage( + conversationId: String, + messageId: String, + agentId: String?, + ): Result { + return safeApiCall { + messagesApi.branchMessage( + BranchMessageRequest( + conversationId = conversationId, + messageId = messageId, + agentId = agentId, + ), + ) + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt new file mode 100644 index 0000000..5a26923 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt @@ -0,0 +1,11 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Preset + +interface PresetRepository { + suspend fun getAll(): Result> + suspend fun create(preset: Preset): Result + suspend fun update(preset: Preset): Result + suspend fun delete(presetId: String): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt new file mode 100644 index 0000000..6556cf1 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt @@ -0,0 +1,48 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.Preset +import com.librechat.android.core.network.api.PresetsApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PresetRepositoryImpl @Inject constructor( + private val presetsApi: PresetsApi, +) : PresetRepository { + + private var cachedPresets: List? = null + + override suspend fun getAll(): Result> { + return safeApiCall { + if (cachedPresets != null) return@safeApiCall cachedPresets!! + val presets = presetsApi.getPresets() + cachedPresets = presets + presets + } + } + + override suspend fun create(preset: Preset): Result { + return safeApiCall { + val created = presetsApi.createPreset(preset) + cachedPresets = null + created + } + } + + override suspend fun update(preset: Preset): Result { + return safeApiCall { + val updated = presetsApi.updatePreset(preset) + cachedPresets = null + updated + } + } + + override suspend fun delete(presetId: String): Result { + return safeApiCall { + presetsApi.deletePreset(presetId) + cachedPresets = null + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt new file mode 100644 index 0000000..fe0ecd5 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt @@ -0,0 +1,23 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Prompt +import com.librechat.android.core.model.PromptGroup +import com.librechat.android.core.model.request.AddPromptToGroupRequest +import com.librechat.android.core.model.request.CreatePromptRequest +import com.librechat.android.core.model.request.UpdatePromptGroupRequest +import com.librechat.android.core.model.request.UpdatePromptTagRequest +import com.librechat.android.core.model.response.PromptGroupListResponse + +interface PromptRepository { + suspend fun getGroups(pageSize: Int = 10, cursor: String? = null): Result + suspend fun getGroup(groupId: String): Result + suspend fun create(request: CreatePromptRequest): Result + suspend fun update(groupId: String, request: UpdatePromptGroupRequest): Result + suspend fun delete(groupId: String): Result + suspend fun getPrompt(promptId: String): Result + suspend fun deletePrompt(promptId: String): Result + suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Result + suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result + suspend fun getPromptsByGroupId(groupId: String): Result> +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt new file mode 100644 index 0000000..930061f --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt @@ -0,0 +1,80 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.Prompt +import com.librechat.android.core.model.PromptGroup +import com.librechat.android.core.model.request.AddPromptToGroupRequest +import com.librechat.android.core.model.request.CreatePromptRequest +import com.librechat.android.core.model.request.UpdatePromptGroupRequest +import com.librechat.android.core.model.request.UpdatePromptTagRequest +import com.librechat.android.core.model.response.PromptGroupListResponse +import com.librechat.android.core.network.api.PromptsApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PromptRepositoryImpl @Inject constructor( + private val promptsApi: PromptsApi, +) : PromptRepository { + + override suspend fun getGroups(pageSize: Int, cursor: String?): Result { + return safeApiCall { + promptsApi.getPromptGroups(pageSize = pageSize, cursor = cursor) + } + } + + override suspend fun getGroup(groupId: String): Result { + return safeApiCall { + promptsApi.getPromptGroup(groupId) + } + } + + override suspend fun create(request: CreatePromptRequest): Result { + return safeApiCall { + promptsApi.createPrompt(request) + } + } + + override suspend fun update(groupId: String, request: UpdatePromptGroupRequest): Result { + return safeApiCall { + promptsApi.updatePromptGroup(groupId, request) + } + } + + override suspend fun delete(groupId: String): Result { + return safeApiCall { + promptsApi.deletePromptGroup(groupId) + } + } + + override suspend fun getPrompt(promptId: String): Result { + return safeApiCall { + promptsApi.getPrompt(promptId) + } + } + + override suspend fun deletePrompt(promptId: String): Result { + return safeApiCall { + promptsApi.deletePrompt(promptId) + } + } + + override suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Result { + return safeApiCall { + promptsApi.addPromptToGroup(groupId, request) + } + } + + override suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result { + return safeApiCall { + promptsApi.updatePromptProductionTag(promptId, request) + } + } + + override suspend fun getPromptsByGroupId(groupId: String): Result> { + return safeApiCall { + promptsApi.getPromptsByGroupId(groupId) + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt new file mode 100644 index 0000000..3ca04b1 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.Conversation + +interface SearchRepository { + suspend fun search(query: String): Result> +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt new file mode 100644 index 0000000..c1ae542 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt @@ -0,0 +1,20 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.network.api.ConversationsApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SearchRepositoryImpl @Inject constructor( + private val conversationsApi: ConversationsApi, +) : SearchRepository { + + override suspend fun search(query: String): Result> = + safeApiCall { + val response = conversationsApi.getConversations(search = query) + response.conversations + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt new file mode 100644 index 0000000..5a0219b --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt @@ -0,0 +1,15 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.SharedLink +import com.librechat.android.core.model.response.ShareLinkCheckResponse +import com.librechat.android.core.model.response.SharedLinksResponse + +interface ShareRepository { + suspend fun createShareLink(conversationId: String): Result + suspend fun getSharedLinks(): Result> + suspend fun getSharedLinksPaginated(cursor: String? = null): Result + suspend fun checkShareLink(conversationId: String): Result + suspend fun toggleShareVisibility(shareId: String): Result + suspend fun deleteShareLink(shareId: String): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt new file mode 100644 index 0000000..b4b1352 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt @@ -0,0 +1,58 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.SharedLink +import com.librechat.android.core.model.response.ShareLinkCheckResponse +import com.librechat.android.core.model.response.SharedLinksResponse +import com.librechat.android.core.network.api.ShareApi +import com.librechat.android.core.network.client.ServerUrlProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShareRepositoryImpl @Inject constructor( + private val shareApi: ShareApi, + private val serverUrlProvider: ServerUrlProvider, +) : ShareRepository { + + override suspend fun createShareLink(conversationId: String): Result { + return safeApiCall { + val sharedLink = shareApi.createShareLink(conversationId) + val shareId = sharedLink.shareId + ?: throw IllegalStateException("Server returned no shareId") + val baseUrl = serverUrlProvider.getBaseUrl().trimEnd('/') + "$baseUrl/share/$shareId" + } + } + + override suspend fun getSharedLinks(): Result> { + return safeApiCall { + shareApi.getSharedLinks().links + } + } + + override suspend fun getSharedLinksPaginated(cursor: String?): Result { + return safeApiCall { + shareApi.getSharedLinks(cursor = cursor) + } + } + + override suspend fun checkShareLink(conversationId: String): Result { + return safeApiCall { + shareApi.checkShareLink(conversationId) + } + } + + override suspend fun toggleShareVisibility(shareId: String): Result { + return safeApiCall { + shareApi.toggleShareVisibility(shareId) + } + } + + override suspend fun deleteShareLink(shareId: String): Result { + return safeApiCall { + shareApi.deleteShareLink(shareId) + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt new file mode 100644 index 0000000..4bdb1cc --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt @@ -0,0 +1,13 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.speech.SpeechConfig +import com.librechat.android.core.model.speech.SpeechToTextResponse +import com.librechat.android.core.model.speech.TtsVoice + +interface SpeechRepository { + suspend fun transcribeAudio(audioData: ByteArray, mimeType: String): Result + suspend fun synthesizeSpeech(text: String, voice: String? = null, model: String? = null): Result + suspend fun getVoices(): Result> + suspend fun getSpeechConfig(): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt new file mode 100644 index 0000000..c54c423 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt @@ -0,0 +1,36 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.speech.SpeechConfig +import com.librechat.android.core.model.speech.SpeechToTextResponse +import com.librechat.android.core.model.speech.TextToSpeechRequest +import com.librechat.android.core.model.speech.TtsVoice +import com.librechat.android.core.network.api.SpeechApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SpeechRepositoryImpl @Inject constructor( + private val speechApi: SpeechApi, +) : SpeechRepository { + + override suspend fun transcribeAudio( + audioData: ByteArray, + mimeType: String, + ): Result = + safeApiCall { speechApi.speechToText(audioData, mimeType) } + + override suspend fun synthesizeSpeech( + text: String, + voice: String?, + model: String?, + ): Result = + safeApiCall { speechApi.textToSpeechManual(TextToSpeechRequest(text, voice, model)) } + + override suspend fun getVoices(): Result> = + safeApiCall { speechApi.getVoices() } + + override suspend fun getSpeechConfig(): Result = + safeApiCall { speechApi.getSpeechConfig() } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepository.kt new file mode 100644 index 0000000..207b0ea --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepository.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.ConversationTag + +interface TagRepository { + suspend fun getTags(): Result> + suspend fun updateConversationTags(conversationId: String, tags: List): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt new file mode 100644 index 0000000..bab763d --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt @@ -0,0 +1,24 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.ConversationTag +import com.librechat.android.core.network.api.TagsApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TagRepositoryImpl @Inject constructor( + private val tagsApi: TagsApi, +) : TagRepository { + + override suspend fun getTags(): Result> = + safeApiCall { tagsApi.getTags() } + + override suspend fun updateConversationTags( + conversationId: String, + tags: List, + ): Result = safeApiCall { + tagsApi.updateConversationTags(conversationId, tags) + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt new file mode 100644 index 0000000..5628bb6 --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.User +import com.librechat.android.core.model.UserFavorite +import com.librechat.android.core.model.response.TermsResponse + +interface UserRepository { + suspend fun getUser(): Result + suspend fun deleteUser(): Result + suspend fun uploadAvatar(imageBytes: ByteArray): Result + suspend fun verifyEmail(token: String): Result + suspend fun resendVerification(email: String): Result + suspend fun getTerms(): Result + suspend fun acceptTerms(): Result + suspend fun getFavorites(): Result> + suspend fun updateFavorites(favorites: List): Result +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt new file mode 100644 index 0000000..148d78f --- /dev/null +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt @@ -0,0 +1,54 @@ +package com.librechat.android.core.data.repository + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.safeApiCall +import com.librechat.android.core.model.User +import com.librechat.android.core.model.UserFavorite +import com.librechat.android.core.model.request.ResendVerificationRequest +import com.librechat.android.core.model.request.VerifyEmailRequest +import com.librechat.android.core.model.response.TermsResponse +import com.librechat.android.core.network.api.UserApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UserRepositoryImpl @Inject constructor( + private val userApi: UserApi, +) : UserRepository { + + override suspend fun getUser(): Result = safeApiCall { + userApi.getUser() + } + + override suspend fun deleteUser(): Result = safeApiCall { + userApi.deleteUser() + } + + override suspend fun uploadAvatar(imageBytes: ByteArray): Result = safeApiCall { + userApi.updateAvatar(imageBytes) + } + + override suspend fun verifyEmail(token: String): Result = safeApiCall { + userApi.verifyEmail(VerifyEmailRequest(token = token)) + } + + override suspend fun resendVerification(email: String): Result = safeApiCall { + userApi.resendVerification(ResendVerificationRequest(email = email)) + } + + override suspend fun getTerms(): Result = safeApiCall { + userApi.getTerms() + } + + override suspend fun acceptTerms(): Result = safeApiCall { + userApi.acceptTerms() + } + + override suspend fun getFavorites(): Result> = safeApiCall { + userApi.getFavorites() + } + + override suspend fun updateFavorites(favorites: List): Result = safeApiCall { + userApi.updateFavorites(favorites) + } +} diff --git a/core/model/CLAUDE.md b/core/model/CLAUDE.md new file mode 100644 index 0000000..03867dd --- /dev/null +++ b/core/model/CLAUDE.md @@ -0,0 +1,30 @@ +# core:model + +All `@Serializable` data classes -- domain models, DTOs, request/response wrappers. This is the shared contract between network, data, and feature modules. + +## What This Module Provides + +- **Domain models**: `User`, `Conversation`, `Message`, `MessageContentPart`, `Agent`, `Preset`, `Prompt`, `PromptGroup`, `ConversationTag`, `SharedLink`, `FileObject`, `Balance`, `StartupConfig`, `ModelSpec`, `ServerConnection` +- **Enums**: `EModelEndpoint`, `ContentType`, `StepType`, `ToolCallType`, `FeedbackRating`, `Provider` +- **StreamEvent sealed hierarchy**: `ContentDelta`, `ToolCallStart`, `ToolCallComplete`, `ThinkingDelta`, `Final`, `Sync`, `Error`, `Created`, `Step`, `AttachmentCreated` +- **Request/response wrappers**: `LoginRequest`, `LoginResponse`, `RegisterRequest`, `ChatRequest`, `ConvoUpdateBody`, `ForkConvoRequest`, etc. + +## Key Patterns + +- **`arg` wrapper for mutation endpoints**: The backend reads `req.body.arg` on conversation update/delete/archive. Request bodies use: `{ "arg": { "conversationId": "...", "title": "..." } }`. See `ConvoUpdateBody` / `ConvoDeleteBody`. +- **Nullable fields with defaults**: Most fields are nullable with `= null` defaults. The backend schema is loose (Mongoose + Zod). Always use `@SerialName` when the JSON key differs from Kotlin naming. +- **`ignoreUnknownKeys = true`**: The server may add new fields at any time. Never assume the response shape is exhaustive. + +## Directory Convention + +- Top-level: domain model classes (Conversation.kt, Message.kt, etc.) +- `request/`: Request body data classes (LoginRequest, ChatRequest, etc.) +- `response/`: API response wrappers (LoginResponse, ConversationListResponse, etc.) + +## Rules + +- **Pure Kotlin only.** NO Android framework dependencies. No `Context`, no `Parcelable`. +- Only dependency beyond `:core:common` is `kotlinx-serialization-json`. +- Use `@Serializable` on every data class. Use `@SerialName` for snake_case JSON fields. +- Use `JsonObject` / `JsonElement` for truly polymorphic/mixed-type fields (e.g., `Agent.avatar`, `Feedback.tag`). +- Convention plugins: `librechat.android.library` + `librechat.kotlin.serialization`. diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts new file mode 100644 index 0000000..ee7d4e6 --- /dev/null +++ b/core/model/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("librechat.android.library") + id("librechat.kotlin.serialization") +} + +android { + namespace = "com.librechat.android.core.model" +} + +dependencies { + implementation(project(":core:common")) + implementation(libs.kotlinx.serialization.json) + // compose-runtime is needed for @Immutable annotation on model classes, + // enabling proper skip logic in the Compose compiler. + implementation("androidx.compose.runtime:runtime:1.7.6") +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt new file mode 100644 index 0000000..db44cd7 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt @@ -0,0 +1,52 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Immutable +@Serializable +data class Agent( + val id: String, + @SerialName("_id") val mongoId: String? = null, + val name: String? = null, + val description: String? = null, + val instructions: String? = null, + val avatar: JsonElement? = null, + val provider: String? = null, + val model: String? = null, + @SerialName("model_parameters") val modelParameters: JsonElement? = null, + val artifacts: String? = null, + @SerialName("access_level") val accessLevel: Int? = null, + @SerialName("recursion_limit") val recursionLimit: Int? = null, + @SerialName("hide_sequential_outputs") val hideSequentialOutputs: Boolean? = null, + @SerialName("end_after_tools") val endAfterTools: Boolean? = null, + val category: String? = "general", + val author: String? = null, + val authorName: String? = null, + @SerialName("is_promoted") val isPromoted: Boolean = false, + val isPublic: Boolean? = null, + @SerialName("conversation_starters") val conversationStarters: List = emptyList(), + val tools: List? = null, + val actions: List? = null, + @SerialName("agent_ids") val agentIds: List? = null, + val edges: List? = null, + val isCollaborative: Boolean? = null, + @SerialName("projectIds") val projectIds: List = emptyList(), + val updatedAt: String? = null, + val createdAt: String? = null, + @SerialName("support_contact") val supportContact: JsonElement? = null, + val mcpServerNames: List? = null, +) { + val avatarUrl: String? + get() = try { + when (avatar) { + is JsonObject -> avatar.jsonObject["filepath"]?.jsonPrimitive?.content + else -> avatar?.jsonPrimitive?.content?.takeIf { it.startsWith("http") } + } + } catch (_: Exception) { null } +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentAction.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentAction.kt new file mode 100644 index 0000000..a63d1e5 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentAction.kt @@ -0,0 +1,39 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class AgentAction( + @SerialName("action_id") val actionId: String? = null, + @SerialName("agent_id") val agentId: String? = null, + val type: String? = null, + val metadata: ActionMetadata? = null, + val version: Int? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +@Serializable +data class ActionMetadata( + val domain: String? = null, + val auth: ActionAuth? = null, + @SerialName("raw_spec") val rawSpec: String? = null, + @SerialName("api_key") val apiKey: String? = null, + @SerialName("oauth_client_id") val oauthClientId: String? = null, + @SerialName("oauth_client_secret") val oauthClientSecret: String? = null, + @SerialName("privacy_policy_url") val privacyPolicyUrl: String? = null, +) + +@Serializable +data class ActionAuth( + val type: String? = null, + @SerialName("authorization_type") val authorizationType: String? = null, + @SerialName("custom_auth_header") val customAuthHeader: String? = null, + @SerialName("authorization_url") val authorizationUrl: String? = null, + @SerialName("client_url") val clientUrl: String? = null, + val scope: String? = null, + @SerialName("token_exchange_method") val tokenExchangeMethod: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentCategory.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentCategory.kt new file mode 100644 index 0000000..fa48e7f --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentCategory.kt @@ -0,0 +1,13 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class AgentCategory( + val value: String, + val label: String? = null, + val count: Int = 0, + val description: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentExpanded.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentExpanded.kt new file mode 100644 index 0000000..54a3311 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentExpanded.kt @@ -0,0 +1,40 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +data class AgentExpanded( + val id: String, + @SerialName("_id") val mongoId: String? = null, + val name: String? = null, + val description: String? = null, + val instructions: String? = null, + val avatar: JsonElement? = null, + val provider: String? = null, + val model: String? = null, + val category: String? = "general", + val author: String? = null, + val authorName: String? = null, + @SerialName("is_promoted") val isPromoted: Boolean = false, + val isPublic: Boolean? = null, + @SerialName("conversation_starters") val conversationStarters: List = emptyList(), + val tools: List = emptyList(), + val actions: List = emptyList(), + val isCollaborative: Boolean? = null, + @SerialName("projectIds") val projectIds: List = emptyList(), + val updatedAt: String? = null, + val createdAt: String? = null, +) { + val avatarUrl: String? + get() = try { + when (avatar) { + is JsonObject -> avatar.jsonObject["filepath"]?.jsonPrimitive?.content + else -> avatar?.jsonPrimitive?.content?.takeIf { it.startsWith("http") } + } + } catch (_: Exception) { null } +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentTool.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentTool.kt new file mode 100644 index 0000000..5201b38 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/AgentTool.kt @@ -0,0 +1,19 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Immutable +@Serializable +data class AgentTool( + @SerialName("tool_id") val toolId: String? = null, + val name: String? = null, + val description: String? = null, + val type: String? = null, + val pluginKey: String? = null, + val icon: String? = null, + val metadata: JsonElement? = null, + val isAvailable: Boolean = true, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ApiKey.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ApiKey.kt new file mode 100644 index 0000000..78024cc --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ApiKey.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class ApiKey( + val id: String? = null, + val name: String, + @SerialName("key") val keyValue: String? = null, + val keyPrefix: String? = null, + val createdAt: String? = null, + val expiresAt: String? = null, + val lastUsedAt: String? = null, +) + +@Serializable +data class ApiKeysResponse( + val keys: List = emptyList(), +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Balance.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Balance.kt new file mode 100644 index 0000000..9ff8d1c --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Balance.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Balance( + val tokenCredits: Long = 0, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Banner.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Banner.kt new file mode 100644 index 0000000..cec9e82 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Banner.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class Banner( + val bannerId: String? = null, + val message: String? = null, + val displayFrom: String? = null, + val displayTo: String? = null, + val type: String? = null, + val isPublic: Boolean? = null, + val persistable: Boolean? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt new file mode 100644 index 0000000..748f501 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt @@ -0,0 +1,39 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Immutable +@Serializable +data class Conversation( + val conversationId: String? = null, + val title: String? = "New Chat", + val user: String? = null, + val endpoint: EModelEndpoint? = null, + val endpointType: EModelEndpoint? = null, + val model: String? = null, + @SerialName("agent_id") val agentId: String? = null, + @SerialName("assistant_id") val assistantId: String? = null, + val tags: List = emptyList(), + val isArchived: Boolean = false, + val temperature: Double? = null, + @SerialName("top_p") val topP: Double? = null, + val topK: Int? = null, + @SerialName("frequency_penalty") val frequencyPenalty: Double? = null, + @SerialName("presence_penalty") val presencePenalty: Double? = null, + val maxOutputTokens: Int? = null, + val maxContextTokens: Int? = null, + val maxTokens: Int? = null, + val system: String? = null, + @SerialName("reasoning_effort") val reasoningEffort: String? = null, + val stop: List? = null, + val iconURL: String? = null, + val greeting: String? = null, + val spec: String? = null, + val tools: List? = null, + @SerialName("web_search") val webSearch: Boolean? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationExport.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationExport.kt new file mode 100644 index 0000000..a9cff13 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationExport.kt @@ -0,0 +1,11 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationExport( + val conversation: Conversation, + val messages: List, + val exportedAt: Long, + val version: Int = 1, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationTag.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationTag.kt new file mode 100644 index 0000000..fdc54e0 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationTag.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class ConversationTag( + @SerialName("_id") val id: String? = null, + val tag: String? = null, + val user: String? = null, + val description: String? = null, + val count: Int = 0, + val position: Int = 0, + val createdAt: String? = null, + val updatedAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Endpoint.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Endpoint.kt new file mode 100644 index 0000000..0938ce5 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Endpoint.kt @@ -0,0 +1,68 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class EModelEndpoint { + @SerialName("azureOpenAI") AZURE_OPENAI, + @SerialName("openAI") OPENAI, + @SerialName("google") GOOGLE, + @SerialName("anthropic") ANTHROPIC, + @SerialName("assistants") ASSISTANTS, + @SerialName("azureAssistants") AZURE_ASSISTANTS, + @SerialName("agents") AGENTS, + @SerialName("custom") CUSTOM, + @SerialName("bedrock") BEDROCK, +} + +@Serializable +enum class ContentType { + @SerialName("text") TEXT, + @SerialName("think") THINK, + @SerialName("text_delta") TEXT_DELTA, + @SerialName("tool_call") TOOL_CALL, + @SerialName("image_file") IMAGE_FILE, + @SerialName("image_url") IMAGE_URL, + @SerialName("video_url") VIDEO_URL, + @SerialName("input_audio") INPUT_AUDIO, + @SerialName("agent_update") AGENT_UPDATE, + @SerialName("error") ERROR, +} + +@Serializable +enum class StepType { + @SerialName("tool_calls") TOOL_CALLS, + @SerialName("message_creation") MESSAGE_CREATION, +} + +@Serializable +enum class ToolCallType { + @SerialName("function") FUNCTION, + @SerialName("retrieval") RETRIEVAL, + @SerialName("file_search") FILE_SEARCH, + @SerialName("code_interpreter") CODE_INTERPRETER, + @SerialName("tool_call") TOOL_CALL, +} + +@Serializable +enum class FeedbackRating { + @SerialName("thumbsUp") THUMBS_UP, + @SerialName("thumbsDown") THUMBS_DOWN, +} + +@Serializable +enum class Provider { + @SerialName("openAI") OPENAI, + @SerialName("anthropic") ANTHROPIC, + @SerialName("azureOpenAI") AZURE, + @SerialName("google") GOOGLE, + @SerialName("vertexai") VERTEXAI, + @SerialName("bedrock") BEDROCK, + @SerialName("mistralai") MISTRALAI, + @SerialName("mistral") MISTRAL, + @SerialName("deepseek") DEEPSEEK, + @SerialName("moonshot") MOONSHOT, + @SerialName("openrouter") OPENROUTER, + @SerialName("xai") XAI, +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/EndpointConfig.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/EndpointConfig.kt new file mode 100644 index 0000000..fa276f7 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/EndpointConfig.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class EndpointConfig( + val type: String? = null, + val order: Int? = null, + val iconURL: String? = null, + val modelDisplayLabel: String? = null, + val name: String? = null, + val userProvide: Boolean? = null, + val userProvideURL: Boolean? = null, + val capabilities: List = emptyList(), + val disableBuilder: Boolean? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Feedback.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Feedback.kt new file mode 100644 index 0000000..04c9918 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Feedback.kt @@ -0,0 +1,13 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Immutable +@Serializable +data class Feedback( + val rating: FeedbackRating, + val tag: JsonElement? = null, + val text: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/FileModel.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/FileModel.kt new file mode 100644 index 0000000..83aed40 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/FileModel.kt @@ -0,0 +1,52 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class FileObject( + @SerialName("file_id") val fileId: String, + @SerialName("temp_file_id") val tempFileId: String? = null, + val filename: String, + val filepath: String, + val type: String, + val bytes: Long, + val source: String? = null, + val user: String? = null, + val conversationId: String? = null, + val messageId: String? = null, + val width: Int? = null, + val height: Int? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +@Immutable +@Serializable +data class FileReference( + @SerialName("file_id") val fileId: String? = null, + val filename: String? = null, + val filepath: String? = null, + val type: String? = null, + val width: Int? = null, + val height: Int? = null, + val bytes: Long? = null, + val source: String? = null, +) + +@Immutable +@Serializable +data class Attachment( + @SerialName("file_id") val fileId: String? = null, + val filename: String? = null, + val filepath: String? = null, + val type: String? = null, + val conversationId: String? = null, + val messageId: String? = null, + val toolCallId: String? = null, + val expiresAt: Long? = null, + val width: Int? = null, + val height: Int? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/LoginOutcome.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/LoginOutcome.kt new file mode 100644 index 0000000..0317677 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/LoginOutcome.kt @@ -0,0 +1,6 @@ +package com.librechat.android.core.model + +sealed interface LoginOutcome { + data class Success(val user: User) : LoginOutcome + data class TwoFactorRequired(val tempToken: String) : LoginOutcome +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Memory.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Memory.kt new file mode 100644 index 0000000..4042fae --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Memory.kt @@ -0,0 +1,19 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class Memory( + val key: String, + val value: String, + @SerialName("createdAt") val createdAt: String? = null, + @SerialName("updatedAt") val updatedAt: String? = null, +) + +@Serializable +data class MemoryPreferences( + val enabled: Boolean = true, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Message.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Message.kt new file mode 100644 index 0000000..35cc3e4 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Message.kt @@ -0,0 +1,36 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Immutable +@Serializable +data class Message( + val messageId: String, + val conversationId: String, + val parentMessageId: String? = null, + val responseMessageId: String? = null, + val overrideParentMessageId: String? = null, + val user: String? = null, + val model: String? = null, + val endpoint: String? = null, + val sender: String? = null, + val text: String = "", + val isCreatedByUser: Boolean = false, + val error: Boolean = false, + val unfinished: Boolean = false, + @SerialName("finish_reason") val finishReason: String? = null, + val tokenCount: Int? = null, + val iconURL: String? = null, + val content: List? = null, + val files: List? = null, + val attachments: List? = null, + val feedback: Feedback? = null, + @SerialName("thread_id") val threadId: String? = null, + val metadata: JsonObject? = null, + val createdAt: String? = null, + val updatedAt: String? = null, + val title: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/MessageContentPart.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/MessageContentPart.kt new file mode 100644 index 0000000..1e1c81f --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/MessageContentPart.kt @@ -0,0 +1,78 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Immutable +@Serializable +data class MessageContentPart( + val type: ContentType, + val text: String? = null, + val think: String? = null, + val error: String? = null, + @SerialName("tool_call_ids") val toolCallIds: List? = null, + @SerialName("tool_call") val toolCall: AgentToolCall? = null, + @SerialName("image_file") val imageFile: ImageFileContent? = null, + @SerialName("image_url") val imageUrl: ImageUrlContent? = null, + @SerialName("video_url") val videoUrl: VideoUrlContent? = null, + @SerialName("input_audio") val inputAudio: InputAudioContent? = null, + @SerialName("agent_update") val agentUpdate: AgentUpdateContent? = null, + val agentId: String? = null, + val groupId: Int? = null, + val stepIndex: Int? = null, + val siblingIndex: Int? = null, +) + +@Serializable +data class AgentToolCall( + val type: ToolCallType? = null, + val name: String? = null, + val args: JsonElement? = null, + val id: String? = null, + val output: String? = null, + val auth: String? = null, + @SerialName("expires_at") val expiresAt: Long? = null, + val function: FunctionCall? = null, +) + +@Serializable +data class FunctionCall( + val name: String? = null, + val arguments: String? = null, + val output: String? = null, +) + +@Serializable +data class ImageFileContent( + @SerialName("file_id") val fileId: String? = null, + val filepath: String? = null, + val filename: String? = null, + val width: Int? = null, + val height: Int? = null, +) + +@Serializable +data class ImageUrlContent( + val url: String? = null, + val detail: String? = null, +) + +@Serializable +data class VideoUrlContent( + val url: String? = null, +) + +@Serializable +data class InputAudioContent( + val data: String? = null, + val format: String? = null, +) + +@Serializable +data class AgentUpdateContent( + val index: Int? = null, + val runId: String? = null, + val agentId: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/PaginatedAgents.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/PaginatedAgents.kt new file mode 100644 index 0000000..b7731a5 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/PaginatedAgents.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model + +/** Wrapper for a single page of agent marketplace results. */ +data class PaginatedAgents( + val agents: List, + val hasMore: Boolean, + val total: Int, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ParameterDefinition.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ParameterDefinition.kt new file mode 100644 index 0000000..36a3397 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ParameterDefinition.kt @@ -0,0 +1,32 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Describes a dynamic model parameter that can be configured per-endpoint. + * Used by ModelParameterSheet to render appropriate controls. + */ +@Serializable +data class ParameterDefinition( + val key: String, + val label: String, + val type: ParameterType, + val min: Double? = null, + val max: Double? = null, + val step: Double? = null, + val default: String? = null, + val description: String? = null, + val options: List? = null, +) + +@Serializable +enum class ParameterType { + @SerialName("slider") SLIDER, + @SerialName("dropdown") DROPDOWN, + @SerialName("checkbox") CHECKBOX, + @SerialName("text") TEXT, + @SerialName("switch") SWITCH, + @SerialName("textarea") TEXTAREA, + @SerialName("tags") TAGS, +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt new file mode 100644 index 0000000..25416d2 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt @@ -0,0 +1,30 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class Preset( + val presetId: String? = null, + val title: String? = null, + val user: String? = null, + val defaultPreset: Boolean? = null, + val order: Int? = null, + val endpoint: EModelEndpoint? = null, + val endpointType: EModelEndpoint? = null, + val model: String? = null, + @SerialName("agent_id") val agentId: String? = null, + val temperature: Double? = null, + @SerialName("top_p") val topP: Double? = null, + val topK: Int? = null, + @SerialName("max_tokens") val maxTokens: Int? = null, + val system: String? = null, + val iconURL: String? = null, + val greeting: String? = null, + val stop: List? = null, + @SerialName("web_search") val webSearch: Boolean? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Prompt.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Prompt.kt new file mode 100644 index 0000000..d8d1ec2 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Prompt.kt @@ -0,0 +1,40 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class Prompt( + @SerialName("_id") val id: String? = null, + val groupId: String, + val author: String, + val prompt: String, + val type: String, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +@Serializable +data class ProductionPromptEmbed( + val prompt: String? = null, +) + +@Immutable +@Serializable +data class PromptGroup( + @SerialName("_id") val id: String? = null, + val name: String, + val numberOfGenerations: Int = 0, + val oneliner: String? = null, + val category: String? = null, + val productionId: String? = null, + val author: String, + val authorName: String, + val command: String? = null, + val prompts: List = emptyList(), + val productionPrompt: ProductionPromptEmbed? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ServerConnection.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ServerConnection.kt new file mode 100644 index 0000000..d9e9d46 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ServerConnection.kt @@ -0,0 +1,7 @@ +package com.librechat.android.core.model + +data class ServerConnection( + val url: String, + val name: String, + val isDefault: Boolean = false, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/SharedLink.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/SharedLink.kt new file mode 100644 index 0000000..ac2879e --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/SharedLink.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class SharedLink( + @SerialName("_id") val id: String? = null, + val conversationId: String, + val title: String? = null, + val user: String? = null, + val shareId: String? = null, + val isPublic: Boolean = true, + val createdAt: String? = null, + val updatedAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/SseContentEvent.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/SseContentEvent.kt new file mode 100644 index 0000000..bc92bb4 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/SseContentEvent.kt @@ -0,0 +1,33 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +@Serializable +data class SseContentEvent( + val type: String? = null, + val conversationId: String? = null, + val messageId: String? = null, + val parentMessageId: String? = null, + val responseMessageId: String? = null, + val text: String? = null, + val message: Message? = null, + val conversation: Conversation? = null, + val content: List? = null, + val final: Boolean? = null, + val sync: Boolean? = null, + val created: Boolean? = null, + val error: String? = null, + val stepType: String? = null, + val stepData: JsonElement? = null, + val toolCallId: String? = null, + val toolName: String? = null, + val input: String? = null, + val output: String? = null, + val attachments: List? = null, + val fileId: String? = null, + val filename: String? = null, + val fileType: String? = null, + val extra: JsonObject? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt new file mode 100644 index 0000000..70f9cd9 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt @@ -0,0 +1,133 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +@Immutable +@Serializable +data class StartupConfig( + val appTitle: String = "LibreChat", + val emailLoginEnabled: Boolean = true, + val registrationEnabled: Boolean = false, + val socialLoginEnabled: Boolean = false, + val socialLogins: List? = null, + val googleLoginEnabled: Boolean = false, + val githubLoginEnabled: Boolean = false, + val discordLoginEnabled: Boolean = false, + val facebookLoginEnabled: Boolean = false, + val appleLoginEnabled: Boolean = false, + val openidLoginEnabled: Boolean = false, + val openidLabel: String? = null, + val openidImageUrl: String? = null, + val openidAutoRedirect: Boolean = false, + val samlLoginEnabled: Boolean = false, + val samlLabel: String? = null, + val samlImageUrl: String? = null, + val emailEnabled: Boolean = true, + val passwordResetEnabled: Boolean = false, + val publicSharedLinksEnabled: Boolean = false, + val sharedLinksEnabled: Boolean = false, + val serverDomain: String = "", + val helpAndFaqURL: String? = null, + val minPasswordLength: Int? = null, + val showBirthdayIcon: Boolean = false, + val modelSpecs: ModelSpecs? = null, + @SerialName("interface") val interfaceConfig: InterfaceConfig? = null, + val turnstile: TurnstileConfig? = null, + val balance: BalanceConfig? = null, + val analyticsGtmId: String? = null, + val instanceProjectId: String? = null, + val bundlerURL: String? = null, + val staticBundlerURL: String? = null, + val sharePointFilePickerEnabled: Boolean? = null, + val sharePointBaseUrl: String? = null, + val sharePointPickerGraphScope: String? = null, + val sharePointPickerSharePointScope: String? = null, + val openidReuseTokens: Boolean? = null, + val conversationImportMaxFileSize: Long? = null, + val webSearch: JsonObject? = null, + val ldap: LdapConfig? = null, + val customFooter: String? = null, + /** Backend version, if the server includes it in the config response (not yet standard). */ + val version: String? = null, +) + +@Immutable +@Serializable +data class ModelSpecs( + val list: List = emptyList(), +) + +@Serializable +data class ModelSpec( + val name: String, + val label: String? = null, + val preset: Preset? = null, + val iconURL: String? = null, + val description: String? = null, +) + +@Immutable +@Serializable +data class InterfaceConfig( + val privacyPolicy: PrivacyPolicyConfig? = null, + val termsOfService: TermsOfServiceConfig? = null, + val endpointsMenu: Boolean = true, + val modelSelect: Boolean = true, + val parameters: Boolean = true, + val presets: Boolean = true, + val sidePanel: Boolean = true, + val bookmarks: Boolean = true, + val prompts: JsonElement? = null, + val agents: JsonElement? = null, + val multiConvo: Boolean = true, + val memories: Boolean = true, + val temporaryChat: Boolean = true, + val runCode: Boolean = true, + val webSearch: Boolean = true, + val fileSearch: Boolean = true, + val fileCitations: Boolean = true, + val customWelcome: String? = null, +) + +@Serializable +data class PrivacyPolicyConfig( + val externalUrl: String? = null, + val openNewTab: Boolean? = null, +) + +@Serializable +data class TermsOfServiceConfig( + val externalUrl: String? = null, +) + +@Serializable +data class TurnstileConfig( + val siteKey: String? = null, + val options: TurnstileOptions? = null, +) + +@Serializable +data class TurnstileOptions( + val language: String? = null, + val size: String? = null, +) + +@Serializable +data class BalanceConfig( + val enabled: Boolean = false, + val startBalance: Long? = null, + val autoRefillEnabled: Boolean = false, + val refillIntervalValue: Int? = null, + val refillIntervalUnit: String? = null, + val refillAmount: Long? = null, +) + +@Serializable +data class LdapConfig( + val enabled: Boolean = false, + val username: Boolean? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt new file mode 100644 index 0000000..baca871 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt @@ -0,0 +1,72 @@ +package com.librechat.android.core.model + +sealed interface StreamEvent { + data class ContentDelta( + val chunk: String, + val messageId: String? = null, + val agentId: String? = null, + val groupId: Int? = null, + ) : StreamEvent + + data class ToolCallStart( + val toolCallId: String, + val toolName: String, + val input: String, + val agentId: String? = null, + val groupId: Int? = null, + ) : StreamEvent + + data class ToolCallComplete( + val toolCallId: String, + val output: String, + val attachments: List? = null, + val agentId: String? = null, + val groupId: Int? = null, + ) : StreamEvent + + data class ThinkingDelta( + val chunk: String, + val agentId: String? = null, + val groupId: Int? = null, + ) : StreamEvent + + data class AttachmentCreated( + val fileId: String, + val filename: String, + val type: String, + val filepath: String? = null, + val toolCallId: String? = null, + val width: Int? = null, + val height: Int? = null, + ) : StreamEvent + + data class Final( + val message: Message? = null, + val conversation: Conversation? = null, + val requestMessage: Message? = null, + val responseMessage: Message? = null, + val parseErrors: List = emptyList(), + ) : StreamEvent { + val hasParseErrors: Boolean get() = parseErrors.isNotEmpty() + } + + data class Sync( + val aggregatedContent: List, + ) : StreamEvent + + data class Error( + val message: String, + val code: String? = null, + ) : StreamEvent + + data class Step( + val stepType: String, + val stepData: String, + ) : StreamEvent + + data class Created( + val conversationId: String, + val messageId: String, + val parentMessageId: String, + ) : StreamEvent +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/SupportContact.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/SupportContact.kt new file mode 100644 index 0000000..19b230e --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/SupportContact.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class SupportContact( + val name: String? = null, + val email: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt new file mode 100644 index 0000000..acee321 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt @@ -0,0 +1,13 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ToolAuthStatus( + @SerialName("tool_id") val toolId: String? = null, + val authenticated: Boolean = false, + @SerialName("auth_type") val authType: String? = null, + @SerialName("auth_url") val authUrl: String? = null, + val message: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallRecord.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallRecord.kt new file mode 100644 index 0000000..97d39dd --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallRecord.kt @@ -0,0 +1,20 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class ToolCallRecord( + val id: String? = null, + @SerialName("tool_id") val toolId: String? = null, + @SerialName("agent_id") val agentId: String? = null, + @SerialName("conversation_id") val conversationId: String? = null, + val name: String? = null, + val input: JsonElement? = null, + val output: JsonElement? = null, + val status: String? = null, + val error: String? = null, + val duration: Long? = null, + val createdAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallResult.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallResult.kt new file mode 100644 index 0000000..fbdda4c --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallResult.kt @@ -0,0 +1,15 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class ToolCallResult( + @SerialName("tool_id") val toolId: String? = null, + val name: String? = null, + val output: JsonElement? = null, + val status: String? = null, + val error: String? = null, + val duration: Long? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/User.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/User.kt new file mode 100644 index 0000000..2654552 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/User.kt @@ -0,0 +1,31 @@ +package com.librechat.android.core.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class User( + val id: String? = null, + @SerialName("_id") val mongoId: String? = null, + val name: String? = null, + val username: String? = null, + val email: String, + val emailVerified: Boolean = false, + val avatar: String? = null, + val provider: String = "local", + val role: String = "USER", + val twoFactorEnabled: Boolean = false, + val termsAccepted: Boolean = false, + val favorites: List = emptyList(), + val createdAt: String? = null, + val updatedAt: String? = null, +) + +@Immutable +@Serializable +data class UserFavorite( + val agentId: String? = null, + val model: String? = null, + val endpoint: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/UserKey.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/UserKey.kt new file mode 100644 index 0000000..00c166b --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/UserKey.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UserKey( + val name: String, + val value: String? = null, + val expiresAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt new file mode 100644 index 0000000..8e55a88 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt @@ -0,0 +1,25 @@ +package com.librechat.android.core.model.mcp + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +/** + * Response from GET /api/mcp/connection/status. + * Backend returns: { success: true, connectionStatus: { "serverName": { connectionState, requiresOAuth, error? } } } + */ +@Immutable +@Serializable +data class McpConnectionStatusResponse( + val success: Boolean = false, + val connectionStatus: Map = emptyMap(), +) + +@Immutable +@Serializable +data class McpServerStatus( + val connectionState: String = "disconnected", + val requiresOAuth: Boolean = false, + val error: String? = null, +) { + val isConnected: Boolean get() = connectionState == "connected" +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt new file mode 100644 index 0000000..23da79c --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt @@ -0,0 +1,19 @@ +package com.librechat.android.core.model.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class McpOAuthStatus( + val status: String? = null, + @SerialName("server_name") val serverName: String? = null, + val error: String? = null, +) + +@Serializable +data class McpOAuthTokens( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("expires_in") val expiresIn: Long? = null, + @SerialName("token_type") val tokenType: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt new file mode 100644 index 0000000..a18504a --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt @@ -0,0 +1,28 @@ +package com.librechat.android.core.model.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateMcpServerRequest( + val name: String, + val url: String, + val type: McpServerType = McpServerType.SSE, + @SerialName("api_key") val apiKey: String? = null, +) + +@Serializable +data class UpdateMcpServerRequest( + val url: String? = null, + val type: McpServerType? = null, + @SerialName("api_key") val apiKey: String? = null, +) + +@Serializable +data class McpReinitializeResponse( + val success: Boolean = false, + val message: String? = null, + val serverName: String? = null, + val oauthRequired: Boolean? = null, + val oauthUrl: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpServer.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpServer.kt new file mode 100644 index 0000000..df699d8 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpServer.kt @@ -0,0 +1,70 @@ +package com.librechat.android.core.model.mcp + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents an MCP server as shown in the UI. + * The backend returns a map of server name -> config from GET /api/mcp/servers. + * This model is constructed from that map in the API layer. + */ +@Immutable +data class McpServer( + val name: String, + val url: String = "", + val type: McpServerType = McpServerType.SSE, + val title: String? = null, + val description: String? = null, + val tools: List = emptyList(), + val isConnected: Boolean = false, + val error: String? = null, + val apiKey: McpApiKeyConfig? = null, + val oauth: McpOAuthConfig? = null, +) + +@Serializable +enum class McpServerType { + @SerialName("sse") SSE, + @SerialName("streamable-http") STREAMABLE_HTTP, + @SerialName("http") HTTP, + @SerialName("stdio") STDIO, + @SerialName("websocket") WEBSOCKET, +} + +/** Auth mode for the MCP server add/edit dialog. */ +enum class McpAuthMode { NONE, API_KEY, OAUTH } + +/** Maps to backend apiKey object: { source, authorization_type, key?, custom_header? }. */ +@Immutable +@Serializable +data class McpApiKeyConfig( + val source: McpApiKeySource = McpApiKeySource.USER, + @SerialName("authorization_type") val authorizationType: McpAuthorizationType = McpAuthorizationType.BEARER, + val key: String? = null, + @SerialName("custom_header") val customHeader: String? = null, +) + +@Serializable +enum class McpApiKeySource { + @SerialName("admin") ADMIN, + @SerialName("user") USER, +} + +@Serializable +enum class McpAuthorizationType { + @SerialName("bearer") BEARER, + @SerialName("basic") BASIC, + @SerialName("custom") CUSTOM, +} + +/** Maps to backend oauth object. Only the most common fields are exposed in the UI. */ +@Immutable +@Serializable +data class McpOAuthConfig( + @SerialName("authorization_url") val authorizationUrl: String? = null, + @SerialName("token_url") val tokenUrl: String? = null, + @SerialName("client_id") val clientId: String? = null, + @SerialName("client_secret") val clientSecret: String? = null, + val scope: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpTool.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpTool.kt new file mode 100644 index 0000000..809a860 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpTool.kt @@ -0,0 +1,15 @@ +package com.librechat.android.core.model.mcp + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Immutable +@Serializable +data class McpTool( + val name: String, + val description: String? = null, + @SerialName("input_schema") val inputSchema: JsonObject? = null, + @SerialName("server_name") val serverName: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt new file mode 100644 index 0000000..c2aa9c7 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class AddPromptToGroupRequest( + val prompt: String, + val type: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddedConversation.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddedConversation.kt new file mode 100644 index 0000000..0e64251 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddedConversation.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.model.request + +import com.librechat.android.core.model.EModelEndpoint +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AddedConversation( + val conversationId: String? = null, + val parentMessageId: String? = null, + val endpoint: EModelEndpoint? = null, + val endpointType: EModelEndpoint? = null, + @SerialName("agent_id") val agentId: String? = null, + val model: String? = null, + val modelLabel: String? = null, + val spec: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt new file mode 100644 index 0000000..763efe6 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt @@ -0,0 +1,27 @@ +package com.librechat.android.core.model.request + +import com.librechat.android.core.model.ActionMetadata +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class CreateActionRequest( + @SerialName("action_id") val actionId: String? = null, + val metadata: ActionMetadata, + val functions: List, +) + +@Serializable +data class FunctionTool( + val type: String = "function", + val function: FunctionDefinition, +) + +@Serializable +data class FunctionDefinition( + val name: String, + val description: String, + val parameters: JsonObject, + val strict: Boolean? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt new file mode 100644 index 0000000..77aa335 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt @@ -0,0 +1,14 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class ArchiveConversationRequest( + val arg: ArchiveConversationArg, +) + +@Serializable +data class ArchiveConversationArg( + val conversationId: String, + val isArchived: Boolean, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt new file mode 100644 index 0000000..7f56d0d --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class BranchMessageRequest( + val conversationId: String, + val messageId: String, + val agentId: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt new file mode 100644 index 0000000..70b58cf --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatAbortRequest( + val abortKey: String, + val endpoint: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt new file mode 100644 index 0000000..55a378f --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt @@ -0,0 +1,49 @@ +package com.librechat.android.core.model.request + +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.model.FileReference +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** Sentinel parentMessageId for root messages (no parent). */ +const val NO_PARENT = "00000000-0000-0000-0000-000000000000" + +@Serializable +data class ChatRequest( + val text: String, + val conversationId: String? = null, + val parentMessageId: String, + val endpoint: EModelEndpoint, + val endpointType: EModelEndpoint? = null, + val model: String? = null, + @SerialName("agent_id") val agentId: String? = null, + val isContinued: Boolean = false, + val isEdited: Boolean = false, + val isRegenerate: Boolean = false, + val overrideParentMessageId: String? = null, + val responseMessageId: String? = null, + val temperature: Double? = null, + @SerialName("top_p") val topP: Double? = null, + val maxOutputTokens: Int? = null, + val maxContextTokens: Int? = null, + val system: String? = null, + @SerialName("reasoning_effort") val reasoningEffort: String? = null, + val stop: List? = null, + val tools: List? = null, + val iconURL: String? = null, + val greeting: String? = null, + val spec: String? = null, + val modelLabel: String? = null, + val maxTokens: Int? = null, + val promptPrefix: String? = null, + val chatGptLabel: String? = null, + val resendFiles: Boolean? = null, + val imageDetail: String? = null, + val key: String? = null, + val extra: JsonObject? = null, + @SerialName("web_search") val webSearch: Boolean? = null, + val files: List? = null, + val addedConvo: AddedConversation? = null, + val ephemeralAgent: EphemeralAgent? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt new file mode 100644 index 0000000..e962230 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ConvoDeleteBody( + val arg: ConvoDeleteArg, +) + +@Serializable +data class ConvoDeleteArg( + val conversationId: String, + val source: String? = null, + @SerialName("thread_id") val threadId: String? = null, + val endpoint: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt new file mode 100644 index 0000000..a9c096e --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt @@ -0,0 +1,14 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class ConvoUpdateBody( + val arg: ConvoUpdateArg, +) + +@Serializable +data class ConvoUpdateArg( + val conversationId: String, + val title: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt new file mode 100644 index 0000000..5a4f898 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt @@ -0,0 +1,27 @@ +package com.librechat.android.core.model.request + +import com.librechat.android.core.model.SupportContact +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class CreateAgentRequest( + val name: String, + val description: String? = null, + val instructions: String? = null, + val model: String? = null, + val provider: String? = null, + @SerialName("model_parameters") val modelParameters: JsonObject? = null, + val artifacts: String? = null, + @SerialName("recursion_limit") val recursionLimit: Int? = null, + @SerialName("hide_sequential_outputs") val hideSequentialOutputs: Boolean? = null, + @SerialName("end_after_tools") val endAfterTools: Boolean? = null, + val category: String? = null, + val tools: List? = null, + @SerialName("conversation_starters") val conversationStarters: List? = null, + val isPublic: Boolean? = null, + val isCollaborative: Boolean? = null, + @SerialName("projectIds") val projectIds: List? = null, + @SerialName("support_contact") val supportContact: SupportContact? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt new file mode 100644 index 0000000..3cc734f --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateApiKeyRequest( + val name: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt new file mode 100644 index 0000000..a789a66 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateMemoryRequest( + val key: String, + val value: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt new file mode 100644 index 0000000..69aa4ba --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt @@ -0,0 +1,20 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class CreatePromptRequest( + val prompt: CreatePromptData, + val group: CreatePromptGroupData, +) + +@Serializable +data class CreatePromptData( + val prompt: String, + val type: String, +) + +@Serializable +data class CreatePromptGroupData( + val name: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt new file mode 100644 index 0000000..0eb88bd --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateShareRequest( + val targetMessageId: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt new file mode 100644 index 0000000..0590470 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt @@ -0,0 +1,16 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateTagRequest( + val tag: String, + val description: String? = null, + val position: Int? = null, +) + +@Serializable +data class UpdateTagRequest( + val description: String? = null, + val position: Int? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt new file mode 100644 index 0000000..d232ec9 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DeleteFilesRequest( + val files: List, + @SerialName("agent_id") val agentId: String? = null, + @SerialName("tool_resource") val toolResource: String? = null, + @SerialName("assistant_id") val assistantId: String? = null, +) + +@Serializable +data class DeleteFileEntry( + @SerialName("file_id") val fileId: String, + val filepath: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt new file mode 100644 index 0000000..cfb231c --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class DuplicateConversationRequest( + val conversationId: String, + val title: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt new file mode 100644 index 0000000..8863511 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EphemeralAgent( + val mcp: List? = null, + @SerialName("web_search") val webSearch: Boolean? = null, + @SerialName("file_search") val fileSearch: Boolean? = null, + @SerialName("execute_code") val executeCode: Boolean? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt new file mode 100644 index 0000000..d34adba --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class FeedbackRequest( + val feedback: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt new file mode 100644 index 0000000..87dd6ed --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt @@ -0,0 +1,24 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class ForkConversationRequest( + val conversationId: String, + val messageId: String, + val option: String? = null, + val splitAtTarget: Boolean? = null, + val latestMessageId: String? = null, +) + +/** + * Maps to the backend ForkOptions enum values. + * - DIRECT_PATH: Only the direct path of messages to the target + * - INCLUDE_BRANCHES: Direct path plus sibling messages at each level + * - TARGET_LEVEL: All messages and branches up to the target level (default) + */ +object ForkOption { + const val DIRECT_PATH = "directPath" + const val INCLUDE_BRANCHES = "includeBranches" + const val TARGET_LEVEL = "targetLevel" +} diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/LoginRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/LoginRequest.kt new file mode 100644 index 0000000..69b43d0 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/LoginRequest.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val email: String, + val password: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt new file mode 100644 index 0000000..43c90d3 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PasswordResetRequest( + val email: String, +) + +@Serializable +data class ResetPasswordRequest( + val userId: String, + val token: String, + val password: String, + @SerialName("confirm_password") val confirmPassword: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt new file mode 100644 index 0000000..274df8d --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class PresetDeleteRequest( + val presetId: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt new file mode 100644 index 0000000..cbaa307 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt @@ -0,0 +1,13 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterRequest( + val name: String, + val email: String, + val username: String, + val password: String, + @SerialName("confirm_password") val confirmPassword: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt new file mode 100644 index 0000000..6971940 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class RevertAgentRequest( + val version: Int, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt new file mode 100644 index 0000000..3e0e49b --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class SaveMessageRequest( + val messageId: String, + val parentMessageId: String? = null, + val responseMessageId: String? = null, + val overrideParentMessageId: String? = null, + val sender: String? = null, + val text: String = "", + val isCreatedByUser: Boolean = false, + val model: String? = null, + val endpoint: String? = null, + val error: Boolean = false, + val unfinished: Boolean = false, + @SerialName("finish_reason") val finishReason: String? = null, + val metadata: JsonObject? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt new file mode 100644 index 0000000..c7362a6 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class TagUpdateRequest( + val tags: List, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt new file mode 100644 index 0000000..e486e82 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class ToolCallRequest( + val input: JsonElement? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt new file mode 100644 index 0000000..2ae4f97 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class TwoFactorDisableRequest( + val token: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt new file mode 100644 index 0000000..9c1cf31 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt @@ -0,0 +1,25 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +/** + * Request body for POST /api/auth/2fa/verify. + * Backend reads { token, backupCode } where token is the TOTP code. + */ +@Serializable +data class TwoFactorVerifyRequest( + val token: String, + val backupCode: String? = null, +) + +/** + * Request body for POST /api/auth/2fa/verify-temp. + * Backend reads { tempToken, token, backupCode } where tempToken is the + * temporary auth token from login and token is the TOTP code. + */ +@Serializable +data class TwoFactorVerifyTempRequest( + val tempToken: String, + val token: String, + val backupCode: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt new file mode 100644 index 0000000..dcf5821 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt @@ -0,0 +1,27 @@ +package com.librechat.android.core.model.request + +import com.librechat.android.core.model.SupportContact +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class UpdateAgentRequest( + val name: String? = null, + val description: String? = null, + val instructions: String? = null, + val model: String? = null, + val provider: String? = null, + @SerialName("model_parameters") val modelParameters: JsonObject? = null, + val artifacts: String? = null, + @SerialName("recursion_limit") val recursionLimit: Int? = null, + @SerialName("hide_sequential_outputs") val hideSequentialOutputs: Boolean? = null, + @SerialName("end_after_tools") val endAfterTools: Boolean? = null, + val category: String? = null, + val tools: List? = null, + @SerialName("conversation_starters") val conversationStarters: List? = null, + val isPublic: Boolean? = null, + val isCollaborative: Boolean? = null, + @SerialName("projectIds") val projectIds: List? = null, + @SerialName("support_contact") val supportContact: SupportContact? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt new file mode 100644 index 0000000..7e40812 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateArtifactRequest( + val content: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt new file mode 100644 index 0000000..7b606ba --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateKeyRequest( + val name: String, + val value: String, + val expiresAt: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt new file mode 100644 index 0000000..d237a43 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateMemoryPreferencesRequest( + val enabled: Boolean, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt new file mode 100644 index 0000000..18fd6f6 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateMemoryRequest( + val value: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt new file mode 100644 index 0000000..0f95a74 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateMessageRequest( + val text: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt new file mode 100644 index 0000000..c1ff4ee --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdatePromptGroupRequest( + val name: String? = null, + val oneliner: String? = null, + val command: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt new file mode 100644 index 0000000..aaf2faf --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdatePromptTagRequest( + val productionPromptId: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt new file mode 100644 index 0000000..d29ee00 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt @@ -0,0 +1,14 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class VerifyEmailRequest( + val token: String, + val email: String? = null, +) + +@Serializable +data class ResendVerificationRequest( + val email: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt new file mode 100644 index 0000000..5cfa8b1 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt @@ -0,0 +1,15 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ActiveJob( + val conversationId: String? = null, + val endpoint: String? = null, + val model: String? = null, +) + +@Serializable +data class ActiveJobsResponse( + val jobs: List = emptyList(), +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt new file mode 100644 index 0000000..84f2fc9 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt @@ -0,0 +1,15 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.Agent +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AgentListResponse( + @SerialName("object") val objectType: String? = null, + val data: List = emptyList(), + @SerialName("first_id") val firstId: String? = null, + @SerialName("last_id") val lastId: String? = null, + @SerialName("has_more") val hasMore: Boolean = false, + val after: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/CategoriesResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/CategoriesResponse.kt new file mode 100644 index 0000000..87816f4 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/CategoriesResponse.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class Category( + val label: String? = null, + val value: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt new file mode 100644 index 0000000..e8587ee --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatAbortResponse( + val success: Boolean = true, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt new file mode 100644 index 0000000..d6ffe5f --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatStartResponse( + val conversationId: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt new file mode 100644 index 0000000..29d0dbb --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatStatusResponse( + val active: Boolean = false, + val conversationId: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt new file mode 100644 index 0000000..bf865f4 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.Conversation +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationListResponse( + val conversations: List = emptyList(), + val nextCursor: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt new file mode 100644 index 0000000..369e5d6 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class FileUploadConfig( + val fileLimit: Int? = null, + val fileSizeLimit: Long? = null, + val totalSizeLimit: Long? = null, + val supportedMimeTypes: List = emptyList(), + val disabled: Boolean = false, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt new file mode 100644 index 0000000..71866f9 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt @@ -0,0 +1,11 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.Message +import kotlinx.serialization.Serializable + +@Serializable +data class ForkConversationResponse( + val conversation: Conversation, + val messages: List = emptyList(), +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt new file mode 100644 index 0000000..8046495 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class GenerateTitleResponse( + val title: String, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/LoginResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/LoginResponse.kt new file mode 100644 index 0000000..2184100 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/LoginResponse.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.User +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponse( + val token: String? = null, + val user: User? = null, + val twoFactorRequired: Boolean = false, + val tempToken: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt new file mode 100644 index 0000000..1b9940c --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.Message +import kotlinx.serialization.Serializable + +@Serializable +data class MessageListResponse( + val messages: List = emptyList(), +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt new file mode 100644 index 0000000..eef7142 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt @@ -0,0 +1,16 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.PromptGroup +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive + +@Serializable +data class PromptGroupListResponse( + val promptGroups: List = emptyList(), + val pageNumber: JsonPrimitive? = null, + val pageSize: JsonPrimitive? = null, + val pages: JsonPrimitive? = null, + @SerialName("has_more") val hasMore: Boolean = false, + val after: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt new file mode 100644 index 0000000..e9ae6c6 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.Prompt +import kotlinx.serialization.Serializable + +@Serializable +data class PromptListResponse( + val prompts: List = emptyList(), +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt new file mode 100644 index 0000000..ccb4313 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.User +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshResponse( + val token: String, + val user: User? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt new file mode 100644 index 0000000..adf762a --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.User +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterResponse( + val user: User, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/SearchResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/SearchResponse.kt new file mode 100644 index 0000000..77cea53 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/SearchResponse.kt @@ -0,0 +1,14 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.Message +import kotlinx.serialization.Serializable + +@Serializable +data class SearchResponse( + val conversations: List = emptyList(), + val messages: List = emptyList(), + val pageNumber: Int? = null, + val pageSize: Int? = null, + val pages: Int? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt new file mode 100644 index 0000000..a131ef5 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ShareLinkCheckResponse( + val success: Boolean, + val shareId: String? = null, + val conversationId: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt new file mode 100644 index 0000000..9114b44 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.model.response + +import com.librechat.android.core.model.SharedLink +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SharedLinksResponse( + @SerialName("links") val links: List = emptyList(), + val nextCursor: String? = null, + val hasNextPage: Boolean? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/TermsResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/TermsResponse.kt new file mode 100644 index 0000000..b949e56 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/TermsResponse.kt @@ -0,0 +1,9 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class TermsResponse( + val termsOfService: String? = null, + val privacyPolicy: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt new file mode 100644 index 0000000..1f5ca41 --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TwoFactorSetupResponse( + @SerialName("otpauth_url") val otpauthUrl: String, + @SerialName("backup_codes") val backupCodes: List, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt new file mode 100644 index 0000000..a3a14fc --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt @@ -0,0 +1,93 @@ +package com.librechat.android.core.model.speech + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +data class SpeechToTextResponse( + val text: String, +) + +@Serializable +data class TextToSpeechRequest( + @SerialName("input") val text: String, + val voice: String? = null, + val model: String? = null, +) + +@Immutable +@Serializable +data class TtsVoice( + val id: String, + val name: String, + val provider: String? = null, + @SerialName("preview_url") val previewUrl: String? = null, +) + +/** + * Handles both string arrays `["alloy","echo"]` and object arrays + * `[{"id":"alloy","name":"Alloy"}]` from the server. + */ +internal object TtsVoiceListSerializer : KSerializer> { + private val delegateSerializer = ListSerializer(TtsVoice.serializer()) + + override val descriptor: SerialDescriptor = delegateSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) { + delegateSerializer.serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder + ?: return delegateSerializer.deserialize(decoder) + + val element = jsonDecoder.decodeJsonElement() + if (element is JsonArray && element.firstOrNull() is JsonPrimitive) { + return element.map { str -> + val name = str.jsonPrimitive.content + TtsVoice(id = name, name = name) + } + } + return (element as JsonArray).map { obj -> + jsonDecoder.json.decodeFromJsonElement(TtsVoice.serializer(), obj) + } + } +} + +/** + * Nullable wrapper around [TtsVoiceListSerializer]. + */ +internal object NullableTtsVoiceListSerializer : KSerializer?> { + private val delegate = TtsVoiceListSerializer.nullable + + override val descriptor: SerialDescriptor = delegate.descriptor + + override fun serialize(encoder: Encoder, value: List?) { + delegate.serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): List? { + return delegate.deserialize(decoder) + } +} + +@Serializable +data class SpeechConfig( + @SerialName("sttExternal") val sttExternal: Boolean = false, + @SerialName("ttsExternal") val ttsExternal: Boolean = false, + @SerialName("sttProvider") val sttProvider: String? = null, + @SerialName("ttsProvider") val ttsProvider: String? = null, + @Serializable(with = NullableTtsVoiceListSerializer::class) + val voices: List? = null, +) diff --git a/core/network/CLAUDE.md b/core/network/CLAUDE.md new file mode 100644 index 0000000..d83ed2f --- /dev/null +++ b/core/network/CLAUDE.md @@ -0,0 +1,46 @@ +# core:network + +Ktor HttpClient, API service classes, SSE streaming client, auth interceptor. All HTTP communication lives here. + +## What This Module Provides + +- **Ktor HttpClient factory** (`di/NetworkModule.kt`): Provides singleton `HttpClient(OkHttp)` with ContentNegotiation, Logging, HttpTimeout, HttpRequestRetry, and AuthInterceptorPlugin. +- **AuthInterceptorPlugin** (`client/AuthInterceptor.kt`): Custom Ktor plugin that injects `Authorization: Bearer` on outgoing requests and retries on 401 after refreshing tokens. Skips auth endpoints (`auth/login`, `auth/register`, `auth/refresh`, etc.). +- **TokenManager interface** (`client/TokenManager.kt`): `getAccessToken()`, `setTokens()`, `refreshAccessToken()` (Mutex-guarded), `clearTokens()`, `sessionExpiredFlow`. Implemented in `:core:data`. +- **ServerUrlProvider interface** (`client/ServerUrlProvider.kt`): Resolves the user-configured base URL. Implemented in `:core:data`. +- **API services** (`api/`): One class per domain -- `AuthApi`, `ConversationsApi`, `MessagesApi`, `ChatStreamApi`, `FilesApi`, `AgentsApi`, `PresetsApi`, `PromptsApi`, `TagsApi`, `ShareApi`, `ConfigApi`, `EndpointsApi`, `BalanceApi`, `UserApi`, `SearchApi`. Each is `@Inject constructor(client: HttpClient)`. +- **SSE client** (`sse/`): `SseClient`, `SseEvent`, `SseEventParser`, `SseConnectionManager`. +- **DTO mappers** (`mapper/`): Convert network DTOs to domain models from `:core:model`. + +## SSE Streaming Architecture + +**Do NOT use Ktor's SSE plugin.** Use the custom line parser over raw `ByteReadChannel`. + +Two-phase protocol: +1. `POST /api/agents/chat` with full message payload -> returns `{ streamId }` (streamId === conversationId) +2. `GET /api/agents/chat/stream/:streamId` opens the SSE event stream + +Legacy single-phase path (OpenAI Assistants): POST body is the SSE stream itself. Both paths need the custom parser. + +`SseConnectionManager` handles lifecycle: start, reconnect with exponential backoff (1s/2s/4s/8s, max 5 retries), abort via `POST /api/agents/chat/abort`, and exposes `StateFlow`. + +On reconnection, append `?resume=true` to get a `sync` event with `runSteps[]` + `aggregatedContent[]`. + +## Key Configuration + +- `Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = false; explicitNulls = false; coerceInputValues = true }` +- `socketTimeoutMillis = 120_000` (SSE streams override to `Long.MAX_VALUE`) +- Browser-like `User-Agent` header required -- backend `ua-parser-js` middleware may reject non-browser UAs. + +## Error Handling + +- `HttpResponseValidator` in the client converts non-2xx to exceptions. +- API services throw on error. Repositories in `:core:data` catch via `safeApiCall` from `:core:common`. +- Respect `429 Too Many Requests` and parse `Retry-After` header on auth endpoints. + +## Rules + +- Dependencies: `:core:model`, `:core:common`, Ktor bundles, kotlinx-serialization, Timber, Hilt. +- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.kotlin.serialization`. +- API services must not contain business logic -- they are thin HTTP wrappers. +- All `arg`-wrapped endpoints must match the backend pattern: `setBody(mapOf("arg" to mapOf(...)))`. diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 0000000..b579ca3 --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("librechat.android.library") + id("librechat.android.hilt") + id("librechat.kotlin.serialization") +} + +android { + namespace = "com.librechat.android.core.network" +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(libs.bundles.ktor) + implementation(libs.kotlinx.serialization.json) + implementation(libs.timber) +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/AgentsApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/AgentsApi.kt new file mode 100644 index 0000000..507b5c1 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/AgentsApi.kt @@ -0,0 +1,184 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Agent +import com.librechat.android.core.model.AgentAction +import com.librechat.android.core.model.AgentCategory +import com.librechat.android.core.model.AgentExpanded +import com.librechat.android.core.model.AgentTool +import com.librechat.android.core.model.ToolAuthStatus +import com.librechat.android.core.model.ToolCallRecord +import com.librechat.android.core.model.ToolCallResult +import com.librechat.android.core.model.request.CreateActionRequest +import com.librechat.android.core.model.request.CreateAgentRequest +import com.librechat.android.core.model.request.RevertAgentRequest +import com.librechat.android.core.model.request.ToolCallRequest +import com.librechat.android.core.model.request.UpdateAgentRequest +import com.librechat.android.core.model.response.AgentListResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import kotlinx.serialization.json.JsonArray +import io.ktor.client.request.delete +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.path +import timber.log.Timber +import javax.inject.Inject + +class AgentsApi @Inject constructor( + private val client: HttpClient, +) { + + // --- Agent CRUD --- + + suspend fun getAgents(category: String? = null): AgentListResponse = + client.get { + url { path("api/agents") } + if (category != null) parameter("category", category) + }.body() + + suspend fun getAgentsPaginated( + pageNumber: Int, + pageSize: Int, + search: String? = null, + category: String? = null, + ): AgentListResponse = + client.get { + url { path("api/agents") } + parameter("pageNumber", pageNumber) + parameter("pageSize", pageSize) + if (search != null) parameter("search", search) + if (category != null) parameter("category", category) + }.body() + + suspend fun getAgent(agentId: String): Agent { + Timber.d("AgentsApi.getAgent(%s) - using VIEW endpoint", agentId) + return client.get { + url { path("api/agents/$agentId") } + }.body() + } + + /** + * Fetches the full agent data for editing via the /expanded endpoint. + * + * The standard GET /api/agents/:id endpoint returns only basic view fields + * (id, name, description, avatar, provider, model, projectIds, isCollaborative, + * isPublic, version, createdAt, updatedAt). It does NOT include instructions, + * tools, category, conversation_starters, model_parameters, or other + * configuration fields needed for editing. + * + * The /expanded endpoint (GET /api/agents/:id/expanded) requires EDIT permission + * and returns the complete agent document with all fields. + */ + suspend fun getAgentForEditing(agentId: String): Agent { + Timber.d("AgentsApi.getAgentForEditing(%s) - using EXPANDED endpoint", agentId) + return client.get { + url { path("api/agents/$agentId/expanded") } + }.body() + } + + suspend fun createAgent(request: CreateAgentRequest): Agent = + client.post { + url { path("api/agents") } + setBody(request) + }.body() + + suspend fun updateAgent(agentId: String, request: UpdateAgentRequest): Agent = + client.patch { + url { path("api/agents/$agentId") } + setBody(request) + }.body() + + suspend fun deleteAgent(agentId: String) { + client.delete { + url { path("api/agents/$agentId") } + } + } + + suspend fun duplicateAgent(agentId: String): Agent = + client.post { + url { path("api/agents/$agentId/duplicate") } + }.body() + + suspend fun revertAgent(agentId: String, request: RevertAgentRequest): Agent = + client.post { + url { path("api/agents/$agentId/revert") } + setBody(request) + }.body() + + suspend fun getAgentExpanded(agentId: String): AgentExpanded = + client.get { + url { path("api/agents/$agentId/expanded") } + }.body() + + suspend fun getAgentCategories(): List = + client.get { + url { path("api/agents/categories") } + }.body() + + // --- Agent Actions --- + + suspend fun getAgentActions(): List = + client.get { + url { path("api/agents/actions") } + }.body() + + suspend fun addOrUpdateAction(agentId: String, request: CreateActionRequest): JsonArray = + client.post { + url { path("api/agents/actions/$agentId") } + setBody(request) + }.body() + + suspend fun deleteAction(agentId: String, actionId: String) { + client.delete { + url { path("api/agents/actions/$agentId/$actionId") } + } + } + + // --- Agent Avatar --- + + suspend fun uploadAgentAvatar( + agentId: String, + imageBytes: ByteArray, + mimeType: String, + ): Agent = + client.submitFormWithBinaryData( + formData = formData { + append("file", imageBytes, Headers.build { + append(HttpHeaders.ContentDisposition, "filename=\"avatar.${mimeType.substringAfter("/")}\"") + append(HttpHeaders.ContentType, mimeType) + }) + }, + ) { + url { path("api/files/images/agents/$agentId/avatar") } + }.body() + + // --- Agent Tools --- + + suspend fun getAvailableTools(): List = + client.get { + url { path("api/agents/tools") } + }.body() + + suspend fun getToolCalls(): List = + client.get { + url { path("api/agents/tools/calls") } + }.body() + + suspend fun getToolAuthStatus(toolId: String): ToolAuthStatus = + client.get { + url { path("api/agents/tools/$toolId/auth") } + }.body() + + suspend fun callTool(toolId: String, request: ToolCallRequest): ToolCallResult = + client.post { + url { path("api/agents/tools/$toolId/call") } + setBody(request) + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt new file mode 100644 index 0000000..2019397 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt @@ -0,0 +1,68 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.ApiKey +import com.librechat.android.core.model.ApiKeysResponse +import com.librechat.android.core.model.request.CreateApiKeyRequest +import io.ktor.client.HttpClient +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.path +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class ApiKeysApi @Inject constructor( + private val client: HttpClient, +) { + private val lenientJson = Json { + ignoreUnknownKeys = true + isLenient = true + } + + suspend fun createApiKey(request: CreateApiKeyRequest): ApiKey { + val response = client.post { + url { path("api/api-keys") } + setBody(request) + } + val text = response.bodyAsText() + if (text.trimStart().startsWith("<")) { + throw IllegalStateException("API keys feature is not available on this server") + } + return lenientJson.decodeFromString(text) + } + + suspend fun listApiKeys(): List { + val response = client.get { + url { path("api/api-keys") } + } + val text = response.bodyAsText() + if (text.trimStart().startsWith("<")) { + return emptyList() + } + val apiKeysResponse = lenientJson.decodeFromString(text) + return apiKeysResponse.keys + } + + suspend fun getApiKey(id: String): ApiKey { + val response = client.get { + url { path("api/api-keys/$id") } + } + val text = response.bodyAsText() + if (text.trimStart().startsWith("<")) { + throw IllegalStateException("API keys feature is not available on this server") + } + return lenientJson.decodeFromString(text) + } + + suspend fun deleteApiKey(id: String) { + val response = client.delete { + url { path("api/api-keys/$id") } + } + val text = response.bodyAsText() + if (text.trimStart().startsWith("<")) { + throw IllegalStateException("API keys feature is not available on this server") + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt new file mode 100644 index 0000000..98d92fc --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt @@ -0,0 +1,170 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.request.LoginRequest +import com.librechat.android.core.model.request.PasswordResetRequest +import com.librechat.android.core.model.request.RegisterRequest +import com.librechat.android.core.model.request.ResetPasswordRequest +import com.librechat.android.core.model.request.TwoFactorDisableRequest +import com.librechat.android.core.model.request.TwoFactorVerifyRequest +import com.librechat.android.core.model.request.TwoFactorVerifyTempRequest +import com.librechat.android.core.model.response.LoginResponse +import com.librechat.android.core.model.response.RefreshResponse +import com.librechat.android.core.model.response.RegisterResponse +import com.librechat.android.core.model.response.TwoFactorSetupResponse +import com.librechat.android.core.network.client.CookieHelper +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.path +import kotlinx.serialization.Serializable +import javax.inject.Inject + +data class LoginResult( + val response: LoginResponse, + val refreshToken: String?, +) + +data class RefreshResult( + val response: RefreshResponse, + /** New refresh token from Set-Cookie header, if the backend rotated it. */ + val newRefreshToken: String?, +) + +class AuthApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun login(email: String, password: String): LoginResult { + val httpResponse = client.post { + url { path("api/auth/login") } + setBody(LoginRequest(email = email, password = password)) + } + val body = httpResponse.body() + val refreshToken = CookieHelper.extractRefreshToken(httpResponse.headers) + return LoginResult(body, refreshToken) + } + + suspend fun register( + name: String, + email: String, + username: String, + password: String, + confirmPassword: String, + ): RegisterResponse = + client.post { + url { path("api/auth/register") } + setBody( + RegisterRequest( + name = name, + email = email, + username = username, + password = password, + confirmPassword = confirmPassword, + ), + ) + }.body() + + suspend fun logout() { + client.post { + url { path("api/auth/logout") } + } + } + + suspend fun refresh(refreshToken: String): RefreshResult { + val httpResponse = client.post { + url { path("api/auth/refresh") } + // Send refresh token via both Cookie header and request body. + // The backend reads from cookies first, falling back to body. + header("Cookie", "refreshToken=$refreshToken") + setBody(mapOf("refreshToken" to refreshToken)) + } + val body = httpResponse.body() + val newRefreshToken = CookieHelper.extractRefreshToken(httpResponse.headers) + return RefreshResult(body, newRefreshToken) + } + + suspend fun requestPasswordReset(email: String) { + client.post { + url { path("api/auth/requestPasswordReset") } + setBody(PasswordResetRequest(email = email)) + } + } + + suspend fun resetPassword(userId: String, token: String, password: String, confirmPassword: String) { + client.post { + url { path("api/auth/resetPassword") } + setBody( + ResetPasswordRequest( + userId = userId, + token = token, + password = password, + confirmPassword = confirmPassword, + ), + ) + } + } + + /** + * Verify 2FA for an already-authenticated user. + * POST /api/auth/2fa/verify with { token: totpCode } + */ + suspend fun verifyTwoFactor(totpCode: String): LoginResult { + val httpResponse = client.post { + url { path("api/auth/2fa/verify") } + setBody(TwoFactorVerifyRequest(token = totpCode)) + } + val body = httpResponse.body() + val refreshToken = CookieHelper.extractRefreshToken(httpResponse.headers) + return LoginResult(body, refreshToken) + } + + suspend fun enableTwoFactor(): TwoFactorSetupResponse = + client.get { + url { path("api/auth/2fa/enable") } + }.body() + + suspend fun confirmTwoFactor(code: String): TwoFactorSetupResponse = + client.post { + url { path("api/auth/2fa/confirm") } + setBody(TwoFactorConfirmRequest(token = code)) + }.body() + + /** + * Verify 2FA during login using a temporary token. + * POST /api/auth/2fa/verify-temp with { tempToken, token: totpCode, backupCode? } + */ + suspend fun verifyTempToken(tempToken: String, totpCode: String, backupCode: String? = null): LoginResult { + val httpResponse = client.post { + url { path("api/auth/2fa/verify-temp") } + setBody( + TwoFactorVerifyTempRequest( + tempToken = tempToken, + token = totpCode, + backupCode = backupCode, + ), + ) + } + val body = httpResponse.body() + val refreshToken = CookieHelper.extractRefreshToken(httpResponse.headers) + return LoginResult(body, refreshToken) + } + + suspend fun regenerateBackupCodes(): TwoFactorSetupResponse = + client.post { + url { path("api/auth/2fa/backup/regenerate") } + }.body() + + suspend fun disableTwoFactor(code: String) { + client.post { + url { path("api/auth/2fa/disable") } + setBody(TwoFactorDisableRequest(token = code)) + } + } +} + +@Serializable +data class TwoFactorConfirmRequest( + val token: String, +) diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/BalanceApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/BalanceApi.kt new file mode 100644 index 0000000..cc620f8 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/BalanceApi.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Balance +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.path +import javax.inject.Inject + +class BalanceApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getBalance(): Balance = + client.get { + url { path("api/balance") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/BannerApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/BannerApi.kt new file mode 100644 index 0000000..b18cc28 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/BannerApi.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Banner +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.path +import javax.inject.Inject + +class BannerApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getBanners(): List = + client.get { + url { path("api/banner") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ChatApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ChatApi.kt new file mode 100644 index 0000000..6d078ed --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ChatApi.kt @@ -0,0 +1,41 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.request.ChatAbortRequest +import com.librechat.android.core.model.request.ChatRequest +import com.librechat.android.core.model.response.ActiveJobsResponse +import com.librechat.android.core.model.response.ChatAbortResponse +import com.librechat.android.core.model.response.ChatStartResponse +import com.librechat.android.core.model.response.ChatStatusResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.path +import javax.inject.Inject + +class ChatApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun startChat(endpoint: String, request: ChatRequest): ChatStartResponse = + client.post { + url { path("api/agents/chat/$endpoint") } + setBody(request) + }.body() + + suspend fun abortChat(streamId: String): ChatAbortResponse = + client.post { + url { path("api/agents/chat/abort") } + setBody(ChatAbortRequest(abortKey = streamId, endpoint = "agents")) + }.body() + + suspend fun getActiveJobs(): ActiveJobsResponse = + client.get { + url { path("api/agents/chat/active") } + }.body() + + suspend fun getChatStatus(conversationId: String): ChatStatusResponse = + client.get { + url { path("api/agents/chat/status/$conversationId") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConfigApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConfigApi.kt new file mode 100644 index 0000000..710987b --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConfigApi.kt @@ -0,0 +1,49 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.EndpointConfig +import com.librechat.android.core.model.StartupConfig +import com.librechat.android.core.model.response.Category +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.path +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class ConfigApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getStartupConfig(): StartupConfig = + client.get { + url { path("api/config") } + }.body() + + private val lenientJson = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + /** + * Fetches configured endpoint configs. The server may return JSON with + * Content-Type: text/html, so we read the raw body and deserialize manually. + */ + suspend fun getEndpoints(): Map { + val response = client.get { + url { path("api/endpoints") } + } + val text = response.bodyAsText() + return lenientJson.decodeFromString(text) + } + + suspend fun getModels(): Map> = + client.get { + url { path("api/models") } + }.body() + + suspend fun getCategories(): List = + client.get { + url { path("api/categories") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt new file mode 100644 index 0000000..4143415 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt @@ -0,0 +1,134 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.request.ArchiveConversationRequest +import com.librechat.android.core.model.request.ArchiveConversationArg +import com.librechat.android.core.model.request.ConvoDeleteBody +import com.librechat.android.core.model.request.ConvoDeleteArg +import com.librechat.android.core.model.request.ConvoUpdateArg +import com.librechat.android.core.model.request.ConvoUpdateBody +import com.librechat.android.core.model.request.DuplicateConversationRequest +import com.librechat.android.core.model.request.ForkConversationRequest +import com.librechat.android.core.model.response.ConversationListResponse +import com.librechat.android.core.model.response.ForkConversationResponse +import com.librechat.android.core.model.response.GenerateTitleResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.path +import javax.inject.Inject + +class ConversationsApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getConversations( + cursor: String? = null, + limit: Int = 25, + isArchived: Boolean = false, + tags: List? = null, + search: String? = null, + sortBy: String? = null, + sortDirection: String? = null, + ): ConversationListResponse = + client.get { + url { path("api/convos") } + parameter("cursor", cursor) + parameter("limit", limit) + parameter("isArchived", isArchived) + tags?.forEach { tag -> parameter("tags[]", tag) } + search?.let { parameter("search", it) } + sortBy?.let { parameter("sortBy", it) } + sortDirection?.let { parameter("sortDirection", it) } + }.body() + + suspend fun getConversation(conversationId: String): Conversation = + client.get { + url { path("api/convos/$conversationId") } + }.body() + + suspend fun updateTitle(conversationId: String, title: String): Conversation = + client.post { + url { path("api/convos/update") } + setBody( + ConvoUpdateBody( + arg = ConvoUpdateArg( + conversationId = conversationId, + title = title, + ), + ), + ) + }.body() + + suspend fun archive(conversationId: String, isArchived: Boolean): Conversation = + client.post { + url { path("api/convos/archive") } + setBody( + ArchiveConversationRequest( + arg = ArchiveConversationArg( + conversationId = conversationId, + isArchived = isArchived, + ), + ), + ) + }.body() + + suspend fun deleteConversation(conversationId: String) { + client.delete { + url { path("api/convos") } + setBody( + ConvoDeleteBody( + arg = ConvoDeleteArg( + conversationId = conversationId, + ), + ), + ) + } + } + + suspend fun deleteAllConversations() { + client.delete { + url { path("api/convos/all") } + } + } + + suspend fun importConversations( + fileBytes: ByteArray, + filename: String, + contentType: String = "application/json", + ): Conversation = + client.submitFormWithBinaryData( + formData = formData { + append("file", fileBytes, Headers.build { + append(HttpHeaders.ContentDisposition, "filename=\"$filename\"") + append(HttpHeaders.ContentType, contentType) + }) + }, + ) { + url { path("api/convos/import") } + }.body() + + suspend fun generateTitle(conversationId: String): GenerateTitleResponse = + client.get { + url { path("api/convos/gen_title/$conversationId") } + }.body() + + suspend fun forkConversation(request: ForkConversationRequest): ForkConversationResponse = + client.post { + url { path("api/convos/fork") } + setBody(request) + }.body() + + suspend fun duplicateConversation(conversationId: String, title: String?): Conversation = + client.post { + url { path("api/convos/duplicate") } + setBody(DuplicateConversationRequest(conversationId = conversationId, title = title)) + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesApi.kt new file mode 100644 index 0000000..d89cc87 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesApi.kt @@ -0,0 +1,109 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.FileObject +import com.librechat.android.core.model.request.DeleteFilesRequest +import com.librechat.android.core.model.response.FileUploadConfig +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.setBody +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.path +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +class FilesApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getFiles(): List = + client.get { + url { path("api/files") } + }.body() + + suspend fun getFileConfig(): FileUploadConfig = + client.get { + url { path("api/files/config") } + }.body() + + /** + * Uploads a file to the server via multipart form data. + * + * The backend requires: + * - `file` part: the binary file data with filename and content type + * - `file_id`: a UUID identifying this upload (server replaces with its own, keeps ours as temp_file_id) + * - `endpoint`: the endpoint context (e.g. "agents", "openAI") -- required by filterFile() + * + * Optional fields: + * - `model`, `agent_id`, `tool_resource`, `message_file`, `width`, `height` + */ + suspend fun uploadFile( + bytes: ByteArray, + filename: String, + type: String, + fileId: String = UUID.randomUUID().toString(), + endpoint: String? = null, + model: String? = null, + agentId: String? = null, + toolResource: String? = null, + messageFile: Boolean? = null, + width: Int? = null, + height: Int? = null, + ): FileObject { + Timber.d( + "uploadFile: filename=%s, type=%s, size=%d bytes, fileId=%s, endpoint=%s, model=%s, agentId=%s, messageFile=%s, width=%s, height=%s", + filename, type, bytes.size, fileId, endpoint, model, agentId, messageFile, width, height, + ) + + val response: FileObject = client.submitFormWithBinaryData( + formData = formData { + append("file", bytes, Headers.build { + append(HttpHeaders.ContentDisposition, "filename=\"${encodeFilename(filename)}\"") + append(HttpHeaders.ContentType, type) + }) + // file_id is required by the backend's filterFile() -- must be a valid UUID + append("file_id", fileId) + if (endpoint != null) append("endpoint", endpoint) + if (model != null) append("model", model) + if (agentId != null) append("agent_id", agentId) + if (toolResource != null) append("tool_resource", toolResource) + if (messageFile != null) append("message_file", messageFile.toString()) + if (width != null) append("width", width.toString()) + if (height != null) append("height", height.toString()) + } + ) { + url { path("api/files") } + }.body() + + Timber.d( + "uploadFile success: fileId=%s, filepath=%s, type=%s, width=%s, height=%s", + response.fileId, response.filepath, response.type, response.width, response.height, + ) + return response + } + + suspend fun deleteFiles(request: DeleteFilesRequest) { + client.delete { + url { path("api/files") } + setBody(request) + } + } + + suspend fun downloadFile(userId: String, fileId: String): ByteArray = + client.get { + url { path("api/files/download/$userId/$fileId") } + }.body() + + companion object { + /** + * Encodes a filename for use in Content-Disposition header. + * Ensures special characters don't break the multipart form boundary. + */ + private fun encodeFilename(filename: String): String = + filename.replace("\"", "\\\"") + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt new file mode 100644 index 0000000..9bf98a3 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.FileObject +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.path +import javax.inject.Inject + +class FilesExtApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getAgentFiles(agentId: String): List = + client.get { + url { path("api/files/agent/$agentId") } + }.body() + + suspend fun downloadCode(sessionId: String, fileId: String): ByteArray = + client.get { + url { path("api/files/code/download/$sessionId/$fileId") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/KeysApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/KeysApi.kt new file mode 100644 index 0000000..e0e2eb5 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/KeysApi.kt @@ -0,0 +1,42 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.UserKey +import com.librechat.android.core.model.request.UpdateKeyRequest +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.encodeURLPathPart +import io.ktor.http.path +import javax.inject.Inject + +class KeysApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getKeyExpiry(): List = + client.get { + url { path("api/keys") } + }.body() + + suspend fun updateKey(request: UpdateKeyRequest): UserKey = + client.put { + url { path("api/keys") } + setBody(request) + }.body() + + suspend fun deleteKey(name: String) { + client.delete { + url { path("api/keys/${name.encodeURLPathPart()}") } + } + } + + suspend fun deleteAllKeys() { + client.delete { + url { path("api/keys") } + parameter("all", true) + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/McpApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/McpApi.kt new file mode 100644 index 0000000..ac19b76 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/McpApi.kt @@ -0,0 +1,224 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.mcp.McpApiKeyConfig +import com.librechat.android.core.model.mcp.McpApiKeySource +import com.librechat.android.core.model.mcp.McpAuthorizationType +import com.librechat.android.core.model.mcp.McpConnectionStatusResponse +import com.librechat.android.core.model.mcp.McpOAuthConfig +import com.librechat.android.core.model.mcp.McpOAuthStatus +import com.librechat.android.core.model.mcp.McpOAuthTokens +import com.librechat.android.core.model.mcp.McpReinitializeResponse +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerStatus +import com.librechat.android.core.model.mcp.McpServerType +import com.librechat.android.core.model.mcp.McpTool +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.path +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import javax.inject.Inject + +class McpApi @Inject constructor( + private val client: HttpClient, + private val json: Json, +) { + + /** + * GET /api/mcp/tools returns { servers: Record } + * where each MCPServer has: name, icon, authenticated, authConfig, tools[] + * tools[] items have: name, pluginKey, description + */ + suspend fun getTools(): List { + val response: JsonObject = client.get { + url { path("api/mcp/tools") } + }.body() + val servers = response["servers"]?.jsonObject ?: return emptyList() + val tools = mutableListOf() + for ((serverName, serverJson) in servers) { + val serverObj = serverJson.jsonObject + val serverTools = serverObj["tools"]?.jsonArray ?: continue + for (toolJson in serverTools) { + val toolObj = toolJson.jsonObject + tools.add( + McpTool( + name = toolObj["name"]?.jsonPrimitive?.contentOrNull ?: continue, + description = toolObj["description"]?.jsonPrimitive?.contentOrNull, + serverName = serverName, + ), + ) + } + } + return tools + } + + suspend fun reinitialize(serverName: String): McpReinitializeResponse { + val response = client.post { + url { path("api/mcp/$serverName/reinitialize") } + } + val text = response.bodyAsText() + if (text.trimStart().startsWith("<")) { + return McpReinitializeResponse(success = false, message = "Server returned unexpected response") + } + return json.decodeFromString(text) + } + + suspend fun getConnectionStatus(): Map { + val response: McpConnectionStatusResponse = client.get { + url { path("api/mcp/connection/status") } + }.body() + return response.connectionStatus + } + + /** + * GET /api/mcp/servers returns Record + * where ParsedServerConfig has: type, url, title, description, etc. + * The key in the map is the server name. + */ + suspend fun listServers(): List { + val response: JsonObject = client.get { + url { path("api/mcp/servers") } + }.body() + return response.entries.map { (serverName, configJson) -> + val config = configJson.jsonObject + McpServer( + name = serverName, + url = config["url"]?.jsonPrimitive?.contentOrNull ?: "", + type = parseServerType(config["type"]?.jsonPrimitive?.contentOrNull), + title = config["title"]?.jsonPrimitive?.contentOrNull, + description = config["description"]?.jsonPrimitive?.contentOrNull, + apiKey = config["apiKey"]?.jsonObject?.let { parseApiKeyConfig(it) }, + oauth = config["oauth"]?.jsonObject?.let { parseOAuthConfig(it) }, + ) + } + } + + suspend fun createServer( + name: String, + description: String? = null, + url: String, + type: McpServerType, + apiKey: McpApiKeyConfig? = null, + oauth: McpOAuthConfig? = null, + ): McpServer { + val configMap = buildMap { + put("url", url) + put("type", type.serialName) + put("title", name) + if (!description.isNullOrBlank()) put("description", description) + if (apiKey != null) { + put("apiKey", buildMap { + put("source", apiKey.source.serialName) + put("authorization_type", apiKey.authorizationType.serialName) + if (!apiKey.key.isNullOrBlank()) put("key", apiKey.key) + if (!apiKey.customHeader.isNullOrBlank()) put("custom_header", apiKey.customHeader) + }) + } + if (oauth != null) { + put("oauth", buildMap { + if (!oauth.authorizationUrl.isNullOrBlank()) put("authorization_url", oauth.authorizationUrl) + if (!oauth.tokenUrl.isNullOrBlank()) put("token_url", oauth.tokenUrl) + if (!oauth.clientId.isNullOrBlank()) put("client_id", oauth.clientId) + if (!oauth.clientSecret.isNullOrBlank()) put("client_secret", oauth.clientSecret) + if (!oauth.scope.isNullOrBlank()) put("scope", oauth.scope) + }) + } + } + val response: JsonObject = client.post { + url { path("api/mcp/servers") } + setBody(mapOf("config" to configMap)) + }.body() + val serverName = response["serverName"]?.jsonPrimitive?.contentOrNull ?: name + return McpServer( + name = serverName, + url = response["url"]?.jsonPrimitive?.contentOrNull ?: url, + type = type, + title = response["title"]?.jsonPrimitive?.contentOrNull, + description = description, + apiKey = apiKey, + oauth = oauth, + ) + } + + suspend fun deleteServer(serverName: String) { + client.delete { + url { path("api/mcp/servers/$serverName") } + } + } + + suspend fun initiateOAuth(serverName: String): String = + client.get { + url { path("api/mcp/$serverName/oauth/initiate") } + }.body() + + suspend fun getOAuthTokens(flowId: String): McpOAuthTokens = + client.get { + url { path("api/mcp/oauth/tokens/$flowId") } + }.body() + + suspend fun getOAuthStatus(flowId: String): McpOAuthStatus = + client.get { + url { path("api/mcp/oauth/status/$flowId") } + }.body() + + private fun parseServerType(type: String?): McpServerType = when (type) { + "sse" -> McpServerType.SSE + "streamable-http", "http" -> McpServerType.STREAMABLE_HTTP + "stdio" -> McpServerType.STDIO + "websocket" -> McpServerType.WEBSOCKET + else -> McpServerType.SSE + } + + private fun parseApiKeyConfig(obj: JsonObject): McpApiKeyConfig = McpApiKeyConfig( + source = when (obj["source"]?.jsonPrimitive?.contentOrNull) { + "admin" -> McpApiKeySource.ADMIN + else -> McpApiKeySource.USER + }, + authorizationType = when (obj["authorization_type"]?.jsonPrimitive?.contentOrNull) { + "basic" -> McpAuthorizationType.BASIC + "custom" -> McpAuthorizationType.CUSTOM + else -> McpAuthorizationType.BEARER + }, + key = obj["key"]?.jsonPrimitive?.contentOrNull, + customHeader = obj["custom_header"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseOAuthConfig(obj: JsonObject): McpOAuthConfig = McpOAuthConfig( + authorizationUrl = obj["authorization_url"]?.jsonPrimitive?.contentOrNull, + tokenUrl = obj["token_url"]?.jsonPrimitive?.contentOrNull, + clientId = obj["client_id"]?.jsonPrimitive?.contentOrNull, + clientSecret = obj["client_secret"]?.jsonPrimitive?.contentOrNull, + scope = obj["scope"]?.jsonPrimitive?.contentOrNull, + ) +} + +private val McpServerType.serialName: String + get() = when (this) { + McpServerType.SSE -> "sse" + McpServerType.STREAMABLE_HTTP -> "streamable-http" + McpServerType.HTTP -> "http" + McpServerType.STDIO -> "stdio" + McpServerType.WEBSOCKET -> "websocket" + } + +private val McpApiKeySource.serialName: String + get() = when (this) { + McpApiKeySource.ADMIN -> "admin" + McpApiKeySource.USER -> "user" + } + +private val McpAuthorizationType.serialName: String + get() = when (this) { + McpAuthorizationType.BEARER -> "bearer" + McpAuthorizationType.BASIC -> "basic" + McpAuthorizationType.CUSTOM -> "custom" + } diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt new file mode 100644 index 0000000..0ffb99d --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt @@ -0,0 +1,50 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Memory +import com.librechat.android.core.model.MemoryPreferences +import com.librechat.android.core.model.request.CreateMemoryRequest +import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest +import com.librechat.android.core.model.request.UpdateMemoryRequest +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.encodeURLPathPart +import io.ktor.http.path +import javax.inject.Inject + +class MemoriesApi @Inject constructor( + private val client: HttpClient, +) { + + suspend fun getMemories(): List = + client.get { + url { path("api/memories") } + }.body() + + suspend fun createMemory(request: CreateMemoryRequest): Memory = + client.post { + url { path("api/memories") } + setBody(request) + }.body() + + suspend fun updatePreferences(request: UpdateMemoryPreferencesRequest): MemoryPreferences = + client.patch { + url { path("api/memories/preferences") } + setBody(request) + }.body() + + suspend fun updateMemory(key: String, request: UpdateMemoryRequest): Memory = + client.patch { + url { path("api/memories/${key.encodeURLPathPart()}") } + setBody(request) + }.body() + + suspend fun deleteMemory(key: String): Unit = + client.delete { + url { path("api/memories/${key.encodeURLPathPart()}") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/MessagesApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/MessagesApi.kt new file mode 100644 index 0000000..cbc90be --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/MessagesApi.kt @@ -0,0 +1,82 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Message +import com.librechat.android.core.model.request.BranchMessageRequest +import com.librechat.android.core.model.request.FeedbackRequest +import com.librechat.android.core.model.request.SaveMessageRequest +import com.librechat.android.core.model.request.UpdateArtifactRequest +import com.librechat.android.core.model.request.UpdateMessageRequest +import com.librechat.android.core.model.response.MessageListResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.path +import javax.inject.Inject + +class MessagesApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getMessages(conversationId: String): List = + client.get { + url { path("api/messages/$conversationId") } + }.body() + + suspend fun getMessage(conversationId: String, messageId: String): Message = + client.get { + url { path("api/messages/$conversationId/$messageId") } + }.body() + + suspend fun saveMessage(conversationId: String, request: SaveMessageRequest): Message = + client.post { + url { path("api/messages/$conversationId") } + setBody(request) + }.body() + + suspend fun updateMessage(conversationId: String, messageId: String, request: UpdateMessageRequest): Message = + client.put { + url { path("api/messages/$conversationId/$messageId") } + setBody(request) + }.body() + + suspend fun deleteMessage(conversationId: String, messageId: String) { + client.delete { + url { path("api/messages/$conversationId/$messageId") } + } + } + + suspend fun branchMessage(request: BranchMessageRequest): Message = + client.post { + url { path("api/messages/branch") } + setBody(request) + }.body() + + suspend fun updateFeedback(conversationId: String, messageId: String, feedback: String?) { + client.put { + url { path("api/messages/$conversationId/$messageId/feedback") } + setBody(FeedbackRequest(feedback = feedback)) + } + } + + suspend fun getGlobalMessages( + cursor: String? = null, + limit: Int = 25, + search: String? = null, + ): MessageListResponse = + client.get { + url { path("api/messages") } + cursor?.let { parameter("cursor", it) } + parameter("limit", limit) + search?.let { parameter("search", it) } + }.body() + + suspend fun updateArtifact(messageId: String, request: UpdateArtifactRequest): Message = + client.post { + url { path("api/messages/artifact/$messageId") } + setBody(request) + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/PresetsApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/PresetsApi.kt new file mode 100644 index 0000000..eb04193 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/PresetsApi.kt @@ -0,0 +1,39 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Preset +import com.librechat.android.core.model.request.PresetDeleteRequest +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.path +import javax.inject.Inject + +class PresetsApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getPresets(): List = + client.get { + url { path("api/presets") } + }.body() + + suspend fun createPreset(preset: Preset): Preset = + client.post { + url { path("api/presets") } + setBody(preset) + }.body() + + suspend fun updatePreset(preset: Preset): Preset = + client.post { + url { path("api/presets") } + setBody(preset) + }.body() + + suspend fun deletePreset(presetId: String) { + client.post { + url { path("api/presets/delete") } + setBody(PresetDeleteRequest(presetId = presetId)) + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/PromptsApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/PromptsApi.kt new file mode 100644 index 0000000..0932591 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/PromptsApi.kt @@ -0,0 +1,131 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.Prompt +import com.librechat.android.core.model.PromptGroup +import com.librechat.android.core.model.request.AddPromptToGroupRequest +import com.librechat.android.core.model.request.CreatePromptRequest +import com.librechat.android.core.model.request.UpdatePromptGroupRequest +import com.librechat.android.core.model.request.UpdatePromptTagRequest +import com.librechat.android.core.model.response.PromptGroupListResponse +import com.librechat.android.core.model.response.PromptListResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.path +import javax.inject.Inject + +class PromptsApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getPromptGroups( + pageSize: Int = 10, + cursor: String? = null, + name: String? = null, + category: String? = null, + ): PromptGroupListResponse = + client.get { + url { path("api/prompts/groups") } + parameter("pageSize", pageSize) + cursor?.let { parameter("cursor", it) } + name?.let { parameter("name", it) } + category?.let { parameter("category", it) } + }.body() + + suspend fun getPromptGroup(groupId: String): PromptGroup = + client.get { + url { path("api/prompts/groups/$groupId") } + }.body() + + suspend fun createPrompt(prompt: CreatePromptRequest): PromptGroup = + client.post { + url { path("api/prompts") } + setBody(prompt) + }.body() + + suspend fun updatePromptGroup(groupId: String, update: UpdatePromptGroupRequest): PromptGroup = + client.patch { + url { path("api/prompts/groups/$groupId") } + setBody(update) + }.body() + + suspend fun deletePromptGroup(groupId: String) { + client.delete { + url { path("api/prompts/groups/$groupId") } + } + } + + /** + * Get prompts with filtering. Uses query parameters for category, type, etc. + */ + suspend fun getPrompts( + category: String? = null, + type: String? = null, + pageNumber: Int = 1, + pageSize: Int = 10, + ): PromptListResponse = + client.get { + url { path("api/prompts") } + category?.let { parameter("category", it) } + type?.let { parameter("type", it) } + parameter("pageNumber", pageNumber) + parameter("pageSize", pageSize) + }.body() + + /** + * Get all prompts without pagination. + */ + suspend fun getAllPrompts(): PromptListResponse = + client.get { + url { path("api/prompts/all") } + }.body() + + /** + * Add a prompt to an existing group. + */ + suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Prompt = + client.post { + url { path("api/prompts/groups/$groupId/prompts") } + setBody(request) + }.body() + + /** + * Get a single prompt by ID. + */ + suspend fun getPrompt(promptId: String): Prompt = + client.get { + url { path("api/prompts/$promptId") } + }.body() + + /** + * Delete a single prompt by ID. + */ + suspend fun deletePrompt(promptId: String) { + client.delete { + url { path("api/prompts/$promptId") } + } + } + + /** + * Update the production tag for a prompt. + */ + suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Prompt = + client.patch { + url { path("api/prompts/$promptId/tags/production") } + setBody(request) + }.body() + + /** + * Get all prompts belonging to a group. + * Backend returns a raw JSON array of Prompt objects. + */ + suspend fun getPromptsByGroupId(groupId: String): List = + client.get { + url { path("api/prompts") } + parameter("groupId", groupId) + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/SearchApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/SearchApi.kt new file mode 100644 index 0000000..61b11d7 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/SearchApi.kt @@ -0,0 +1,16 @@ +package com.librechat.android.core.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.path +import javax.inject.Inject + +class SearchApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun checkSearchEnabled(): Boolean = + client.get { + url { path("api/search/enable") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ShareApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ShareApi.kt new file mode 100644 index 0000000..d50e0e4 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/ShareApi.kt @@ -0,0 +1,58 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.SharedLink +import com.librechat.android.core.model.request.CreateShareRequest +import com.librechat.android.core.model.response.ShareLinkCheckResponse +import com.librechat.android.core.model.response.SharedLinksResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.path +import javax.inject.Inject + +class ShareApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getSharedLinks(cursor: String? = null): SharedLinksResponse = + client.get { + url { path("api/share") } + parameter("cursor", cursor) + parameter("pageSize", 25) + parameter("isPublic", true) + }.body() + + suspend fun createShareLink( + conversationId: String, + targetMessageId: String? = null, + ): SharedLink = + client.post { + url { path("api/share/$conversationId") } + setBody(CreateShareRequest(targetMessageId = targetMessageId)) + }.body() + + suspend fun getShareLink(shareId: String): SharedLink = + client.get { + url { path("api/share/$shareId") } + }.body() + + suspend fun checkShareLink(conversationId: String): ShareLinkCheckResponse = + client.get { + url { path("api/share/link/$conversationId") } + }.body() + + suspend fun toggleShareVisibility(shareId: String): SharedLink = + client.patch { + url { path("api/share/$shareId") } + }.body() + + suspend fun deleteShareLink(shareId: String) { + client.delete { + url { path("api/share/$shareId") } + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/SpeechApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/SpeechApi.kt new file mode 100644 index 0000000..5d4386d --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/SpeechApi.kt @@ -0,0 +1,72 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.speech.SpeechConfig +import com.librechat.android.core.model.speech.SpeechToTextResponse +import com.librechat.android.core.model.speech.TextToSpeechRequest +import com.librechat.android.core.model.speech.TtsVoice +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.path +import javax.inject.Inject + +class SpeechApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun speechToText(audioData: ByteArray, mimeType: String): SpeechToTextResponse { + val extension = mimeTypeToExtension(mimeType) + return client.submitFormWithBinaryData( + formData = formData { + append("audio", audioData, Headers.build { + append(HttpHeaders.ContentDisposition, "filename=\"audio.$extension\"") + append(HttpHeaders.ContentType, mimeType) + }) + }, + ) { + url { path("api/files/speech/stt") } + }.body() + } + + private fun mimeTypeToExtension(mimeType: String): String = when { + mimeType.contains("ogg") -> "ogg" + mimeType.contains("webm") -> "webm" + mimeType.contains("mp4") || mimeType.contains("m4a") -> "m4a" + mimeType.contains("wav") -> "wav" + mimeType.contains("mp3") || mimeType.contains("mpeg") -> "mp3" + mimeType.contains("flac") -> "flac" + mimeType.contains("3gpp") || mimeType.contains("3gp") -> "3gp" + else -> "ogg" + } + + suspend fun textToSpeech(request: TextToSpeechRequest): ByteArray = + client.post { + url { path("api/files/speech/tts") } + contentType(ContentType.Application.Json) + setBody(request) + }.body() + + suspend fun textToSpeechManual(request: TextToSpeechRequest): ByteArray = + client.post { + url { path("api/files/speech/tts/manual") } + contentType(ContentType.Application.Json) + setBody(request) + }.body() + + suspend fun getVoices(): List = + client.get { + url { path("api/files/speech/tts/voices") } + }.body() + + suspend fun getSpeechConfig(): SpeechConfig = + client.get { + url { path("api/files/speech/config/get") } + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/TagsApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/TagsApi.kt new file mode 100644 index 0000000..6437b99 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/TagsApi.kt @@ -0,0 +1,49 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.ConversationTag +import com.librechat.android.core.model.request.CreateTagRequest +import com.librechat.android.core.model.request.TagUpdateRequest +import com.librechat.android.core.model.request.UpdateTagRequest +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.path +import javax.inject.Inject + +class TagsApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getTags(): List = + client.get { + url { path("api/tags") } + }.body() + + suspend fun createTag(request: CreateTagRequest): ConversationTag = + client.post { + url { path("api/tags") } + setBody(request) + }.body() + + suspend fun updateTag(tag: String, request: UpdateTagRequest): ConversationTag = + client.put { + url { path("api/tags/$tag") } + setBody(request) + }.body() + + suspend fun deleteTag(tag: String) { + client.delete { + url { path("api/tags/$tag") } + } + } + + suspend fun updateConversationTags(conversationId: String, tags: List) { + client.put { + url { path("api/tags/convo/$conversationId") } + setBody(TagUpdateRequest(tags = tags)) + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt new file mode 100644 index 0000000..7378697 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt @@ -0,0 +1,112 @@ +package com.librechat.android.core.network.api + +import com.librechat.android.core.model.User +import com.librechat.android.core.model.UserFavorite +import com.librechat.android.core.model.request.ResendVerificationRequest +import com.librechat.android.core.model.request.VerifyEmailRequest +import com.librechat.android.core.model.response.TermsResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.path +import kotlinx.serialization.Serializable +import javax.inject.Inject + +@Serializable +data class UserUpdateRequest( + val name: String? = null, + val username: String? = null, +) + +@Serializable +data class UpdateFavoritesRequest( + val favorites: List, +) + +@Serializable +data class UpdatePluginsRequest( + val plugins: List, +) + +class UserApi @Inject constructor( + private val client: HttpClient, +) { + suspend fun getUser(): User = + client.get { + url { path("api/user") } + }.body() + + suspend fun updateUser(update: UserUpdateRequest): User = + client.post { + url { path("api/user") } + setBody(update) + }.body() + + suspend fun deleteUser() { + client.delete { + url { path("api/user/delete") } + } + } + + suspend fun verifyEmail(request: VerifyEmailRequest) { + client.post { + url { path("api/user/verify") } + setBody(request) + } + } + + suspend fun resendVerification(request: ResendVerificationRequest) { + client.post { + url { path("api/user/verify/resend") } + setBody(request) + } + } + + suspend fun getTerms(): TermsResponse = + client.get { + url { path("api/user/terms") } + }.body() + + suspend fun acceptTerms() { + client.post { + url { path("api/user/terms/accept") } + } + } + + suspend fun updateAvatar(imageBytes: ByteArray): User = + client.submitFormWithBinaryData( + formData = formData { + append("file", imageBytes, Headers.build { + append(HttpHeaders.ContentDisposition, "filename=\"avatar.png\"") + append(HttpHeaders.ContentType, "image/png") + }) + append("manual", "true") + }, + ) { + url { path("api/user/avatar") } + }.body() + + suspend fun getFavorites(): List = + client.get { + url { path("api/user/favorites") } + }.body() + + suspend fun updateFavorites(favorites: List): User = + client.post { + url { path("api/user/favorites") } + setBody(UpdateFavoritesRequest(favorites = favorites)) + }.body() + + suspend fun updatePlugins(plugins: List): User = + client.post { + url { path("api/user/plugins") } + setBody(UpdatePluginsRequest(plugins = plugins)) + }.body() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt new file mode 100644 index 0000000..59ad9a8 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt @@ -0,0 +1,11 @@ +package com.librechat.android.core.network.client + +/** + * Exception thrown when the server returns a non-2xx HTTP response. + * The [message] field contains a user-friendly error extracted from the response body. + */ +class ApiException( + val statusCode: Int, + override val message: String, + val isBanned: Boolean = false, +) : Exception(message) diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt new file mode 100644 index 0000000..76a02db --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt @@ -0,0 +1,87 @@ +package com.librechat.android.core.network.client + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.plugin +import io.ktor.client.request.HttpRequestPipeline +import io.ktor.client.request.headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.util.AttributeKey +import timber.log.Timber + +class AuthInterceptorPlugin private constructor( + private val tokenManager: TokenManager, +) { + class Config { + lateinit var tokenManager: TokenManager + } + + companion object : HttpClientPlugin { + override val key = AttributeKey("AuthInterceptor") + private val RetryFlag = AttributeKey("AuthRetried") + + override fun prepare(block: Config.() -> Unit): AuthInterceptorPlugin { + val config = Config().apply(block) + return AuthInterceptorPlugin(config.tokenManager) + } + + override fun install(plugin: AuthInterceptorPlugin, scope: HttpClient) { + val skipPaths = setOf( + "auth/login", "auth/register", "auth/refresh", + "auth/requestPasswordReset", "auth/resetPassword", + ) + + // Attach token to outgoing requests + scope.requestPipeline.intercept(HttpRequestPipeline.State) { + val path = context.url.buildString() + if (skipPaths.none { path.contains(it) }) { + val token = plugin.tokenManager.getAccessToken() + if (token != null) { + context.headers.append(HttpHeaders.Authorization, "Bearer $token") + } + } + } + + // Intercept 401 responses at the HttpSend level for proper retry. + // HttpSend.intercept operates before response deserialization, so retrying + // here re-runs the full pipeline including content negotiation. + scope.plugin(HttpSend).intercept { request -> + val originalCall = execute(request) + + if (originalCall.response.status != HttpStatusCode.Unauthorized) { + return@intercept originalCall + } + + // Check if we already retried this request + val alreadyRetried = request.attributes.getOrNull(RetryFlag) == true + if (alreadyRetried) { + Timber.w("401 after retry - session expired") + plugin.tokenManager.emitSessionExpired() + return@intercept originalCall + } + + Timber.d("401 received, attempting token refresh") + val refreshed = plugin.tokenManager.refreshAccessToken() + if (!refreshed) { + Timber.w("Token refresh failed - session expired") + plugin.tokenManager.emitSessionExpired() + return@intercept originalCall + } + + // Retry the original request with the new token + val newToken = plugin.tokenManager.getAccessToken() + request.headers { + remove(HttpHeaders.Authorization) + if (newToken != null) { + append(HttpHeaders.Authorization, "Bearer $newToken") + } + } + request.attributes.put(RetryFlag, true) + + execute(request) + } + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/CookieHelper.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/CookieHelper.kt new file mode 100644 index 0000000..90886b7 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/CookieHelper.kt @@ -0,0 +1,22 @@ +package com.librechat.android.core.network.client + +import io.ktor.http.Headers + +object CookieHelper { + + fun extractRefreshToken(headers: Headers): String? { + val cookies = headers.getAll("Set-Cookie") ?: return null + for (cookie in cookies) { + val parts = cookie.split(";").map { it.trim() } + val nameValue = parts.firstOrNull() ?: continue + if (nameValue.startsWith("refreshToken=")) { + return nameValue.removePrefix("refreshToken=") + } + } + return null + } + + fun buildCookieHeader(refreshToken: String): String { + return "refreshToken=$refreshToken" + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt new file mode 100644 index 0000000..623bf4b --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt @@ -0,0 +1,156 @@ +package com.librechat.android.core.network.client + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import okhttp3.Cache +import timber.log.Timber +import java.io.File + +object LibreChatHttpClient { + + private const val HTTP_CACHE_SIZE = 50L * 1024 * 1024 // 50 MB + + fun create( + json: Json, + tokenManager: TokenManager, + serverUrlProvider: ServerUrlProvider, + cacheDir: File? = null, + debug: Boolean = false, + ): HttpClient = HttpClient(OkHttp) { + + engine { + if (cacheDir != null) { + config { + cache(Cache(File(cacheDir, "http_cache"), HTTP_CACHE_SIZE)) + } + } + } + + install(ContentNegotiation) { + json(json) + } + + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + val sanitized = message + .replace(Regex("Authorization: Bearer [^\\s]+"), "Authorization: Bearer [REDACTED]") + .replace(Regex("refreshToken=[^;&\\s]+"), "refreshToken=[REDACTED]") + Timber.tag("HTTP").d(sanitized) + } + } + level = if (debug) LogLevel.HEADERS else LogLevel.NONE + } + + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 120_000 + } + + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 2) + retryOnException(maxRetries = 2, retryOnTimeout = true) + exponentialDelay() + } + + install(AuthInterceptorPlugin) { + this.tokenManager = tokenManager + } + + // Validate responses before deserialization — catches non-2xx and parses error body. + // Note: 401 responses are handled by AuthInterceptorPlugin at the HttpSend level + // (token refresh + retry). By the time the validator sees the response, 401 means + // the retry also failed, so we throw ApiException to surface the auth failure. + HttpResponseValidator { + validateResponse { response -> + if (!response.status.isSuccess()) { + val statusCode = response.status.value + val bodyText = try { response.bodyAsText() } catch (_: Exception) { "" } + val errorMessage = extractErrorMessage(json, bodyText, statusCode) + val isBanned = statusCode == 403 && bodyText.contains("ban", ignoreCase = true) + + Timber.w("HTTP $statusCode: $errorMessage") + + if (isBanned) { + tokenManager.emitSessionExpired() + } + + throw ApiException( + statusCode = statusCode, + message = errorMessage, + isBanned = isBanned, + ) + } + } + } + + defaultRequest { + val baseUrl = serverUrlProvider.getBaseUrl() + if (baseUrl.isNotEmpty()) { + url.takeFrom(baseUrl) + } + contentType(ContentType.Application.Json) + // WORKAROUND: The backend's uaParser middleware (ua-parser-js) rejects requests + // that don't have a recognized browser User-Agent with 403. We set a standard + // Chrome mobile UA so the parser detects a browser name. This is a temporary + // workaround until the backend exempts native app clients from the UA check. + headers.append(HttpHeaders.UserAgent, BROWSER_USER_AGENT) + } + } + + // Standard Chrome on Android UA string — ua-parser-js will detect "Chrome" as browser name + private const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36" + + /** + * Try to extract a user-friendly error message from the response body. + * Handles JSON responses like {"error": "message"} or {"message": "..."}. + * Falls back to a generic message based on status code. + */ + private fun extractErrorMessage(json: Json, body: String, statusCode: Int): String { + if (body.isNotBlank()) { + try { + val jsonObj = json.parseToJsonElement(body).jsonObject + // Try common error fields: "message", "error", "error_message". + // Fields may be objects rather than strings, so safe-cast to JsonPrimitive. + val msg = (jsonObj["message"] as? JsonPrimitive)?.content + ?: (jsonObj["error"] as? JsonPrimitive)?.content + ?: (jsonObj["error_message"] as? JsonPrimitive)?.content + if (!msg.isNullOrBlank()) return msg + } catch (_: Exception) { + // Body is not JSON (might be HTML) — check for common patterns + if (body.contains("banned", ignoreCase = true) || body.contains("forbidden", ignoreCase = true)) { + return "Access denied. Your account may have been restricted." + } + } + } + + return when (statusCode) { + 400 -> "Bad request" + 403 -> "Access denied" + 404 -> "Not found" + 429 -> "Too many requests. Please try again later." + in 500..599 -> "Server error. Please try again later." + else -> "Request failed (HTTP $statusCode)" + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt new file mode 100644 index 0000000..59ff0ff --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt @@ -0,0 +1,8 @@ +package com.librechat.android.core.network.client + +interface SecureTokenStorage { + suspend fun getAccessToken(): String? + suspend fun getRefreshToken(): String? + suspend fun storeTokens(accessToken: String, refreshToken: String) + suspend fun clearAll() +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt new file mode 100644 index 0000000..ad49fe8 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt @@ -0,0 +1,5 @@ +package com.librechat.android.core.network.client + +interface ServerUrlProvider { + fun getBaseUrl(): String +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/TokenManager.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/TokenManager.kt new file mode 100644 index 0000000..a330448 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/TokenManager.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.network.client + +import kotlinx.coroutines.flow.SharedFlow + +interface TokenManager { + suspend fun getAccessToken(): String? + suspend fun setTokens(accessToken: String, refreshToken: String) + suspend fun refreshAccessToken(): Boolean + suspend fun clearTokens() + fun emitSessionExpired() + val sessionExpiredFlow: SharedFlow +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/di/NetworkModule.kt new file mode 100644 index 0000000..4c75c1d --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/di/NetworkModule.kt @@ -0,0 +1,107 @@ +package com.librechat.android.core.network.di + +import android.content.Context +import com.librechat.android.core.network.client.LibreChatHttpClient +import com.librechat.android.core.network.client.ServerUrlProvider +import com.librechat.android.core.network.client.TokenManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import com.librechat.android.core.network.client.AuthInterceptorPlugin +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + explicitNulls = false + coerceInputValues = true + } + + @Provides + @Singleton + fun provideHttpClient( + @ApplicationContext context: Context, + json: Json, + tokenManager: TokenManager, + serverUrlProvider: ServerUrlProvider, + ): HttpClient = LibreChatHttpClient.create( + json = json, + tokenManager = tokenManager, + serverUrlProvider = serverUrlProvider, + cacheDir = context.cacheDir, + ) + + /** + * Minimal HttpClient for SSE streaming. No ContentNegotiation (avoids + * response body buffering), infinite request/socket timeouts (streams + * can run for minutes), and no response validator (SSE errors are + * handled by SseClient's retry logic). + */ + @Provides + @Singleton + @StreamingClient + fun provideSseClient( + tokenManager: TokenManager, + serverUrlProvider: ServerUrlProvider, + ): HttpClient = HttpClient(OkHttp) { + install(AuthInterceptorPlugin) { + this.tokenManager = tokenManager + } + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = Long.MAX_VALUE + socketTimeoutMillis = Long.MAX_VALUE + } + defaultRequest { + val baseUrl = serverUrlProvider.getBaseUrl() + if (baseUrl.isNotEmpty()) { + url.takeFrom(baseUrl) + } + headers.append( + HttpHeaders.UserAgent, + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", + ) + } + } + + @Provides + @Singleton + @RefreshClient + fun provideRefreshClient( + json: Json, + serverUrlProvider: ServerUrlProvider, + ): HttpClient = HttpClient(OkHttp) { + install(ContentNegotiation) { json(json) } + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 10_000 + } + defaultRequest { + val baseUrl = serverUrlProvider.getBaseUrl() + if (baseUrl.isNotEmpty()) { + url.takeFrom(baseUrl) + } + contentType(ContentType.Application.Json) + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/di/RefreshClient.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/di/RefreshClient.kt new file mode 100644 index 0000000..e1d39cc --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/di/RefreshClient.kt @@ -0,0 +1,7 @@ +package com.librechat.android.core.network.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class RefreshClient diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/di/StreamingClient.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/di/StreamingClient.kt new file mode 100644 index 0000000..f8e26be --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/di/StreamingClient.kt @@ -0,0 +1,7 @@ +package com.librechat.android.core.network.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class StreamingClient diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt new file mode 100644 index 0000000..1c55c57 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt @@ -0,0 +1,110 @@ +package com.librechat.android.core.network.sse + +import com.librechat.android.core.model.StreamEvent +import io.ktor.client.HttpClient +import io.ktor.client.request.accept +import io.ktor.client.request.parameter +import io.ktor.client.request.prepareGet +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.isSuccess +import io.ktor.http.path +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.json.Json +import timber.log.Timber +import javax.inject.Inject +import kotlin.math.min + +class SseClient @Inject constructor( + private val json: Json, +) { + private val mapper = SseEventMapper(json) + private val lineParser = SseLineParser() + + fun connect( + client: HttpClient, + streamPath: String, + resume: Boolean = false, + ): Flow = flow { + mapper.resetState() + var attempt = 0 + var shouldResume = resume + var done = false + val maxRetries = 5 + val initialDelayMs = 1000L + val maxDelayMs = 30_000L + + while (attempt <= maxRetries && !done) { + try { + // Use prepareGet + execute to stream the response incrementally. + // client.get() buffers the entire body in memory before returning, + // which defeats SSE streaming. prepareGet().execute {} keeps the + // HTTP connection open so we can read line-by-line as data arrives. + val statement = client.prepareGet { + url { path(streamPath) } + accept(ContentType.Text.EventStream) + if (shouldResume) { + parameter("resume", "true") + } + } + + statement.execute { response -> + when { + response.status.isSuccess() -> { + attempt = 0 // Reset on successful connection + val channel = response.bodyAsChannel() + lineParser.parse(channel).collect { sseEvent -> + val streamEvent = mapper.map(sseEvent) + if (streamEvent != null) { + emit(streamEvent) + if (streamEvent is StreamEvent.Final) { + done = true + } + } + } + done = true + } + + response.status == HttpStatusCode.NotFound -> { + done = true + } + + response.status == HttpStatusCode.Unauthorized -> { + Timber.w("SSE: 401 Unauthorized for $streamPath") + emit(StreamEvent.Error(message = "Unauthorized", code = "401")) + done = true + } + + else -> { + Timber.w("SSE: unexpected status ${response.status} for $streamPath") + attempt++ + } + } + } + } catch (e: java.io.IOException) { + Timber.w(e, "SSE I/O error (attempt $attempt)") + attempt++ + if (attempt > maxRetries) { + emit(StreamEvent.Error(message = "Connection lost. Please check your network and try again.")) + done = true + } + } catch (e: Exception) { + Timber.w(e, "SSE connection error (attempt $attempt)") + attempt++ + if (attempt > maxRetries) { + emit(StreamEvent.Error(message = "Connection failed. Please try again.")) + done = true + } + } + + if (!done) { + val delayMs = min(initialDelayMs * (1L shl (attempt - 1)), maxDelayMs) + delay(delayMs) + shouldResume = true + } + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEvent.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEvent.kt new file mode 100644 index 0000000..eff51c7 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEvent.kt @@ -0,0 +1,6 @@ +package com.librechat.android.core.network.sse + +data class SseEvent( + val event: String = "", + val data: String = "", +) diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt new file mode 100644 index 0000000..bc395fc --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt @@ -0,0 +1,425 @@ +package com.librechat.android.core.network.sse + +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.Message +import com.librechat.android.core.model.StreamEvent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import timber.log.Timber + +/** + * Maps raw SSE events into domain [StreamEvent]s. + * + * Supports both the LangGraph nested event format (used by agents endpoint) + * and the legacy flat format. The backend sends events in this structure: + * + * **LangGraph events** have an `"event"` key: + * ```json + * {"event":"on_message_delta","data":{"id":"step_xxx","delta":{"content":[{"type":"text","text":"Hello"}]}}} + * ``` + * + * **Control events** have top-level flag keys: + * ```json + * {"final":true,"conversation":{...},"requestMessage":{...},"responseMessage":{...}} + * {"created":{"message":{...}}} + * {"sync":true,"resumeState":{...}} + * ``` + */ +class SseEventMapper(private val json: Json) { + + // Track agent context from on_run_step for cross-event correlation. + // on_message_delta events don't carry agentId/groupId; the server only + // includes them on on_run_step events. We store the last values and + // apply them to subsequent content events. + private var activeAgentId: String? = null + private var activeGroupId: Int? = null + + /** Resets tracked state. Call when starting a new SSE stream. */ + fun resetState() { + activeAgentId = null + activeGroupId = null + } + + /** + * Safely extracts a string from a [JsonElement] that may be a [JsonPrimitive] (string) + * or a [JsonObject]/[JsonArray]. Tool call args and outputs from agent SSE streams + * (e.g. image generation) arrive as JSON objects, not string primitives. + * Returns [JsonPrimitive.contentOrNull] for primitives, or [JsonElement.toString] + * (the raw JSON text) for objects and arrays. + */ + private fun JsonElement?.toStringValue(): String? = when (this) { + null -> null + is JsonPrimitive -> contentOrNull + else -> toString() + } + + fun map(event: SseEvent): StreamEvent? { + if (event.data.isBlank() || event.data == "[DONE]") return null + + return try { + val root = json.parseToJsonElement(event.data).jsonObject + // Check SSE event type for attachment events sent as + // `event: attachment\ndata: {...}` (the data object won't have + // an "event" key — it's the raw attachment metadata). + if (event.event == "attachment" || event.event == "librechat:attachment") { + return mapAttachment(root) + } + mapJsonObject(root) + } catch (e: Exception) { + Timber.w(e, "SSE parse error: event=${event.event}") + StreamEvent.Error(message = "Parse error: ${e.message}") + } + } + + private fun mapJsonObject(root: JsonObject): StreamEvent? { + // 1. Check for "final" control event (highest priority) + if (root["final"]?.jsonPrimitive?.booleanOrNull == true) { + return mapFinalEvent(root) + } + + // 2. Check for "created" control event + if (root.containsKey("created")) { + return mapCreatedEvent(root) + } + + // 3. Check for "sync" control event + if (root["sync"]?.jsonPrimitive?.booleanOrNull == true) { + return mapSyncEvent(root) + } + + // 4. Check for "error" field (may be a string or an object) + val errorText = root["error"]?.toStringValue() + if (errorText != null) { + return StreamEvent.Error(message = errorText) + } + + // 5. Check for LangGraph nested event (has "event" key) + val eventType = root["event"]?.jsonPrimitive?.contentOrNull + if (eventType != null) { + return mapLangGraphEvent(eventType, root) + } + + // 6. Legacy flat format fallback + return mapLegacyEvent(root) + } + + // --- Control events --- + + private fun mapFinalEvent(root: JsonObject): StreamEvent { + val parseErrors = mutableListOf() + + val conversation = root["conversation"]?.let { + try { json.decodeFromJsonElement(Conversation.serializer(), it) } + catch (e: Exception) { + val msg = "Failed to parse final conversation: ${e.message}" + Timber.w(e, msg) + parseErrors.add(msg) + null + } + } + val requestMessage = root["requestMessage"]?.let { + try { json.decodeFromJsonElement(Message.serializer(), it) } + catch (e: Exception) { + val msg = "Failed to parse final requestMessage: ${e.message}" + Timber.w(e, msg) + parseErrors.add(msg) + null + } + } + val responseMessage = root["responseMessage"]?.let { + try { json.decodeFromJsonElement(Message.serializer(), it) } + catch (e: Exception) { + val msg = "Failed to parse final responseMessage: ${e.message}" + Timber.w(e, msg) + parseErrors.add(msg) + null + } + } + // Legacy: some events use "message" instead of "responseMessage" + val legacyMessage = root["message"]?.let { + try { json.decodeFromJsonElement(Message.serializer(), it) } + catch (e: Exception) { + val msg = "Failed to parse final legacy message: ${e.message}" + Timber.w(e, msg) + parseErrors.add(msg) + null + } + } + + // If ALL critical fields failed to parse, emit an Error instead + val allFieldsNull = conversation == null && requestMessage == null + && responseMessage == null && legacyMessage == null + if (allFieldsNull && parseErrors.isNotEmpty()) { + Timber.e("Final event: all fields failed to parse -- %s", parseErrors) + return StreamEvent.Error( + message = "Failed to parse final event: ${parseErrors.joinToString("; ")}", + ) + } + + return StreamEvent.Final( + message = legacyMessage, + conversation = conversation, + requestMessage = requestMessage, + responseMessage = responseMessage, + parseErrors = parseErrors, + ) + } + + private fun mapCreatedEvent(root: JsonObject): StreamEvent.Created { + // "created" can be either a boolean or an object containing message info + val createdObj = root["created"] + val messageObj = when { + // Format: {"created": {"message": {...}}} + createdObj is JsonObject -> createdObj["message"]?.jsonObject + // Format: {"created": true, "message": {...}} + else -> root["message"]?.jsonObject + } + + val conversationId = messageObj?.get("conversationId")?.jsonPrimitive?.contentOrNull ?: "" + val messageId = messageObj?.get("messageId")?.jsonPrimitive?.contentOrNull ?: "" + val parentMessageId = messageObj?.get("parentMessageId")?.jsonPrimitive?.contentOrNull ?: "" + + return StreamEvent.Created( + conversationId = conversationId, + messageId = messageId, + parentMessageId = parentMessageId, + ) + } + + private fun mapSyncEvent(root: JsonObject): StreamEvent? { + // TODO: Parse resumeState.aggregatedContent for full sync support + return null + } + + // --- LangGraph events --- + + private fun mapLangGraphEvent(eventType: String, root: JsonObject): StreamEvent? { + val data = root["data"]?.jsonObject ?: return null + val metadata = data["metadata"]?.jsonObject + + // Server puts agentId/groupId at top-level of data for on_run_step, + // and optionally in data.metadata for other events. + val eventAgentId = data["agentId"]?.jsonPrimitive?.contentOrNull + ?: metadata?.get("agentId")?.jsonPrimitive?.contentOrNull + val eventGroupId = data["groupId"]?.jsonPrimitive?.intOrNull + ?: metadata?.get("groupId")?.jsonPrimitive?.intOrNull + + // Track agent context from run steps for cross-event correlation + if (eventType == "on_run_step" && (eventAgentId != null || eventGroupId != null)) { + activeAgentId = eventAgentId + activeGroupId = eventGroupId + } + + // Resolve: use event-level values if present, otherwise fall back to tracked state + val agentId = eventAgentId ?: activeAgentId + val groupId = eventGroupId ?: activeGroupId + + Timber.d("[Comparison] mapLangGraphEvent: type=%s, agentId=%s, groupId=%s", eventType, agentId, groupId) + + return when (eventType) { + "on_message_delta" -> mapMessageDelta(data, agentId, groupId) + "on_reasoning_delta" -> mapReasoningDelta(data, agentId, groupId) + "on_run_step" -> mapRunStep(data, agentId, groupId) + "on_run_step_delta" -> mapRunStepDelta(data) + "on_run_step_completed" -> mapRunStepCompleted(data, agentId, groupId) + "on_chat_model_end" -> null + "on_agent_update" -> null + "attachment" -> mapAttachment(data) + else -> null + } + } + + private fun mapMessageDelta( + data: JsonObject, + agentId: String?, + groupId: Int?, + ): StreamEvent? { + // {"id":"step_xxx","delta":{"content":[{"type":"text","text":"Hello"}]}} + val delta = data["delta"]?.jsonObject ?: return null + val contentArray = delta["content"]?.jsonArray ?: return null + + val textBuilder = StringBuilder() + for (element in contentArray) { + val part = element.jsonObject + val type = part["type"]?.jsonPrimitive?.contentOrNull + if (type == "text") { + val text = part["text"]?.jsonPrimitive?.contentOrNull + if (text != null) textBuilder.append(text) + } + } + + val text = textBuilder.toString() + if (text.isEmpty()) return null + + return StreamEvent.ContentDelta( + chunk = text, + messageId = data["id"]?.jsonPrimitive?.contentOrNull, + agentId = agentId, + groupId = groupId, + ) + } + + private fun mapReasoningDelta( + data: JsonObject, + agentId: String?, + groupId: Int?, + ): StreamEvent? { + // {"id":"step_xxx","delta":{"content":[{"type":"think","think":"..."}]}} + val delta = data["delta"]?.jsonObject ?: return null + val contentArray = delta["content"]?.jsonArray ?: return null + + val thinkBuilder = StringBuilder() + for (element in contentArray) { + val part = element.jsonObject + val type = part["type"]?.jsonPrimitive?.contentOrNull + if (type == "think") { + val think = part["think"]?.jsonPrimitive?.contentOrNull + if (think != null) thinkBuilder.append(think) + } + } + + val text = thinkBuilder.toString() + if (text.isEmpty()) return null + + return StreamEvent.ThinkingDelta( + chunk = text, + agentId = agentId, + groupId = groupId, + ) + } + + private fun mapRunStep( + data: JsonObject, + agentId: String?, + groupId: Int?, + ): StreamEvent? { + // {"id":"step_xxx","stepDetails":{"type":"tool_calls","tool_calls":[{"id":"call_xxx","name":"search","args":""}]}} + val stepDetails = data["stepDetails"]?.jsonObject ?: return null + val toolCalls = stepDetails["tool_calls"]?.jsonArray ?: return null + val firstToolCall = toolCalls.firstOrNull()?.jsonObject ?: return null + + val toolCallId = firstToolCall["id"]?.jsonPrimitive?.contentOrNull ?: return null + val toolName = firstToolCall["name"]?.jsonPrimitive?.contentOrNull ?: "" + val args = firstToolCall["args"]?.toStringValue() ?: "" + + return StreamEvent.ToolCallStart( + toolCallId = toolCallId, + toolName = toolName, + input = args, + agentId = agentId, + groupId = groupId, + ) + } + + private fun mapRunStepDelta(data: JsonObject): StreamEvent? { + // Tool call argument streaming - not currently tracked + return null + } + + private fun mapRunStepCompleted( + data: JsonObject, + agentId: String?, + groupId: Int?, + ): StreamEvent? { + // {"result":{"id":"step_xxx","tool_call":{"id":"call_xxx","name":"search","output":"..."}}} + val result = data["result"]?.jsonObject ?: return null + val toolCall = result["tool_call"]?.jsonObject ?: return null + + val toolCallId = toolCall["id"]?.jsonPrimitive?.contentOrNull ?: return null + val output = toolCall["output"]?.toStringValue() ?: "" + + return StreamEvent.ToolCallComplete( + toolCallId = toolCallId, + output = output, + agentId = agentId, + groupId = groupId, + ) + } + + private fun mapAttachment(data: JsonObject): StreamEvent? { + // Attachment events carry file metadata for tool-generated artifacts + // (e.g., images from DALL-E / image_edit_oai). The data object has: + // file_id, filename, filepath, type, width, height, toolCallId, messageId, etc. + val fileId = data["file_id"]?.jsonPrimitive?.contentOrNull ?: "" + val filename = data["filename"]?.jsonPrimitive?.contentOrNull ?: "" + val type = data["type"]?.jsonPrimitive?.contentOrNull ?: "" + if (fileId.isBlank() && filename.isBlank()) return null + return StreamEvent.AttachmentCreated( + fileId = fileId, + filename = filename, + type = type, + filepath = data["filepath"]?.jsonPrimitive?.contentOrNull, + toolCallId = data["toolCallId"]?.jsonPrimitive?.contentOrNull, + width = data["width"]?.jsonPrimitive?.intOrNull, + height = data["height"]?.jsonPrimitive?.intOrNull, + ) + } + + // --- Legacy flat format fallback --- + + private fun mapLegacyEvent(root: JsonObject): StreamEvent? { + val type = root["type"]?.jsonPrimitive?.contentOrNull + val metadata = root["metadata"]?.jsonObject + val agentId = metadata?.get("agentId")?.jsonPrimitive?.contentOrNull + val groupId = metadata?.get("groupId")?.jsonPrimitive?.intOrNull + + return when (type) { + "content", "text" -> { + val text = root["text"]?.jsonPrimitive?.contentOrNull ?: return null + StreamEvent.ContentDelta( + chunk = text, + messageId = root["messageId"]?.jsonPrimitive?.contentOrNull, + agentId = agentId, + groupId = groupId, + ) + } + "thinking", "think" -> { + val text = root["text"]?.jsonPrimitive?.contentOrNull ?: return null + StreamEvent.ThinkingDelta( + chunk = text, + agentId = agentId, + groupId = groupId, + ) + } + "tool_call_start" -> { + StreamEvent.ToolCallStart( + toolCallId = root["toolCallId"]?.jsonPrimitive?.contentOrNull ?: "", + toolName = root["toolName"]?.jsonPrimitive?.contentOrNull ?: "", + input = root["input"]?.toStringValue() ?: "", + agentId = agentId, + groupId = groupId, + ) + } + "tool_call_complete" -> { + StreamEvent.ToolCallComplete( + toolCallId = root["toolCallId"]?.jsonPrimitive?.contentOrNull ?: "", + output = root["output"]?.toStringValue() ?: "", + agentId = agentId, + groupId = groupId, + ) + } + else -> { + // Check for message property (another legacy format) + val text = root["text"]?.jsonPrimitive?.contentOrNull + ?: root["response"]?.jsonPrimitive?.contentOrNull + if (text != null) { + StreamEvent.ContentDelta( + chunk = text, + agentId = agentId, + groupId = groupId, + ) + } else { + null + } + } + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt new file mode 100644 index 0000000..4c79421 --- /dev/null +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt @@ -0,0 +1,82 @@ +package com.librechat.android.core.network.sse + +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readUTF8Line +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import java.io.IOException + +class SseLineParser( + private val lineReadTimeoutMs: Long = DEFAULT_LINE_READ_TIMEOUT_MS, +) { + + fun parse(channel: ByteReadChannel): Flow = flow { + var currentEvent = "" + val currentData = StringBuilder() + + try { + while (!channel.isClosedForRead) { + val line = try { + withTimeout(lineReadTimeoutMs) { + channel.readUTF8Line() + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + Timber.w("SSE stream stalled: no data for ${lineReadTimeoutMs / 1000}s") + throw IOException("Stream stalled: no data received for ${lineReadTimeoutMs / 1000}s") + } ?: break + + when { + // Comment lines (keepalive pings) + line.startsWith(":") -> continue + + // Event type + line.startsWith("event:") -> { + currentEvent = line.removePrefix("event:").trim() + } + + // Data lines - support multi-line data by appending with newline + line.startsWith("data:") -> { + val data = line.removePrefix("data:").trim() + if (currentData.isNotEmpty()) { + currentData.append("\n") + } + currentData.append(data) + } + + // Empty line = end of event + line.isBlank() -> { + if (currentData.isNotEmpty()) { + val data = currentData.toString() + if (data == "[DONE]") { + currentEvent = "" + currentData.clear() + break + } + emit(SseEvent(event = currentEvent, data = data)) + currentEvent = "" + currentData.clear() + } + } + } + } + } catch (e: IOException) { + Timber.e(e, "SSE parse IOException") + throw e + } + + // Emit any remaining buffered data + if (currentData.isNotEmpty()) { + val data = currentData.toString() + if (data != "[DONE]") { + emit(SseEvent(event = currentEvent, data = data)) + } + } + } + + companion object { + /** Default timeout: 120 seconds to accommodate long AI thinking/agent blocks. */ + const val DEFAULT_LINE_READ_TIMEOUT_MS = 120_000L + } +} diff --git a/core/ui/CLAUDE.md b/core/ui/CLAUDE.md new file mode 100644 index 0000000..1cd3ded --- /dev/null +++ b/core/ui/CLAUDE.md @@ -0,0 +1,106 @@ +# core:ui + +Material 3 theme and shared Compose components used across all feature modules. Purely presentational -- no business logic, no ViewModels, no repositories. + +## What This Module Provides + +### Theme (`theme/`) +- `Theme.kt`: `LibreChatTheme` wrapping Material 3 `MaterialTheme`. Maps LibreChat web colors to M3 color roles. +- `Color.kt`: Color palette derived from the web app's CSS variables. +- `Type.kt`: Typography scale. +- `Shape.kt`: Corner radius definitions. + +### Shared Components (`components/`) +- `LibreChatTopBar` - App bar with optional back navigation and actions. +- `LoadingIndicator` - Centered circular progress. +- `ErrorBanner` - Dismissible error message bar. +- `EmptyState` - Illustration + message for empty lists. +- `AvatarImage` - User/agent/model avatar with Coil image loading and fallback. +- `ConfirmationDialog` - Reusable confirm/cancel dialog. +- `ModelIcon` - Endpoint-specific model icons. +- `EndpointBadge` - Colored badge showing the AI provider. +- `SearchBar` - Reusable search input field. +- `BottomSheetScaffold` - Wrapper for modal bottom sheets. +- `PullToRefresh` - Pull-to-refresh wrapper. +- `BannerDisplay` - Dismissible server banner cards (info/warning/error types). + +### Markdown (`markdown/`) +- `MarkdownRenderer` - Compose-native markdown rendering (commonmark). +- `CodeBlock` - Syntax-highlighted code blocks (Compose-native, NOT WebView per block). +- `LatexRenderer` - Math rendering via WebView or compose-math. + +### Message Components (`message/`) +- `MessageBubble` - Shared message rendering used by `:feature:chat`. +- `ToolCallCard` - Expandable tool call display. +- `FileAttachmentChip` - File reference chip. +- `FeedbackButtons` - Thumbs up/down. +- `StreamingIndicator` - Pulsing indicator during SSE streaming. + +## Rules + +- **No business logic.** No ViewModels, no repository calls, no use cases. +- Components accept domain models from `:core:model` as parameters and render them. +- All components must be stateless or hoist state to the caller. +- Dependencies: `:core:model`, `:core:common`, Coil for image loading, Compose libraries. +- Convention plugins: `librechat.android.library` + `librechat.android.compose`. +- No Hilt in this module (no `@Inject`, no `@Module`). +- Use `@Preview` annotations on all components for Android Studio preview support. +- During SSE streaming, buffer markdown re-renders to ~100ms intervals to avoid frame drops. + +### Dynamic Parameters (`components/Dynamic*.kt`) +- `DynamicParameterPanel` dispatches `List` to typed controls (slider/dropdown/checkbox/input/textarea) +- `ParameterDefinition` contains: key, type, label, description, default, min, max, step, options +- `DynamicSlider` snaps to step increments; calculates stepCount from range +- All controls are stateless — caller manages values via `Map` +- `ModelParameterSheet` falls back to existing fixed params when no schema available +- **Gotcha**: Slider step count calculated as `((max - min) / step).toInt()` — ensure step divides range evenly to avoid drift + +### Accessibility (`theme/AccessibilityUtils.kt`) +- `Modifier.minTouchTarget()` — enforces 48dp minimum size +- `Modifier.semanticHeading()` — marks composable as heading for TalkBack +- Helper functions: `endpointContentDescription()`, `modelIconContentDescription()` +- Applied to `LoadingIndicator` and `EmptyState` components + +## Compose Performance Rules + +These rules apply to ALL composables across feature modules, not just core:ui. + +### UI State Architecture + +1. **Single UI state data class per screen.** Each ViewModel exposes ONE `StateFlow` — never 5+ individual StateFlows that each trigger recomposition independently. + +2. **UI state contains only display-ready data.** The ViewModel maps domain models (e.g., `Conversation` with 28 fields) to minimal display data classes (e.g., `DrawerConversationDisplayData` with 7 fields). Composables should never receive full domain models. + +3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types. + +4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavHost. State changes should only recompose the composable that reads them. + +5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame). + +### Stability + +6. **All types passed to composables must be stable.** Primitives, `String`, `@Immutable` data classes, and enums are stable. Standard `List`/`Set`/`Map` are NOT stable by default — they are declared stable in `compose-stability.conf` at the project root. + +7. **Never pass lambdas that capture unstable references.** Prefer method references (`viewModel::doThing`) or `remember`'d lambdas over inline lambdas that capture changing state. + +### LazyColumn / LazyList + +8. **Always provide `key`** on `items()` calls. Keys must be unique, stable identifiers (e.g., `conversationId`). + +9. **Always provide `contentType`** when a LazyColumn has mixed item types (headers, content rows, loading indicators). This enables Compose to reuse compositions across items of the same type. + +10. **One element per `item {}` block.** Multiple composables in one block prevents independent recycling. + +11. **No nested same-direction scrollables.** Never put `LazyColumn` inside `verticalScroll` — combine into one `LazyColumn` using `item {}` blocks. + +### Avoiding Unnecessary Work + +12. **Move data transformations to the ViewModel.** Sorting, filtering, grouping, and model mapping happen in the ViewModel — not in `remember` blocks in composables. + +13. **Cache expensive objects as top-level constants.** `RoundedCornerShape`, `Regex`, `DateTimeFormatter` — allocating these per-item per-frame is wasteful. + +14. **Use `background(color, shape)` instead of `clip(shape).background(color)`.** The `clip` modifier creates a persistent clip layer per composable; `background` with a shape parameter draws the clipped background without the layer overhead. + +15. **Avoid `copy(alpha = ...)` on colors.** Alpha-blended colors force GPU compositing. Use opaque colors from the theme where possible. + +16. **Defer state reads to later phases.** Use lambda-based modifiers (`Modifier.offset { }`, `Modifier.drawBehind { }`) instead of value-based ones (`Modifier.offset(x, y)`, `Modifier.background(animatedColor)`) for scroll/animation state. diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 0000000..50a9d99 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("librechat.android.library") + id("librechat.android.compose") +} + +android { + namespace = "com.librechat.android.core.ui" +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(libs.coil.compose) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt new file mode 100644 index 0000000..63bbb9b --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt @@ -0,0 +1,97 @@ +package com.librechat.android.core.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +/** Default user avatar background matching the official web app. */ +private val UserAvatarBlue = Color(0xFF7989FF) + +@Composable +fun AvatarImage( + imageUrl: String?, + modifier: Modifier = Modifier, + size: Dp = 32.dp, + fallbackText: String = "?", + @DrawableRes fallbackIconRes: Int? = null, + fallbackBackgroundColor: Color? = null, + showPersonIcon: Boolean = false, + tintIcon: Boolean = false, + contentDescription: String? = "$fallbackText avatar", +) { + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = contentDescription, + modifier = modifier.size(size).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else if (showPersonIcon) { + // User avatar fallback: person icon with blue background + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(UserAvatarBlue), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = contentDescription, + modifier = Modifier.size(size * 0.6f), + tint = Color.White, + ) + } + } else if (fallbackIconRes != null) { + // Provider icon fallback: drawable resource with themed background + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(fallbackBackgroundColor ?: MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = fallbackIconRes), + contentDescription = contentDescription, + modifier = Modifier.size(size * 0.7f), + tint = if (tintIcon) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + Color.Unspecified + }, + ) + } + } else { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = fallbackText.take(1).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt new file mode 100644 index 0000000..5b8348d --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt @@ -0,0 +1,127 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.Banner +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +/** + * Renders a vertical stack of dismissible server banners. + * + * Banners are filtered against [dismissedIds] so dismissed banners stay hidden. + * Supports three visual types: "info" (blue), "warning" (amber), "error" (red). + */ +@Composable +fun BannerDisplay( + banners: List, + dismissedIds: Set, + onDismiss: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val visibleBanners = banners.filter { banner -> + val id = banner.bannerId ?: return@filter false + id !in dismissedIds + } + + Column(modifier = modifier.fillMaxWidth()) { + visibleBanners.forEach { banner -> + val bannerId = banner.bannerId ?: return@forEach + AnimatedVisibility( + visible = true, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + BannerCard( + banner = banner, + onDismiss = { onDismiss(bannerId) }, + ) + } + } + } +} + +@Composable +private fun BannerCard( + banner: Banner, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val bannerType = banner.type ?: "info" + val containerColor = when (bannerType) { + "warning" -> Color(0xFFFFF3E0) // amber container + "error" -> MaterialTheme.colorScheme.errorContainer + else -> Color(0xFFE3F2FD) // blue container (info) + } + val contentColor = when (bannerType) { + "warning" -> Color(0xFFE65100) + "error" -> MaterialTheme.colorScheme.onErrorContainer + else -> Color(0xFF0D47A1) + } + val icon = when (bannerType) { + "warning" -> Icons.Default.Warning + "error" -> Icons.Default.Error + else -> Icons.Default.Info + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + colors = CardDefaults.cardColors(containerColor = containerColor), + ) { + Row( + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = bannerType, + tint = contentColor, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = banner.message ?: "", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_dismiss_banner), + tint = contentColor, + ) + } + } + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt new file mode 100644 index 0000000..d5c862d --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt @@ -0,0 +1,69 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview + +/** Labeled boolean toggle rendered as a Material 3 Switch (not a Checkbox widget). */ +@Composable +fun DynamicCheckbox( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .semantics { + contentDescription = "$label toggle, ${if (checked) "on" else "off"}" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DynamicCheckboxPreview() { + var checked by remember { mutableStateOf(true) } + DynamicCheckbox( + label = "Stream response", + checked = checked, + onCheckedChange = { checked = it }, + description = "Enable streaming for real-time output.", + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt new file mode 100644 index 0000000..360723c --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt @@ -0,0 +1,94 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** Labeled dropdown selector using ExposedDropdownMenuBox with read-only text field anchor. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DynamicDropdown( + label: String, + selectedValue: String, + options: List, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, +) { + var expanded by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedValue, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onValueChange(option) + expanded = false + }, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DynamicDropdownPreview() { + var selected by remember { mutableStateOf("gpt-4") } + DynamicDropdown( + label = "Model", + selectedValue = selected, + options = listOf("gpt-4", "gpt-3.5-turbo", "claude-3-opus"), + onValueChange = { selected = it }, + description = "Select the model to use.", + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt new file mode 100644 index 0000000..67ae601 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt @@ -0,0 +1,67 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** Single-line text input with label, optional placeholder, and description. */ +@Composable +fun DynamicInput( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + description: String? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + placeholder = if (placeholder != null) { + { Text(placeholder) } + } else { + null + }, + singleLine = true, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DynamicInputPreview() { + var text by remember { mutableStateOf("") } + DynamicInput( + label = "System Prompt", + value = text, + onValueChange = { text = it }, + placeholder = "Enter a system prompt...", + description = "Custom instructions for the model.", + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt new file mode 100644 index 0000000..2dba387 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt @@ -0,0 +1,173 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.ParameterDefinition +import com.librechat.android.core.model.ParameterType +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +/** Dispatches a list of ParameterDefinitions to typed controls (slider, dropdown, checkbox, input, textarea) by ParameterType. */ +@Composable +fun DynamicParameterPanel( + definitions: List, + values: Map, + onValueChanged: (key: String, value: String) -> Unit, + modifier: Modifier = Modifier, + title: String = stringResource(R.string.endpoint_parameters), +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.semantics { heading() }, + ) + + definitions.forEach { definition -> + val currentValue = values[definition.key] ?: definition.default.orEmpty() + + when (definition.type) { + ParameterType.SLIDER -> { + val min = definition.min?.toFloat() ?: 0f + val max = definition.max?.toFloat() ?: 1f + val step = definition.step?.toFloat() ?: 0.1f + val floatValue = currentValue.toFloatOrNull() ?: min + + DynamicSlider( + label = definition.label, + value = floatValue, + onValueChange = { onValueChanged(definition.key, it.toString()) }, + min = min, + max = max, + step = step, + description = definition.description, + ) + } + + ParameterType.DROPDOWN -> { + DynamicDropdown( + label = definition.label, + selectedValue = currentValue, + options = definition.options ?: emptyList(), + onValueChange = { onValueChanged(definition.key, it) }, + description = definition.description, + ) + } + + ParameterType.CHECKBOX -> { + val checked = currentValue.toBooleanStrictOrNull() ?: false + DynamicCheckbox( + label = definition.label, + checked = checked, + onCheckedChange = { onValueChanged(definition.key, it.toString()) }, + description = definition.description, + ) + } + + ParameterType.TEXT -> { + DynamicInput( + label = definition.label, + value = currentValue, + onValueChange = { onValueChanged(definition.key, it) }, + placeholder = definition.default, + description = definition.description, + ) + } + + ParameterType.SWITCH -> { + val checked = currentValue.toBooleanStrictOrNull() ?: false + DynamicCheckbox( + label = definition.label, + checked = checked, + onCheckedChange = { onValueChanged(definition.key, it.toString()) }, + description = definition.description, + ) + } + + ParameterType.TEXTAREA -> { + DynamicTextarea( + label = definition.label, + value = currentValue, + onValueChange = { onValueChanged(definition.key, it) }, + placeholder = definition.default, + description = definition.description, + ) + } + + ParameterType.TAGS -> { + DynamicTagsInput( + label = definition.label, + tags = if (currentValue.isBlank()) emptyList() + else currentValue.split("\n").filter { it.isNotBlank() }, + onTagsChanged = { tags -> + onValueChanged(definition.key, tags.joinToString("\n")) + }, + description = definition.description, + maxTags = definition.max?.toInt() ?: 4, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DynamicParameterPanelPreview() { + val definitions = listOf( + ParameterDefinition( + key = "temperature", + label = "Temperature", + type = ParameterType.SLIDER, + min = 0.0, + max = 2.0, + step = 0.1, + default = "1.0", + description = "Controls randomness of the output.", + ), + ParameterDefinition( + key = "model", + label = "Model", + type = ParameterType.DROPDOWN, + options = listOf("gpt-4", "gpt-3.5-turbo"), + default = "gpt-4", + ), + ParameterDefinition( + key = "stream", + label = "Stream", + type = ParameterType.SWITCH, + default = "true", + description = "Enable streaming output.", + ), + ParameterDefinition( + key = "stop", + label = "Stop Sequence", + type = ParameterType.TEXT, + description = "Sequence where the model stops generating.", + ), + ) + var values by remember { mutableStateOf(emptyMap()) } + DynamicParameterPanel( + definitions = definitions, + values = values, + onValueChanged = { key, value -> + values = values.toMutableMap().apply { this[key] = value } + }, + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt new file mode 100644 index 0000000..02d5f4a --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt @@ -0,0 +1,93 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import kotlin.math.roundToInt +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +/** Labeled slider that snaps to step increments, with stepCount derived from (max - min) / step. */ +@Composable +fun DynamicSlider( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + min: Float = 0f, + max: Float = 1f, + step: Float = 0.1f, + description: String? = null, + displayDecimals: Int = 2, +) { + val stepCount = ((max - min) / step).toInt().coerceAtLeast(1) - 1 + val displayValue = "%.${displayDecimals}f".format(value) + + val sliderCd = stringResource(R.string.cd_slider_value, label, displayValue) + Column( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = sliderCd }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = displayValue, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Slider( + value = value, + onValueChange = { newValue -> + onValueChange((newValue / step).roundToInt() * step) + }, + valueRange = min..max, + steps = stepCount, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DynamicSliderPreview() { + var value by remember { mutableFloatStateOf(0.7f) } + DynamicSlider( + label = "Temperature", + value = value, + onValueChange = { value = it }, + min = 0f, + max = 2f, + step = 0.1f, + description = "Controls randomness of the output.", + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt new file mode 100644 index 0000000..6f4f5d7 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt @@ -0,0 +1,121 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +/** Tag input that renders chips and allows adding/removing text tags up to a max count. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DynamicTagsInput( + label: String, + tags: List, + onTagsChanged: (List) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + maxTags: Int = 4, +) { + var inputText by remember { mutableStateOf("") } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + if (tags.isNotEmpty()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + tags.forEach { tag -> + AssistChip( + onClick = {}, + label = { Text(tag) }, + trailingIcon = { + IconButton( + onClick = { onTagsChanged(tags - tag) }, + modifier = Modifier.size(18.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_remove_tag, tag), + modifier = Modifier.size(14.dp), + ) + } + }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + if (tags.size < maxTags) { + OutlinedTextField( + value = inputText, + onValueChange = { inputText = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.add_stop_sequence_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + val trimmed = inputText.trim() + if (trimmed.isNotEmpty() && tags.size < maxTags) { + onTagsChanged(tags + trimmed) + inputText = "" + } + }, + ), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DynamicTagsInputPreview() { + var tags by remember { mutableStateOf(listOf("END", "STOP")) } + DynamicTagsInput( + label = "Stop Sequences", + tags = tags, + onTagsChanged = { tags = it }, + description = "Up to 4 sequences where the model will stop generating.", + maxTags = 4, + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt new file mode 100644 index 0000000..a52f7ed --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt @@ -0,0 +1,72 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** Multi-line text input with configurable minLines/maxLines, label, and optional description. */ +@Composable +fun DynamicTextarea( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + description: String? = null, + minLines: Int = 3, + maxLines: Int = 6, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + placeholder = if (placeholder != null) { + { Text(placeholder) } + } else { + null + }, + minLines = minLines, + maxLines = maxLines, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DynamicTextareaPreview() { + var text by remember { mutableStateOf("") } + DynamicTextarea( + label = "Custom Instructions", + value = text, + onValueChange = { text = it }, + placeholder = "Enter detailed instructions...", + description = "Provide additional context for the model.", + minLines = 3, + maxLines = 6, + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EmptyState.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EmptyState.kt new file mode 100644 index 0000000..05f79f8 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EmptyState.kt @@ -0,0 +1,59 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyState( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + icon: ImageVector? = null, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { heading() }, + ) + if (description != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt new file mode 100644 index 0000000..1331a3b --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt @@ -0,0 +1,59 @@ +package com.librechat.android.core.ui.components + +import androidx.annotation.DrawableRes +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.ui.R + +/** + * Returns the bundled drawable resource for the given endpoint, or null if none is available. + * Used by conversation list items and chat message avatars for provider-specific icons. + */ +@DrawableRes +fun EModelEndpoint.toIconRes(): Int? = when (this) { + EModelEndpoint.OPENAI -> R.drawable.ic_openai + EModelEndpoint.AZURE_OPENAI -> R.drawable.ic_azure + EModelEndpoint.GOOGLE -> R.drawable.ic_google + EModelEndpoint.ANTHROPIC -> R.drawable.ic_anthropic + EModelEndpoint.BEDROCK -> R.drawable.ic_bedrock + EModelEndpoint.AGENTS -> R.drawable.ic_agents + EModelEndpoint.ASSISTANTS -> R.drawable.ic_openai + EModelEndpoint.AZURE_ASSISTANTS -> R.drawable.ic_azure + EModelEndpoint.CUSTOM -> null +} + +/** + * Returns the drawable resource for an endpoint string (serial name), or null. + */ +@DrawableRes +fun endpointIconRes(endpoint: String?): Int? = when (endpoint) { + "openAI" -> R.drawable.ic_openai + "azureOpenAI" -> R.drawable.ic_azure + "google" -> R.drawable.ic_google + "anthropic" -> R.drawable.ic_anthropic + "bedrock" -> R.drawable.ic_bedrock + "agents" -> R.drawable.ic_agents + "assistants" -> R.drawable.ic_openai + "azureAssistants" -> R.drawable.ic_azure + else -> null +} + +/** + * Returns true if the endpoint icon is monochrome and should be tinted with + * the theme's onSurfaceVariant color. Brand-colored icons (Anthropic, Google, + * Azure, Bedrock) should be rendered with [Color.Unspecified] to preserve their colors. + */ +fun EModelEndpoint.isMonochromeIcon(): Boolean = when (this) { + EModelEndpoint.OPENAI, + EModelEndpoint.ASSISTANTS, + EModelEndpoint.AGENTS, + -> true + else -> false +} + +/** + * String-based variant of [isMonochromeIcon]. + */ +fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) { + "openAI", "assistants", "agents" -> true + else -> false +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt new file mode 100644 index 0000000..b6fed43 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt @@ -0,0 +1,521 @@ +package com.librechat.android.core.ui.components + +import com.librechat.android.core.model.ParameterDefinition +import com.librechat.android.core.model.ParameterType + +/** + * Registry of parameter definitions per endpoint, matching the official LibreChat web app's + * parameterSettings.ts. Each endpoint maps to an ordered list of ParameterDefinition that + * drives the dynamic ModelParameterSheet rendering. + */ +object EndpointParameterRegistry { + + fun getDefinitions(endpoint: String): List { + val key = endpoint.lowercase() + return ENDPOINT_PARAMS[key] ?: ENDPOINT_PARAMS["default"]!! + } + + private val ENDPOINT_PARAMS: Map> = mapOf( + "openai" to openAiParams(), + "azureopenai" to openAiParams(), + "custom" to openAiParams(), + "anthropic" to anthropicParams(), + "google" to googleParams(), + "bedrock" to bedrockParams(), + "default" to defaultParams(), + ) + + private fun openAiParams() = listOf( + ParameterDefinition( + key = "chatGptLabel", + label = "Custom Name", + type = ParameterType.TEXT, + default = "", + description = "Set a custom name for the AI.", + ), + ParameterDefinition( + key = "promptPrefix", + label = "Custom Instructions", + type = ParameterType.TEXTAREA, + default = "", + description = "Set custom instructions to include in System Message.", + ), + ParameterDefinition( + key = "maxContextTokens", + label = "Max Context Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max context tokens for this conversation.", + ), + ParameterDefinition( + key = "max_tokens", + label = "Max Output Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max tokens in the response.", + ), + ParameterDefinition( + key = "temperature", + label = "Temperature", + type = ParameterType.SLIDER, + min = 0.0, + max = 2.0, + step = 0.01, + default = "1.0", + description = "Controls randomness. Lower values are more focused and deterministic.", + ), + ParameterDefinition( + key = "top_p", + label = "Top P", + type = ParameterType.SLIDER, + min = 0.0, + max = 1.0, + step = 0.01, + default = "1.0", + description = "Nucleus sampling. Controls diversity via cumulative probability cutoff.", + ), + ParameterDefinition( + key = "frequency_penalty", + label = "Frequency Penalty", + type = ParameterType.SLIDER, + min = -2.0, + max = 2.0, + step = 0.01, + default = "0.0", + description = "Penalizes tokens based on their frequency in the text so far.", + ), + ParameterDefinition( + key = "presence_penalty", + label = "Presence Penalty", + type = ParameterType.SLIDER, + min = -2.0, + max = 2.0, + step = 0.01, + default = "0.0", + description = "Penalizes tokens based on whether they appear in the text so far.", + ), + ParameterDefinition( + key = "stop", + label = "Stop Sequences", + type = ParameterType.TAGS, + max = 4.0, + default = "", + description = "Up to 4 sequences where the model will stop generating.", + ), + ParameterDefinition( + key = "resendFiles", + label = "Resend Files", + type = ParameterType.SWITCH, + default = "false", + description = "Resend previously attached files with every message.", + ), + ParameterDefinition( + key = "reasoning_effort", + label = "Reasoning Effort", + type = ParameterType.DROPDOWN, + options = listOf("", "none", "minimal", "low", "medium", "high", "xhigh"), + default = "", + description = "Controls how much reasoning the model performs.", + ), + ParameterDefinition( + key = "web_search", + label = "Web Search", + type = ParameterType.SWITCH, + default = "false", + description = "Enable web search to get up-to-date information.", + ), + ParameterDefinition( + key = "fileTokenLimit", + label = "File Token Limit", + type = ParameterType.TEXT, + default = "", + description = "Maximum number of tokens from attached files.", + ), + ) + + private fun anthropicParams() = listOf( + ParameterDefinition( + key = "modelLabel", + label = "Custom Name", + type = ParameterType.TEXT, + default = "", + description = "Set a custom name for the AI.", + ), + ParameterDefinition( + key = "promptPrefix", + label = "Custom Instructions", + type = ParameterType.TEXTAREA, + default = "", + description = "Set custom instructions to include in System Message.", + ), + ParameterDefinition( + key = "maxContextTokens", + label = "Max Context Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max context tokens for this conversation.", + ), + ParameterDefinition( + key = "maxOutputTokens", + label = "Max Output Tokens", + type = ParameterType.TEXT, + min = 1.0, + max = 128000.0, + default = "", + description = "Max tokens in the response (1-128000).", + ), + ParameterDefinition( + key = "temperature", + label = "Temperature", + type = ParameterType.SLIDER, + min = 0.0, + max = 1.0, + step = 0.01, + default = "1.0", + description = "Controls randomness. Lower values are more focused and deterministic.", + ), + ParameterDefinition( + key = "topP", + label = "Top P", + type = ParameterType.SLIDER, + min = 0.0, + max = 1.0, + step = 0.01, + default = "0.7", + description = "Nucleus sampling. Controls diversity via cumulative probability cutoff.", + ), + ParameterDefinition( + key = "topK", + label = "Top K", + type = ParameterType.SLIDER, + min = 1.0, + max = 40.0, + step = 1.0, + default = "5", + description = "Limits token selection to the top K most likely tokens.", + ), + ParameterDefinition( + key = "resendFiles", + label = "Resend Files", + type = ParameterType.SWITCH, + default = "false", + description = "Resend previously attached files with every message.", + ), + ParameterDefinition( + key = "promptCache", + label = "Prompt Caching", + type = ParameterType.SWITCH, + default = "true", + description = "Enable prompt caching to reduce latency and cost.", + ), + ParameterDefinition( + key = "thinking", + label = "Thinking", + type = ParameterType.SWITCH, + default = "true", + description = "Enable extended thinking.", + ), + ParameterDefinition( + key = "thinkingBudget", + label = "Thinking Budget", + type = ParameterType.TEXT, + min = 1024.0, + max = 200000.0, + default = "", + description = "Max tokens for thinking (1024-200000).", + ), + ParameterDefinition( + key = "effort", + label = "Effort", + type = ParameterType.DROPDOWN, + options = listOf("", "low", "medium", "high", "max"), + default = "", + description = "Controls the overall effort level of the response.", + ), + ParameterDefinition( + key = "web_search", + label = "Web Search", + type = ParameterType.SWITCH, + default = "false", + description = "Enable web search to get up-to-date information.", + ), + ParameterDefinition( + key = "fileTokenLimit", + label = "File Token Limit", + type = ParameterType.TEXT, + default = "", + description = "Maximum number of tokens from attached files.", + ), + ) + + private fun googleParams() = listOf( + ParameterDefinition( + key = "modelLabel", + label = "Custom Name", + type = ParameterType.TEXT, + default = "", + description = "Set a custom name for the AI.", + ), + ParameterDefinition( + key = "promptPrefix", + label = "Custom Instructions", + type = ParameterType.TEXTAREA, + default = "", + description = "Set custom instructions to include in System Message.", + ), + ParameterDefinition( + key = "maxContextTokens", + label = "Max Context Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max context tokens for this conversation.", + ), + ParameterDefinition( + key = "maxOutputTokens", + label = "Max Output Tokens", + type = ParameterType.TEXT, + min = 1.0, + max = 64000.0, + default = "", + description = "Max tokens in the response (1-64000).", + ), + ParameterDefinition( + key = "temperature", + label = "Temperature", + type = ParameterType.SLIDER, + min = 0.0, + max = 2.0, + step = 0.01, + default = "1.0", + description = "Controls randomness. Lower values are more focused and deterministic.", + ), + ParameterDefinition( + key = "topP", + label = "Top P", + type = ParameterType.SLIDER, + min = 0.0, + max = 1.0, + step = 0.01, + default = "0.95", + description = "Nucleus sampling. Controls diversity via cumulative probability cutoff.", + ), + ParameterDefinition( + key = "topK", + label = "Top K", + type = ParameterType.SLIDER, + min = 1.0, + max = 40.0, + step = 1.0, + default = "40", + description = "Limits token selection to the top K most likely tokens.", + ), + ParameterDefinition( + key = "resendFiles", + label = "Resend Files", + type = ParameterType.SWITCH, + default = "false", + description = "Resend previously attached files with every message.", + ), + ParameterDefinition( + key = "thinking", + label = "Thinking", + type = ParameterType.SWITCH, + default = "true", + description = "Enable extended thinking.", + ), + ParameterDefinition( + key = "thinkingBudget", + label = "Thinking Budget", + type = ParameterType.TEXT, + min = -1.0, + max = 32000.0, + default = "", + description = "Max tokens for thinking (-1 = dynamic, up to 32000).", + ), + ParameterDefinition( + key = "web_search", + label = "Grounding with Google Search", + type = ParameterType.SWITCH, + default = "false", + description = "Enable web search to get up-to-date information.", + ), + ParameterDefinition( + key = "fileTokenLimit", + label = "File Token Limit", + type = ParameterType.TEXT, + default = "", + description = "Maximum number of tokens from attached files.", + ), + ) + + private fun bedrockParams() = listOf( + ParameterDefinition( + key = "modelLabel", + label = "Custom Name", + type = ParameterType.TEXT, + default = "", + description = "Set a custom name for the AI.", + ), + ParameterDefinition( + key = "promptPrefix", + label = "Custom Instructions", + type = ParameterType.TEXTAREA, + default = "", + description = "Set custom instructions to include in System Message.", + ), + ParameterDefinition( + key = "maxContextTokens", + label = "Max Context Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max context tokens for this conversation.", + ), + ParameterDefinition( + key = "maxTokens", + label = "Max Output Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max tokens in the response.", + ), + ParameterDefinition( + key = "temperature", + label = "Temperature", + type = ParameterType.SLIDER, + min = 0.0, + max = 1.0, + step = 0.01, + default = "1.0", + description = "Controls randomness. Lower values are more focused and deterministic.", + ), + ParameterDefinition( + key = "topP", + label = "Top P", + type = ParameterType.SLIDER, + min = 0.0, + max = 1.0, + step = 0.01, + default = "0.7", + description = "Nucleus sampling. Controls diversity via cumulative probability cutoff.", + ), + ParameterDefinition( + key = "topK", + label = "Top K", + type = ParameterType.SLIDER, + min = 0.0, + max = 500.0, + step = 1.0, + default = "0", + description = "Limits token selection to the top K most likely tokens.", + ), + ParameterDefinition( + key = "resendFiles", + label = "Resend Files", + type = ParameterType.SWITCH, + default = "false", + description = "Resend previously attached files with every message.", + ), + ParameterDefinition( + key = "promptCache", + label = "Prompt Caching", + type = ParameterType.SWITCH, + default = "false", + description = "Enable prompt caching to reduce latency and cost.", + ), + ParameterDefinition( + key = "thinking", + label = "Thinking", + type = ParameterType.SWITCH, + default = "false", + description = "Enable extended thinking.", + ), + ParameterDefinition( + key = "thinkingBudget", + label = "Thinking Budget", + type = ParameterType.TEXT, + min = 1024.0, + max = 200000.0, + default = "", + description = "Max tokens for thinking (1024-200000).", + ), + ParameterDefinition( + key = "effort", + label = "Effort", + type = ParameterType.DROPDOWN, + options = listOf("", "low", "medium", "high", "max"), + default = "", + description = "Controls the overall effort level of the response.", + ), + ParameterDefinition( + key = "fileTokenLimit", + label = "File Token Limit", + type = ParameterType.TEXT, + default = "", + description = "Maximum number of tokens from attached files.", + ), + ) + + private fun defaultParams() = listOf( + ParameterDefinition( + key = "modelLabel", + label = "Custom Name", + type = ParameterType.TEXT, + default = "", + description = "Set a custom name for the AI.", + ), + ParameterDefinition( + key = "promptPrefix", + label = "Custom Instructions", + type = ParameterType.TEXTAREA, + default = "", + description = "Set custom instructions to include in System Message.", + ), + ParameterDefinition( + key = "maxContextTokens", + label = "Max Context Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max context tokens for this conversation.", + ), + ParameterDefinition( + key = "maxOutputTokens", + label = "Max Output Tokens", + type = ParameterType.TEXT, + default = "", + description = "Max tokens in the response.", + ), + ParameterDefinition( + key = "temperature", + label = "Temperature", + type = ParameterType.SLIDER, + min = 0.0, + max = 2.0, + step = 0.01, + default = "1.0", + description = "Controls randomness. Lower values are more focused and deterministic.", + ), + ParameterDefinition( + key = "topP", + label = "Top P", + type = ParameterType.SLIDER, + min = 0.0, + max = 1.0, + step = 0.01, + default = "1.0", + description = "Nucleus sampling. Controls diversity via cumulative probability cutoff.", + ), + ParameterDefinition( + key = "resendFiles", + label = "Resend Files", + type = ParameterType.SWITCH, + default = "false", + description = "Resend previously attached files with every message.", + ), + ParameterDefinition( + key = "fileTokenLimit", + label = "File Token Limit", + type = ParameterType.TEXT, + default = "", + description = "Maximum number of tokens from attached files.", + ), + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt new file mode 100644 index 0000000..506198f --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt @@ -0,0 +1,64 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +@Composable +fun ErrorBanner( + message: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource(R.string.cd_error), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = message, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + if (onRetry != null) { + TextButton(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } + } + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt new file mode 100644 index 0000000..ea60e5d --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt @@ -0,0 +1,55 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LibreChatTopBar( + title: String, + modifier: Modifier = Modifier, + onMenuClick: (() -> Unit)? = null, + onNavigateBack: (() -> Unit)? = null, + actions: @Composable () -> Unit = {}, +) { + CenterAlignedTopAppBar( + title = { Text(text = title) }, + modifier = modifier, + navigationIcon = { + when { + onNavigateBack != null -> { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + } + onMenuClick != null -> { + IconButton(onClick = onMenuClick) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.cd_menu), + ) + } + } + } + }, + actions = { actions() }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt new file mode 100644 index 0000000..c59b9d5 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt @@ -0,0 +1,29 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier, +) { + val loadingCd = stringResource(R.string.cd_loading) + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { contentDescription = loadingCd }, + ) + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt new file mode 100644 index 0000000..99c412e --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt @@ -0,0 +1,284 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.ParameterDefinition +import com.librechat.android.core.model.ParameterType +import com.librechat.android.core.ui.R +import androidx.compose.ui.res.stringResource + +@Stable +data class ModelParameters( + val temperature: Float = 1.0f, + val maxOutputTokens: Int? = null, + val topP: Float = 1.0f, + val frequencyPenalty: Float = 0.0f, + val presencePenalty: Float = 0.0f, + val customName: String = "", + val customInstructions: String = "", + val maxContextTokens: Int? = null, + val topK: Int? = null, + val resendFiles: Boolean = false, + val thinking: Boolean = false, + val thinkingBudget: String = "Auto", + val webSearch: Boolean = false, + val fileTokenLimit: Int? = null, + val dynamicValues: Map = emptyMap(), +) { + companion object { + val DEFAULT = ModelParameters() + } + + /** + * Reads a parameter value by its registry key, bridging between the typed fields + * in ModelParameters and the dynamic string-based system. + */ + fun getValueForKey(key: String): String = when (key) { + "chatGptLabel", "modelLabel" -> customName + "promptPrefix", "system" -> customInstructions + "maxContextTokens" -> maxContextTokens?.toString() ?: "" + "max_tokens", "maxOutputTokens", "maxTokens" -> maxOutputTokens?.toString() ?: "" + "temperature" -> temperature.toString() + "top_p", "topP" -> topP.toString() + "frequency_penalty" -> frequencyPenalty.toString() + "presence_penalty" -> presencePenalty.toString() + "topK" -> (topK ?: 0).toString() + "resendFiles" -> resendFiles.toString() + "thinking" -> thinking.toString() + "thinkingBudget" -> thinkingBudget + "web_search" -> webSearch.toString() + "fileTokenLimit" -> fileTokenLimit?.toString() ?: "" + "stop" -> dynamicValues["stop"] ?: "" + "reasoning_effort", "effort" -> dynamicValues[key] ?: "" + "promptCache" -> dynamicValues["promptCache"] ?: "false" + else -> dynamicValues[key] ?: "" + } + + /** + * Returns a copy of ModelParameters with the given key updated to the new value. + * Maps dynamic registry keys back to the typed fields. + */ + fun withUpdatedKey(key: String, value: String): ModelParameters = when (key) { + "chatGptLabel", "modelLabel" -> copy(customName = value) + "promptPrefix", "system" -> copy(customInstructions = value) + "maxContextTokens" -> copy(maxContextTokens = value.toIntOrNull()) + "max_tokens", "maxOutputTokens", "maxTokens" -> copy(maxOutputTokens = value.toIntOrNull()) + "temperature" -> copy(temperature = value.toFloatOrNull() ?: temperature) + "top_p", "topP" -> copy(topP = value.toFloatOrNull() ?: topP) + "frequency_penalty" -> copy(frequencyPenalty = value.toFloatOrNull() ?: frequencyPenalty) + "presence_penalty" -> copy(presencePenalty = value.toFloatOrNull() ?: presencePenalty) + "topK" -> { + val intVal = value.toIntOrNull() + copy(topK = if (intVal == 0) null else intVal) + } + "resendFiles" -> copy(resendFiles = value.toBooleanStrictOrNull() ?: resendFiles) + "thinking" -> copy(thinking = value.toBooleanStrictOrNull() ?: thinking) + "thinkingBudget" -> copy(thinkingBudget = value) + "web_search" -> copy(webSearch = value.toBooleanStrictOrNull() ?: webSearch) + "fileTokenLimit" -> copy(fileTokenLimit = value.toIntOrNull()) + else -> copy(dynamicValues = dynamicValues.toMutableMap().apply { this[key] = value }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModelParameterSheet( + parameters: ModelParameters, + onParametersChanged: (ModelParameters) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + selectedEndpoint: String = "", + dynamicParameterDefinitions: List? = null, + onSaveAsPreset: () -> Unit = {}, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + ModelParameterContent( + parameters = parameters, + onParametersChanged = onParametersChanged, + selectedEndpoint = selectedEndpoint, + dynamicParameterDefinitions = dynamicParameterDefinitions, + onSaveAsPreset = onSaveAsPreset, + modifier = Modifier + .padding(horizontal = 24.dp) + .navigationBarsPadding(), + ) + } +} + +@Composable +fun ModelParameterContent( + parameters: ModelParameters, + onParametersChanged: (ModelParameters) -> Unit, + modifier: Modifier = Modifier, + selectedEndpoint: String = "", + dynamicParameterDefinitions: List? = null, + onSaveAsPreset: () -> Unit = {}, +) { + val definitions = remember(selectedEndpoint, dynamicParameterDefinitions) { + if (!dynamicParameterDefinitions.isNullOrEmpty()) { + dynamicParameterDefinitions + } else { + EndpointParameterRegistry.getDefinitions(selectedEndpoint) + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.model_parameters), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + definitions.forEach { definition -> + val currentValue = parameters.getValueForKey(definition.key) + + when (definition.type) { + ParameterType.TEXT -> { + DynamicInput( + label = definition.label, + value = currentValue, + onValueChange = { newValue -> + onParametersChanged(parameters.withUpdatedKey(definition.key, newValue)) + }, + placeholder = definition.default?.ifEmpty { stringResource(R.string.default_placeholder) } ?: stringResource(R.string.default_placeholder), + description = definition.description, + ) + } + + ParameterType.TEXTAREA -> { + DynamicTextarea( + label = definition.label, + value = currentValue, + onValueChange = { newValue -> + onParametersChanged(parameters.withUpdatedKey(definition.key, newValue)) + }, + placeholder = definition.default?.ifEmpty { null }, + description = definition.description, + ) + } + + ParameterType.SLIDER -> { + val min = definition.min?.toFloat() ?: 0f + val max = definition.max?.toFloat() ?: 1f + val step = definition.step?.toFloat() ?: 0.01f + val floatValue = currentValue.toFloatOrNull() ?: (definition.default?.toFloatOrNull() ?: min) + + DynamicSlider( + label = definition.label, + value = floatValue.coerceIn(min, max), + onValueChange = { newValue -> + onParametersChanged(parameters.withUpdatedKey(definition.key, newValue.toString())) + }, + min = min, + max = max, + step = step, + description = definition.description, + ) + } + + ParameterType.SWITCH, ParameterType.CHECKBOX -> { + val checked = currentValue.toBooleanStrictOrNull() + ?: (definition.default?.toBooleanStrictOrNull() ?: false) + + DynamicCheckbox( + label = definition.label, + checked = checked, + onCheckedChange = { newChecked -> + onParametersChanged(parameters.withUpdatedKey(definition.key, newChecked.toString())) + }, + description = definition.description, + ) + } + + ParameterType.DROPDOWN -> { + val displayValue = if (currentValue.isEmpty()) { + definition.options?.firstOrNull() ?: "" + } else { + currentValue + } + + DynamicDropdown( + label = definition.label, + selectedValue = displayValue, + options = definition.options ?: emptyList(), + onValueChange = { newValue -> + onParametersChanged(parameters.withUpdatedKey(definition.key, newValue)) + }, + description = definition.description, + ) + } + + ParameterType.TAGS -> { + val tags = if (currentValue.isBlank()) emptyList() + else currentValue.split("\n").filter { it.isNotBlank() } + + DynamicTagsInput( + label = definition.label, + tags = tags, + onTagsChanged = { newTags -> + onParametersChanged( + parameters.withUpdatedKey(definition.key, newTags.joinToString("\n")), + ) + }, + description = definition.description, + maxTags = definition.max?.toInt() ?: 4, + ) + } + } + } + + // Reset to defaults + OutlinedButton( + onClick = { + onParametersChanged(ModelParameters.DEFAULT) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.reset_to_defaults)) + } + + // Save As Preset + FilledTonalButton( + onClick = onSaveAsPreset, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.save_as_preset)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt new file mode 100644 index 0000000..52c8572 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt @@ -0,0 +1,71 @@ +package com.librechat.android.core.ui.components + +import android.os.Build +import android.view.RoundedCorner +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp + +private const val FALLBACK_CORNER_RADIUS_DP = 24f + +/** + * Wraps screen content to apply rounded corners during navigation transitions. + * Pass the [transition] from AnimatedVisibilityScope inside a composable() block. + */ +@Composable +fun ScreenTransitionWrapper( + transition: Transition, + content: @Composable () -> Unit, +) { + val screenCornerRadiusDp = rememberScreenCornerRadiusDp() + + val cornerRadius by transition.animateFloat( + transitionSpec = { tween(300, easing = LinearEasing) }, + label = "screenCorner", + ) { state -> + when (state) { + EnterExitState.PreEnter -> screenCornerRadiusDp + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> screenCornerRadiusDp + } + } + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(cornerRadius.dp)), + ) { + content() + } +} + +@Composable +private fun rememberScreenCornerRadiusDp(): Float { + val view = LocalView.current + val density = LocalDensity.current + return remember(view, density) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val insets = view.rootWindowInsets + val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT) + if (topLeft != null && topLeft.radius > 0) { + with(density) { topLeft.radius.toDp().value } + } else { + FALLBACK_CORNER_RADIUS_DP + } + } else { + FALLBACK_CORNER_RADIUS_DP + } + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt new file mode 100644 index 0000000..264a67f --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt @@ -0,0 +1,27 @@ +package com.librechat.android.core.ui.theme + +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/** + * Ensures a minimum 48dp touch target for accessibility compliance. + */ +fun Modifier.minTouchTarget(): Modifier = sizeIn(minWidth = 48.dp, minHeight = 48.dp) + +/** + * Marks a composable as a semantic heading for screen readers. + */ +fun Modifier.semanticHeading(): Modifier = semantics { heading() } + +/** + * Content description for an AI provider endpoint. + */ +fun endpointContentDescription(endpoint: String): String = "AI provider: $endpoint" + +/** + * Content description for a model icon. + */ +fun modelIconContentDescription(modelName: String): String = "Model: $modelName" diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Color.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Color.kt new file mode 100644 index 0000000..f12f0a6 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Color.kt @@ -0,0 +1,45 @@ +package com.librechat.android.core.ui.theme + +import androidx.compose.ui.graphics.Color + +// Light Theme Colors +val LightBackground = Color(0xFFFFFFFF) +val LightOnBackground = Color(0xFF212121) +val LightSurface = Color(0xFFFFFFFF) +val LightSurfaceContainer = Color(0xFFF7F7F8) +val LightSurfaceContainerLowest = Color(0xFFFFFFFF) +val LightSurfaceContainerLow = Color(0xFFF0F0F0) +val LightSurfaceContainerHigh = Color(0xFFF5F5F5) +val LightSurfaceContainerHighest = Color(0xFFEFEFEF) +val LightSecondaryContainer = Color(0xFFE3E3E3) +val LightSurfaceVariant = Color(0xFFE3E3E3) +val LightOnSurfaceVariant = Color(0xFF666666) +val LightOutline = Color(0xFF999999) +val LightOutlineVariant = Color(0xFFE0E0E0) +val LightPrimary = Color(0xFF8B5CF6) +val LightPrimaryContainer = Color(0xFF7C3AED) +val LightOnPrimary = Color(0xFFFFFFFF) +val LightOnPrimaryContainer = Color(0xFFFFFFFF) +val LightError = Color(0xFFEF4444) +val LightOnError = Color(0xFFFFFFFF) + +// Dark Theme Colors +val DarkBackground = Color(0xFF0D0D0D) +val DarkOnBackground = Color(0xFFECECF1) +val DarkSurface = Color(0xFF171717) +val DarkSurfaceContainer = Color(0xFF1A1A1A) +val DarkSurfaceContainerLowest = Color(0xFF0D0D0D) +val DarkSurfaceContainerLow = Color(0xFF171717) +val DarkSurfaceContainerHigh = Color(0xFF1E1E1E) +val DarkSurfaceContainerHighest = Color(0xFF252525) +val DarkSecondaryContainer = Color(0xFF2A2A2A) +val DarkSurfaceVariant = Color(0xFF424242) +val DarkOnSurfaceVariant = Color(0xFFAAAAAA) +val DarkOutline = Color(0xFF888888) +val DarkOutlineVariant = Color(0xFF333333) +val DarkPrimary = Color(0xFFAB68FF) +val DarkPrimaryContainer = Color(0xFFC084FC) +val DarkOnPrimary = Color(0xFF0D0D0D) +val DarkOnPrimaryContainer = Color(0xFF1A0040) +val DarkError = Color(0xFFEF4444) +val DarkOnError = Color(0xFFFFFFFF) diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Shape.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Shape.kt new file mode 100644 index 0000000..1f65c26 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Shape.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val libreChatShapes = Shapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(28.dp), +) diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Theme.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Theme.kt new file mode 100644 index 0000000..2ed8066 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Theme.kt @@ -0,0 +1,78 @@ +package com.librechat.android.core.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val libreChatLightColorScheme = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + primaryContainer = LightPrimaryContainer, + onPrimaryContainer = LightOnPrimaryContainer, + background = LightBackground, + onBackground = LightOnBackground, + surface = LightSurface, + surfaceContainer = LightSurfaceContainer, + surfaceContainerLowest = LightSurfaceContainerLowest, + surfaceContainerLow = LightSurfaceContainerLow, + surfaceContainerHigh = LightSurfaceContainerHigh, + surfaceContainerHighest = LightSurfaceContainerHighest, + secondaryContainer = LightSecondaryContainer, + surfaceVariant = LightSurfaceVariant, + onSurfaceVariant = LightOnSurfaceVariant, + outline = LightOutline, + outlineVariant = LightOutlineVariant, + error = LightError, + onError = LightOnError, +) + +private val libreChatDarkColorScheme = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + primaryContainer = DarkPrimaryContainer, + onPrimaryContainer = DarkOnPrimaryContainer, + background = DarkBackground, + onBackground = DarkOnBackground, + surface = DarkSurface, + surfaceContainer = DarkSurfaceContainer, + surfaceContainerLowest = DarkSurfaceContainerLowest, + surfaceContainerLow = DarkSurfaceContainerLow, + surfaceContainerHigh = DarkSurfaceContainerHigh, + surfaceContainerHighest = DarkSurfaceContainerHighest, + secondaryContainer = DarkSecondaryContainer, + surfaceVariant = DarkSurfaceVariant, + onSurfaceVariant = DarkOnSurfaceVariant, + outline = DarkOutline, + outlineVariant = DarkOutlineVariant, + error = DarkError, + onError = DarkOnError, +) + +@Composable +fun LibreChatTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(LocalContext.current) + else dynamicLightColorScheme(LocalContext.current) + } + darkTheme -> libreChatDarkColorScheme + else -> libreChatLightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = libreChatTypography, + shapes = libreChatShapes, + content = content, + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Type.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Type.kt new file mode 100644 index 0000000..80259b1 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Type.kt @@ -0,0 +1,52 @@ +package com.librechat.android.core.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val libreChatTypography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 30.sp, + lineHeight = 36.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + titleSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), +) diff --git a/core/ui/src/main/res/drawable/ic_agents.xml b/core/ui/src/main/res/drawable/ic_agents.xml new file mode 100644 index 0000000..650beb6 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_agents.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_anthropic.xml b/core/ui/src/main/res/drawable/ic_anthropic.xml new file mode 100644 index 0000000..553b8f3 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_anthropic.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_azure.xml b/core/ui/src/main/res/drawable/ic_azure.xml new file mode 100644 index 0000000..787bfa4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_azure.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_bedrock.xml b/core/ui/src/main/res/drawable/ic_bedrock.xml new file mode 100644 index 0000000..5bc3a71 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_bedrock.xml @@ -0,0 +1,10 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_google.xml b/core/ui/src/main/res/drawable/ic_google.xml new file mode 100644 index 0000000..679f971 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_google.xml @@ -0,0 +1,10 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_openai.xml b/core/ui/src/main/res/drawable/ic_openai.xml new file mode 100644 index 0000000..366b49d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_openai.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..419f7f6 --- /dev/null +++ b/core/ui/src/main/res/values/strings.xml @@ -0,0 +1,34 @@ + + + + Loading\u2026 + Error + Retry + Cancel + Confirm + OK + Delete + Back + + + Loading + Back + Menu + Error + Dismiss banner + Remove %1$s + %1$s slider, value %2$s + AI provider: %1$s + Model: %1$s + %1$s avatar + + + Model Parameters + Reset to Defaults + Save As Preset + Default + Endpoint Parameters + + + Add stop sequence\u2026 + diff --git a/feature/agents/CLAUDE.md b/feature/agents/CLAUDE.md new file mode 100644 index 0000000..b5965eb --- /dev/null +++ b/feature/agents/CLAUDE.md @@ -0,0 +1,56 @@ +# feature:agents + +## Screens +- **AgentMarketplaceScreen** -- grid of agent cards with search and category filters +- **AgentDetailScreen** -- full agent info with "Start Chat" action + +## Navigation +- `AGENTS_ROUTE` ("agents") -> marketplace grid +- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view +- `onStartChat(agentId)` callback navigates to chat with the selected agent + +## Marketplace ViewModel +- `AgentMarketplaceViewModel` uses server-side pagination via `AgentRepository.getAgentsPaginated()` +- Page size: 10 agents per page +- Categories fetched from `getAgentCategories()` API (server-driven, not client-derived) +- `onCategorySelected()` toggles category filter and resets to page 1 +- `onSearchQueryChanged()` debounces 500ms, then resets to page 1 with server-side search +- `loadMore()` appends next page; no-ops if already loading or no more pages +- Pull-to-refresh resets to page 1 +- **Gotcha**: Search and category changes reset pagination — always load from page 1 when filters change + +## Agent Card Layout +- Card: 136dp height, endpoint avatar + name (2-line clamp) + description (2-line clamp) + "By {author}" +- Category badge at top-right corner +- Tap opens agent detail + +## Agent Detail +- `AgentDetailViewModel` loads single agent by ID +- Shows: large avatar, name, description, capabilities/tools list +- Actions: Pin/Unpin, Copy link, "Start Chat" button +- "Start Chat" flow: sets `endpoint: "agents"` + `agent_id`, creates conversation template, navigates to new chat + +## Data Layer +- `AgentRepository` in `:core:data` wraps `AgentsApi` from `:core:network` +- API: `GET /api/agents` (list), `GET /api/agents/:id` (detail), `GET /api/agents/categories` +- Categories are server-driven, not hardcoded; special values: "promoted" (Top Picks), "all" + +## Infinite Scroll +- `LazyVerticalGrid` with `rememberLazyGridState()` + `derivedStateOf` for scroll detection +- Triggers `loadMore()` when last visible item is within 3 of the end +- Shows `CircularProgressIndicator` in a full-span grid item while loading more + +## Spec Notes (not yet implemented) +- Category tabs with slide animation (`AnimatedContent` with `slideInHorizontally`) +- Permission check: `PermissionTypes.MARKETPLACE` with `Permissions.USE` + +### Agent Editor — Advanced Sections +- `AgentEditorScreen` now includes 6 collapsible sections below the basic fields: + - `AgentActionsPanel` — OpenAPI action CRUD (domain, type, auth) + - `AgentMcpToolsSelector` — hierarchical MCP tools grouped by server, checkbox selection + - `AgentCodeInterpreterSection` / `AgentFileSearchSection` — simple capability toggles + - `AgentSharingSection` — visibility (Private/Team/Public) + collaborative toggle + - `AgentHandoffConfig` — select agents for handoff, displayed as InputChips +- `AgentEditorViewModel` depends on both `AgentRepository` and `McpRepository` +- **Gotcha**: MCP tools load requires a separate `McpRepository.getTools()` call; they're not bundled with agent data +- **Gotcha**: `isPublic`/`isCollaborative` map to the sharing section, not individual toggles in the agent model diff --git a/feature/agents/build.gradle.kts b/feature/agents/build.gradle.kts new file mode 100644 index 0000000..0f43935 --- /dev/null +++ b/feature/agents/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("librechat.android.feature") +} + +android { + namespace = "com.librechat.android.feature.agents" +} + +dependencies { + implementation(project(":core:network")) + implementation(libs.coil.compose) + implementation(libs.kotlinx.serialization.json) + implementation(libs.snakeyaml.engine) + implementation(libs.timber) +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt new file mode 100644 index 0000000..90c7867 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt @@ -0,0 +1,52 @@ +package com.librechat.android.feature.agents + +import androidx.compose.runtime.Immutable + +@Immutable +data class AgentCardDisplayData( + val id: String, + val name: String, + val description: String?, + val avatarUrl: String?, + val author: String?, + val authorName: String?, +) + +@Immutable +data class AgentDetailDisplayData( + val id: String, + val name: String, + val description: String?, + val avatarUrl: String?, + val author: String?, + val authorName: String?, + val model: String?, + val category: String?, + val tools: List?, + val conversationStarters: List, +) + +@Immutable +data class AgentHandoffDisplayData( + val id: String, + val name: String, +) + +@Immutable +data class AgentActionDisplayData( + val actionId: String?, + val domain: String?, + val type: String?, + val authType: String?, + val rawSpec: String?, + val functionCount: Int, +) + +@Immutable +data class AgentToolDisplayData( + val toolId: String?, + val name: String?, + val description: String?, + val icon: String?, + val isAvailable: Boolean, +) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt new file mode 100644 index 0000000..ed31f09 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt @@ -0,0 +1,868 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.librechat.android.core.model.ActionAuth +import com.librechat.android.core.model.ActionMetadata +import com.librechat.android.core.model.request.FunctionTool +import com.librechat.android.feature.agents.AgentActionDisplayData +import com.librechat.android.feature.agents.util.OpenApiSpecParser +import com.librechat.android.feature.agents.util.ParsedFunctionInfo +import kotlinx.coroutines.delay +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +private const val VALIDATION_DEBOUNCE_MS = 800L +private const val HIDDEN_PLACEHOLDER = "" + +/** Collapsible CRUD panel for OpenAPI actions with full editor. */ +@Composable +fun AgentActionsPanel( + actions: List, + onSaveAction: (actionId: String?, metadata: ActionMetadata, functions: List) -> Unit, + onDeleteAction: (actionId: String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + var editingAction by remember { mutableStateOf(null) } + var showEditor by rememberSaveable { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.openapi_actions_count, actions.size), + style = MaterialTheme.typography.titleSmall, + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (actions.isEmpty()) { + Text( + text = stringResource(R.string.no_actions_configured), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + + actions.forEach { action -> + val actionId = action.actionId ?: return@forEach + ActionCard( + action = action, + onEdit = { + editingAction = action + showEditor = true + }, + onDelete = { onDeleteAction(actionId) }, + ) + } + + OutlinedButton( + onClick = { + editingAction = null + showEditor = true + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.add_action)) + } + } + } + } + + if (showEditor) { + ActionEditorDialog( + existingAction = editingAction, + onDismiss = { + showEditor = false + editingAction = null + }, + onSave = { actionId, metadata, functions -> + onSaveAction(actionId, metadata, functions) + showEditor = false + editingAction = null + }, + ) + } +} + +@Composable +private fun ActionCard( + action: AgentActionDisplayData, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = action.domain ?: stringResource(R.string.unknown_author), + style = MaterialTheme.typography.bodyLarge, + ) + val authLabel = when (action.authType) { + "service_http" -> stringResource(R.string.auth_api_key) + "oauth" -> stringResource(R.string.auth_oauth) + else -> stringResource(R.string.auth_none) + } + Text( + text = stringResource(R.string.action_auth_info, authLabel, action.functionCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row { + IconButton(onClick = onEdit) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit_action), + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_action), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ========================================== +// Action Editor Dialog (Full-screen) +// ========================================== + +@Composable +private fun ActionEditorDialog( + existingAction: AgentActionDisplayData?, + onDismiss: () -> Unit, + onSave: (actionId: String?, metadata: ActionMetadata, functions: List) -> Unit, +) { + // State for the editor + var rawSpec by rememberSaveable { mutableStateOf(existingAction?.rawSpec ?: "") } + var authType by rememberSaveable { mutableStateOf(existingAction?.authType ?: "none") } + var apiKey by rememberSaveable { mutableStateOf("") } + var authorizationType by rememberSaveable { mutableStateOf("bearer") } + var customAuthHeader by rememberSaveable { mutableStateOf("") } + var oauthClientId by rememberSaveable { mutableStateOf("") } + var oauthClientSecret by rememberSaveable { mutableStateOf("") } + var authorizationUrl by rememberSaveable { mutableStateOf("") } + var clientUrl by rememberSaveable { mutableStateOf("") } + var scope by rememberSaveable { mutableStateOf("") } + var tokenExchangeMethod by rememberSaveable { mutableStateOf("default_post") } + var privacyPolicyUrl by rememberSaveable { mutableStateOf("") } + + // Validation state + var parsedDomain by remember { mutableStateOf("") } + var parsedFunctions by remember { mutableStateOf>(emptyList()) } + var parsedFunctionInfos by remember { mutableStateOf>(emptyList()) } + var validationErrors by remember { mutableStateOf>(emptyList()) } + var isValidating by remember { mutableStateOf(false) } + + // Auth config dialog + var showAuthConfig by rememberSaveable { mutableStateOf(false) } + + // For editing existing actions: show hidden placeholder for sensitive fields + val isEditing = existingAction?.actionId != null + + // Debounced validation + LaunchedEffect(rawSpec) { + if (rawSpec.isBlank()) { + parsedDomain = "" + parsedFunctions = emptyList() + parsedFunctionInfos = emptyList() + validationErrors = emptyList() + return@LaunchedEffect + } + isValidating = true + delay(VALIDATION_DEBOUNCE_MS) + val result = OpenApiSpecParser.parse(rawSpec) + parsedDomain = result.domain + parsedFunctions = result.functions + parsedFunctionInfos = OpenApiSpecParser.extractFunctionInfo(rawSpec) + validationErrors = result.errors + isValidating = false + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + Scaffold( + topBar = { + Surface(tonalElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isEditing) stringResource(R.string.edit_action) else stringResource(R.string.add_action), + style = MaterialTheme.typography.titleMedium, + ) + Row { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton( + onClick = { + val auth = ActionAuth( + type = authType, + authorizationType = if (authType == "service_http") authorizationType else null, + customAuthHeader = if (authType == "service_http" && authorizationType == "custom") { + customAuthHeader.ifBlank { null } + } else { + null + }, + authorizationUrl = if (authType == "oauth") authorizationUrl.ifBlank { null } else null, + clientUrl = if (authType == "oauth") clientUrl.ifBlank { null } else null, + scope = if (authType == "oauth") scope.ifBlank { null } else null, + tokenExchangeMethod = if (authType == "oauth") tokenExchangeMethod else null, + ) + val metadata = ActionMetadata( + domain = parsedDomain, + auth = auth, + rawSpec = rawSpec, + apiKey = if (authType == "service_http" && apiKey.isNotBlank() && apiKey != HIDDEN_PLACEHOLDER) { + apiKey + } else { + null + }, + oauthClientId = if (authType == "oauth" && oauthClientId.isNotBlank() && oauthClientId != HIDDEN_PLACEHOLDER) { + oauthClientId + } else { + null + }, + oauthClientSecret = if (authType == "oauth" && oauthClientSecret.isNotBlank() && oauthClientSecret != HIDDEN_PLACEHOLDER) { + oauthClientSecret + } else { + null + }, + privacyPolicyUrl = privacyPolicyUrl.ifBlank { null }, + ) + onSave(existingAction?.actionId, metadata, parsedFunctions) + }, + enabled = parsedFunctions.isNotEmpty() && validationErrors.isEmpty() && !isValidating, + ) { + Text(if (isEditing) stringResource(R.string.save) else stringResource(R.string.create)) + } + } + } + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 1. Authentication section + AuthenticationSection( + authType = authType, + onShowAuthConfig = { showAuthConfig = true }, + ) + + HorizontalDivider() + + // 2. Schema input + Text( + text = stringResource(R.string.label_openapi_schema), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.openapi_schema_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = rawSpec, + onValueChange = { rawSpec = it }, + label = { Text(stringResource(R.string.label_openapi_spec)) }, + placeholder = { + Text( + text = "{\n \"openapi\": \"3.0.0\",\n \"info\": { ... },\n \"servers\": [{ \"url\": \"...\" }],\n \"paths\": { ... }\n}", + fontFamily = FontFamily.Monospace, + ) + }, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + minLines = 8, + maxLines = 20, + modifier = Modifier.fillMaxWidth(), + ) + + // Validation feedback + if (isValidating) { + Text( + text = stringResource(R.string.validating), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (validationErrors.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + validationErrors.forEach { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + + // Domain display + if (parsedDomain.isNotBlank()) { + Text( + text = stringResource(R.string.domain_prefix, parsedDomain), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + + // 3. Available actions table + if (parsedFunctionInfos.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.available_actions_count, parsedFunctionInfos.size), + style = MaterialTheme.typography.titleSmall, + ) + FunctionTable(functions = parsedFunctionInfos) + } + + // 4. Privacy policy URL + OutlinedTextField( + value = privacyPolicyUrl, + onValueChange = { privacyPolicyUrl = it }, + label = { Text(stringResource(R.string.label_privacy_policy_url)) }, + placeholder = { Text("https://example.com/privacy") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + // Auth configuration dialog + if (showAuthConfig) { + AuthConfigDialog( + currentAuthType = authType, + apiKey = apiKey, + authorizationType = authorizationType, + customAuthHeader = customAuthHeader, + oauthClientId = oauthClientId, + oauthClientSecret = oauthClientSecret, + authorizationUrl = authorizationUrl, + clientUrl = clientUrl, + scope = scope, + tokenExchangeMethod = tokenExchangeMethod, + isEditing = isEditing, + onDismiss = { showAuthConfig = false }, + onSave = { newAuthType, newApiKey, newAuthorizationType, newCustomHeader, + newOauthClientId, newOauthClientSecret, newAuthUrl, newClientUrl, + newScope, newTokenExchangeMethod -> + authType = newAuthType + apiKey = newApiKey + authorizationType = newAuthorizationType + customAuthHeader = newCustomHeader + oauthClientId = newOauthClientId + oauthClientSecret = newOauthClientSecret + authorizationUrl = newAuthUrl + clientUrl = newClientUrl + scope = newScope + tokenExchangeMethod = newTokenExchangeMethod + showAuthConfig = false + }, + ) + } +} + +// ========================================== +// Authentication Section +// ========================================== + +@Composable +private fun AuthenticationSection( + authType: String, + onShowAuthConfig: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.label_authentication), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onShowAuthConfig, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = when (authType) { + "service_http" -> Icons.Default.Lock + "oauth" -> Icons.Default.Security + else -> Icons.Default.Lock + }, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = when (authType) { + "service_http" -> stringResource(R.string.auth_api_key_full) + "oauth" -> stringResource(R.string.auth_oauth_full) + else -> stringResource(R.string.auth_no_auth) + }, + ) + } + } +} + +// ========================================== +// Auth Configuration Dialog +// ========================================== + +@Composable +private fun AuthConfigDialog( + currentAuthType: String, + apiKey: String, + authorizationType: String, + customAuthHeader: String, + oauthClientId: String, + oauthClientSecret: String, + authorizationUrl: String, + clientUrl: String, + scope: String, + tokenExchangeMethod: String, + isEditing: Boolean, + onDismiss: () -> Unit, + onSave: ( + authType: String, + apiKey: String, + authorizationType: String, + customAuthHeader: String, + oauthClientId: String, + oauthClientSecret: String, + authorizationUrl: String, + clientUrl: String, + scope: String, + tokenExchangeMethod: String, + ) -> Unit, +) { + var selectedAuthType by rememberSaveable { mutableStateOf(currentAuthType) } + var localApiKey by rememberSaveable { mutableStateOf(apiKey) } + var localAuthorizationType by rememberSaveable { mutableStateOf(authorizationType) } + var localCustomHeader by rememberSaveable { mutableStateOf(customAuthHeader) } + var localOauthClientId by rememberSaveable { mutableStateOf(oauthClientId) } + var localOauthClientSecret by rememberSaveable { mutableStateOf(oauthClientSecret) } + var localAuthUrl by rememberSaveable { mutableStateOf(authorizationUrl) } + var localClientUrl by rememberSaveable { mutableStateOf(clientUrl) } + var localScope by rememberSaveable { mutableStateOf(scope) } + var localTokenExchangeMethod by rememberSaveable { mutableStateOf(tokenExchangeMethod) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.label_authentication)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Auth type radio group + Text( + text = stringResource(R.string.label_authentication_type), + style = MaterialTheme.typography.labelLarge, + ) + + AuthTypeRadioOption( + label = stringResource(R.string.auth_none), + selected = selectedAuthType == "none", + onClick = { selectedAuthType = "none" }, + ) + AuthTypeRadioOption( + label = stringResource(R.string.auth_api_key), + selected = selectedAuthType == "service_http", + onClick = { selectedAuthType = "service_http" }, + ) + AuthTypeRadioOption( + label = stringResource(R.string.auth_oauth), + selected = selectedAuthType == "oauth", + onClick = { selectedAuthType = "oauth" }, + ) + + // API Key configuration + AnimatedVisibility(visible = selectedAuthType == "service_http") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider() + Text( + text = stringResource(R.string.label_api_key_settings), + style = MaterialTheme.typography.labelLarge, + ) + + OutlinedTextField( + value = localApiKey, + onValueChange = { localApiKey = it }, + label = { Text(stringResource(R.string.label_api_key)) }, + placeholder = { + Text(if (isEditing) HIDDEN_PLACEHOLDER else "sk-...") + }, + singleLine = true, + visualTransformation = if (localApiKey == HIDDEN_PLACEHOLDER) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringResource(R.string.label_authorization_type), + style = MaterialTheme.typography.labelMedium, + ) + + AuthTypeRadioOption( + label = stringResource(R.string.auth_basic), + selected = localAuthorizationType == "basic", + onClick = { localAuthorizationType = "basic" }, + ) + AuthTypeRadioOption( + label = stringResource(R.string.auth_bearer), + selected = localAuthorizationType == "bearer", + onClick = { localAuthorizationType = "bearer" }, + ) + AuthTypeRadioOption( + label = stringResource(R.string.auth_custom), + selected = localAuthorizationType == "custom", + onClick = { localAuthorizationType = "custom" }, + ) + + AnimatedVisibility(visible = localAuthorizationType == "custom") { + OutlinedTextField( + value = localCustomHeader, + onValueChange = { localCustomHeader = it }, + label = { Text(stringResource(R.string.label_custom_auth_header)) }, + placeholder = { Text("X-Api-Key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + // OAuth configuration + AnimatedVisibility(visible = selectedAuthType == "oauth") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider() + Text( + text = stringResource(R.string.label_oauth_settings), + style = MaterialTheme.typography.labelLarge, + ) + + OutlinedTextField( + value = localOauthClientId, + onValueChange = { localOauthClientId = it }, + label = { Text(stringResource(R.string.label_client_id)) }, + placeholder = { + Text(if (isEditing) HIDDEN_PLACEHOLDER else "client-id") + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = localOauthClientSecret, + onValueChange = { localOauthClientSecret = it }, + label = { Text(stringResource(R.string.label_client_secret)) }, + placeholder = { + Text(if (isEditing) HIDDEN_PLACEHOLDER else "client-secret") + }, + singleLine = true, + visualTransformation = if (localOauthClientSecret == HIDDEN_PLACEHOLDER) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = localAuthUrl, + onValueChange = { localAuthUrl = it }, + label = { Text(stringResource(R.string.label_authorization_url)) }, + placeholder = { Text("https://auth.example.com/authorize") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = localClientUrl, + onValueChange = { localClientUrl = it }, + label = { Text(stringResource(R.string.label_token_url)) }, + placeholder = { Text("https://auth.example.com/token") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = localScope, + onValueChange = { localScope = it }, + label = { Text(stringResource(R.string.label_scope)) }, + placeholder = { Text("openid profile") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringResource(R.string.label_token_exchange_method), + style = MaterialTheme.typography.labelMedium, + ) + + AuthTypeRadioOption( + label = stringResource(R.string.token_default_post), + selected = localTokenExchangeMethod == "default_post", + onClick = { localTokenExchangeMethod = "default_post" }, + ) + AuthTypeRadioOption( + label = stringResource(R.string.token_basic_auth_header), + selected = localTokenExchangeMethod == "basic_auth_header", + onClick = { localTokenExchangeMethod = "basic_auth_header" }, + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onSave( + selectedAuthType, + localApiKey, + localAuthorizationType, + localCustomHeader, + localOauthClientId, + localOauthClientSecret, + localAuthUrl, + localClientUrl, + localScope, + localTokenExchangeMethod, + ) + }, + ) { + Text(stringResource(R.string.apply)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun AuthTypeRadioOption( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = onClick, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +// ========================================== +// Function Table +// ========================================== + +@Composable +private fun FunctionTable( + functions: List, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.table_name), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(180.dp), + ) + Text( + text = stringResource(R.string.table_method), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(60.dp), + ) + Text( + text = stringResource(R.string.table_path), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(200.dp), + ) + } + HorizontalDivider() + + // Rows + functions.forEach { func -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = func.name, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.width(180.dp), + maxLines = 1, + ) + Text( + text = func.method, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(60.dp), + ) + Text( + text = func.path, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.width(200.dp), + maxLines = 1, + ) + } + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt new file mode 100644 index 0000000..af86be1 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt @@ -0,0 +1,176 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +data class AgentAdvancedSettings( + val temperature: Float? = null, + val topP: Float? = null, + val maxTokens: Int? = null, +) + +@Composable +fun AgentAdvancedPanel( + settings: AgentAdvancedSettings, + onSettingsChanged: (AgentAdvancedSettings) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.advanced_settings), + style = MaterialTheme.typography.titleSmall, + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Temperature slider (0-2) + AdvancedSlider( + label = stringResource(R.string.label_temperature), + value = settings.temperature ?: 1.0f, + onValueChange = { + onSettingsChanged(settings.copy(temperature = roundToStep(it, 0.1f))) + }, + valueRange = 0f..2f, + steps = 19, + description = stringResource(R.string.temperature_description), + ) + + // Top P slider (0-1) + AdvancedSlider( + label = stringResource(R.string.label_top_p), + value = settings.topP ?: 1.0f, + onValueChange = { + onSettingsChanged(settings.copy(topP = roundToStep(it, 0.05f))) + }, + valueRange = 0f..1f, + steps = 19, + description = stringResource(R.string.top_p_description), + ) + + // Max tokens input + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.label_max_tokens), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.max_tokens_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = settings.maxTokens?.toString() ?: "", + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() } + onSettingsChanged( + settings.copy(maxTokens = filtered.toIntOrNull()), + ) + }, + placeholder = { Text(stringResource(R.string.default_value)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun AdvancedSlider( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + steps: Int, + description: String, + modifier: Modifier = Modifier, +) { + var sliderValue by remember(value) { mutableFloatStateOf(value) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "%.2f".format(sliderValue), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = sliderValue, + onValueChange = { + sliderValue = it + onValueChange(it) + }, + valueRange = valueRange, + steps = steps, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +private fun roundToStep(value: Float, step: Float): Float { + return (value / step).roundToInt() * step +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt new file mode 100644 index 0000000..be7d872 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt @@ -0,0 +1,92 @@ +package com.librechat.android.feature.agents.components + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@Composable +fun AgentAvatarPicker( + avatarUrl: String?, + agentName: String, + onImageSelected: (Uri) -> Unit, + modifier: Modifier = Modifier, +) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + if (uri != null) { + onImageSelected(uri) + } + } + + val avatarCd = stringResource(R.string.cd_agent_avatar_tap) + Box( + modifier = modifier + .size(80.dp) + .clip(CircleShape) + .clickable { launcher.launch("image/*") } + .semantics { contentDescription = avatarCd }, + contentAlignment = Alignment.Center, + ) { + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = stringResource(R.string.cd_agent_avatar_name, agentName), + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = agentName.take(1).uppercase().ifEmpty { "?" }, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + // Edit overlay + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt new file mode 100644 index 0000000..ada2efd --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt @@ -0,0 +1,131 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +data class AgentCapabilities( + val artifacts: Boolean = false, + val endAfterTools: Boolean = false, + val hideSequentialOutputs: Boolean = false, + val recursionLimit: Int = 25, +) + +@Composable +fun AgentCapabilitiesSection( + capabilities: AgentCapabilities, + onCapabilitiesChanged: (AgentCapabilities) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.label_capabilities), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + CapabilityToggle( + label = stringResource(R.string.label_artifacts), + description = stringResource(R.string.artifacts_description), + checked = capabilities.artifacts, + onCheckedChange = { + onCapabilitiesChanged(capabilities.copy(artifacts = it)) + }, + ) + + CapabilityToggle( + label = stringResource(R.string.label_end_after_tools), + description = stringResource(R.string.end_after_tools_description), + checked = capabilities.endAfterTools, + onCheckedChange = { + onCapabilitiesChanged(capabilities.copy(endAfterTools = it)) + }, + ) + + CapabilityToggle( + label = stringResource(R.string.label_hide_sequential_outputs), + description = stringResource(R.string.hide_sequential_description), + checked = capabilities.hideSequentialOutputs, + onCheckedChange = { + onCapabilitiesChanged(capabilities.copy(hideSequentialOutputs = it)) + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.label_recursion_limit), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.recursion_limit_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = capabilities.recursionLimit.toString(), + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() } + val intVal = filtered.toIntOrNull() ?: 0 + onCapabilitiesChanged( + capabilities.copy(recursionLimit = intVal.coerceIn(1, 100)), + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun CapabilityToggle( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt new file mode 100644 index 0000000..7d7fe0a --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt @@ -0,0 +1,99 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.librechat.android.core.model.AgentCategory +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +/** + * Maps category values to human-readable English labels. + * The server returns localization keys like "com_ui_idea" in the label field. + * Since we don't have a localization system, we provide English labels inline. + */ +private val CATEGORY_DISPLAY_LABELS = mapOf( + "idea" to "Ideas", + "travel" to "Travel", + "teach_or_explain" to "Learning", + "write" to "Writing", + "shop" to "Shopping", + "code" to "Code", + "misc" to "Misc.", + "roleplay" to "Roleplay", + "finance" to "Finance", + "general" to "General", +) + +private fun AgentCategory.displayLabel(): String { + // If the label starts with "com_", it's a localization key -- look up the friendly name + val rawLabel = label + if (rawLabel != null && !rawLabel.startsWith("com_")) { + return rawLabel + } + return CATEGORY_DISPLAY_LABELS[value] ?: value.replaceFirstChar { it.uppercase() } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentCategorySelector( + selectedCategory: String, + categories: List, + onCategorySelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + // Filter out special categories (promoted, all) used only in the marketplace + val editorCategories = remember(categories) { + categories.filter { it.value != "promoted" && it.value != "all" } + } + + val displayValue = editorCategories.find { it.value == selectedCategory }?.displayLabel() + ?: CATEGORY_DISPLAY_LABELS[selectedCategory] + ?: selectedCategory.ifEmpty { stringResource(R.string.general) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = displayValue, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.label_category)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + editorCategories.forEach { category -> + DropdownMenuItem( + text = { Text(category.displayLabel()) }, + onClick = { + onCategorySelected(category.value) + expanded = false + }, + ) + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt new file mode 100644 index 0000000..29b12ff --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt @@ -0,0 +1,56 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@Composable +fun AgentCodeInterpreterSection( + enabled: Boolean, + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.label_code_interpreter), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.code_interpreter_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = enabled, + onCheckedChange = onToggle, + ) + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt new file mode 100644 index 0000000..03355a2 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt @@ -0,0 +1,56 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@Composable +fun AgentFileSearchSection( + enabled: Boolean, + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.label_file_search), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.file_search_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = enabled, + onCheckedChange = onToggle, + ) + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt new file mode 100644 index 0000000..10e07bb --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt @@ -0,0 +1,209 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.AgentHandoffDisplayData +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@Composable +fun AgentHandoffConfig( + handoffAgentIds: List, + availableAgents: List, + onAddHandoff: (agentId: String) -> Unit, + onRemoveHandoff: (agentId: String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + var showAddDialog by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.handoff_agents_count, handoffAgentIds.size), + style = MaterialTheme.typography.titleSmall, + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.handoff_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (handoffAgentIds.isEmpty()) { + Text( + text = stringResource(R.string.no_handoff_agents), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + + handoffAgentIds.forEach { agentId -> + val agentDisplay = availableAgents.find { it.id == agentId } + val displayName = agentDisplay?.name ?: agentId + InputChip( + selected = false, + onClick = {}, + label = { Text(displayName) }, + trailingIcon = { + IconButton(onClick = { onRemoveHandoff(agentId) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_remove_item, displayName), + ) + } + }, + ) + } + + OutlinedButton( + onClick = { showAddDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.add_handoff_agent)) + } + } + } + } + + if (showAddDialog) { + AddHandoffAgentDialog( + availableAgents = availableAgents.filter { it.id !in handoffAgentIds }, + onDismiss = { showAddDialog = false }, + onSelect = { agentId -> + onAddHandoff(agentId) + showAddDialog = false + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddHandoffAgentDialog( + availableAgents: List, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { + var dropdownExpanded by remember { mutableStateOf(false) } + var selectedAgent by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_handoff_agent)) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + if (availableAgents.isEmpty()) { + Text( + text = stringResource(R.string.no_more_agents), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + ExposedDropdownMenuBox( + expanded = dropdownExpanded, + onExpandedChange = { dropdownExpanded = it }, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = selectedAgent?.name ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.select_agent)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false }, + ) { + availableAgents.forEach { agent -> + DropdownMenuItem( + text = { Text(agent.name) }, + onClick = { + selectedAgent = agent + dropdownExpanded = false + }, + ) + } + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { selectedAgent?.let { onSelect(it.id) } }, + enabled = selectedAgent != null, + ) { + Text(stringResource(R.string.add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt new file mode 100644 index 0000000..26a2a06 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt @@ -0,0 +1,171 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.mcp.McpTool +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +/** Hierarchical MCP tool picker grouped by server with checkbox selection. */ +@Composable +fun AgentMcpToolsSelector( + mcpTools: List, + selectedToolNames: Set, + onToolToggled: (toolName: String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.mcp_tools_count, selectedToolNames.size), + style = MaterialTheme.typography.titleSmall, + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + ) + } + + val unknownServerLabel = stringResource(R.string.unknown_server) + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (mcpTools.isEmpty()) { + Text( + text = stringResource(R.string.no_mcp_tools), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + val grouped = remember(mcpTools) { + mcpTools.groupBy { it.serverName ?: unknownServerLabel } + } + + grouped.forEach { (serverName, tools) -> + McpServerToolsGroup( + serverName = serverName, + tools = tools, + selectedToolNames = selectedToolNames, + onToolToggled = onToolToggled, + ) + } + } + } + } + } +} + +@Composable +private fun McpServerToolsGroup( + serverName: String, + tools: List, + selectedToolNames: Set, + onToolToggled: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var serverExpanded by remember { mutableStateOf(true) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { serverExpanded = !serverExpanded } + .padding(vertical = 4.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = serverName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Icon( + imageVector = if (serverExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (serverExpanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + ) + } + + AnimatedVisibility(visible = serverExpanded) { + Column(modifier = Modifier.padding(start = 8.dp)) { + tools.forEach { tool -> + McpToolRow( + tool = tool, + isSelected = tool.name in selectedToolNames, + onToggle = { onToolToggled(tool.name) }, + ) + } + } + } + } +} + +@Composable +private fun McpToolRow( + tool: McpTool, + isSelected: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 2.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggle() }, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = tool.name, + style = MaterialTheme.typography.bodyMedium, + ) + val desc = tool.description + if (desc != null) { + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt new file mode 100644 index 0000000..88a64b6 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt @@ -0,0 +1,119 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +data class ModelOption( + val id: String, + val name: String, + val endpoint: String, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentModelPicker( + selectedModel: String, + availableModels: List, + onModelSelected: (modelId: String, provider: String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + + val filteredModels = remember(searchQuery, availableModels) { + if (searchQuery.isBlank()) { + availableModels + } else { + availableModels.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.id.contains(searchQuery, ignoreCase = true) + } + } + } + + val groupedModels = remember(filteredModels) { + filteredModels.groupBy { it.endpoint } + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = if (expanded) searchQuery else selectedModel.ifEmpty { "" }, + onValueChange = { searchQuery = it }, + label = { Text(stringResource(R.string.model_required_label)) }, + placeholder = { Text(stringResource(R.string.select_model_hint)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryEditable), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + searchQuery = "" + }, + modifier = Modifier.heightIn(max = 300.dp), + ) { + if (groupedModels.isEmpty()) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.no_models_found), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = {}, + enabled = false, + ) + } else { + groupedModels.forEach { (endpoint, models) -> + DropdownMenuItem( + text = { + Text( + text = endpoint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + }, + onClick = {}, + enabled = false, + ) + models.forEach { model -> + DropdownMenuItem( + text = { Text(model.name) }, + onClick = { + onModelSelected(model.id, model.endpoint) + expanded = false + searchQuery = "" + }, + ) + } + } + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt new file mode 100644 index 0000000..291e094 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt @@ -0,0 +1,148 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +enum class AgentVisibility(val label: String) { + PRIVATE("Private"), + TEAM("Team"), + PUBLIC("Public"), +} + +@androidx.compose.runtime.Immutable +data class AgentSharingState( + val visibility: AgentVisibility = AgentVisibility.PRIVATE, + val isCollaborative: Boolean = false, +) + +/** Visibility (Private/Team/Public) and collaborative toggle; maps to agent model isPublic/isCollaborative. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentSharingSection( + sharingState: AgentSharingState, + onSharingChanged: (AgentSharingState) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.sharing_and_permissions), + style = MaterialTheme.typography.titleSmall, + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Visibility dropdown + var dropdownExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = dropdownExpanded, + onExpandedChange = { dropdownExpanded = it }, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = sharingState.visibility.label, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.label_visibility)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false }, + ) { + AgentVisibility.entries.forEach { visibility -> + DropdownMenuItem( + text = { Text(visibility.label) }, + onClick = { + onSharingChanged(sharingState.copy(visibility = visibility)) + dropdownExpanded = false + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Collaborative toggle + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.label_collaborative), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.collaborative_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = sharingState.isCollaborative, + onCheckedChange = { + onSharingChanged(sharingState.copy(isCollaborative = it)) + }, + ) + } + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt new file mode 100644 index 0000000..eddc3a5 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt @@ -0,0 +1,69 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@androidx.compose.runtime.Immutable +data class SupportContactState( + val name: String = "", + val email: String = "", +) + +@Composable +fun AgentSupportContactSection( + supportContact: SupportContactState, + onSupportContactChanged: (SupportContactState) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.label_support_contact), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Name field + Text( + text = stringResource(R.string.label_name), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = supportContact.name, + onValueChange = { + onSupportContactChanged(supportContact.copy(name = it)) + }, + placeholder = { Text(stringResource(R.string.support_contact_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Email field + Text( + text = stringResource(R.string.label_email), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = supportContact.email, + onValueChange = { + onSupportContactChanged(supportContact.copy(email = it)) + }, + placeholder = { Text("support@example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt new file mode 100644 index 0000000..0b2235e --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt @@ -0,0 +1,125 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +data class AgentVersion( + val version: Int, + val updatedAt: String?, + val isCurrent: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentVersionHistory( + versions: List, + onRevert: (version: Int) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .navigationBarsPadding(), + ) { + Text( + text = stringResource(R.string.version_history), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (versions.isEmpty()) { + Text( + text = stringResource(R.string.no_version_history), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + itemsIndexed(versions) { _, version -> + VersionItem( + version = version, + onRevert = { onRevert(version.version) }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun VersionItem( + version: AgentVersion, + onRevert: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.version_number, version.version), + style = MaterialTheme.typography.bodyLarge, + ) + val timestamp = version.updatedAt + if (timestamp != null) { + Text( + text = timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (version.isCurrent) { + Text( + text = stringResource(R.string.version_current), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + if (!version.isCurrent) { + Button(onClick = onRevert) { + Text(stringResource(R.string.revert)) + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt new file mode 100644 index 0000000..6eb6988 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt @@ -0,0 +1,293 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import com.librechat.android.feature.agents.AgentToolDisplayData +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +/** + * Full-screen dialog for selecting agent tools, matching the web app's ToolSelectDialog. + * Shows tools in a grid with icon, name, description, and add/remove button. + */ +@Composable +fun ToolSelectDialog( + tools: List, + selectedToolIds: List, + onToolAdded: (String) -> Unit, + onToolRemoved: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var searchQuery by remember { mutableStateOf("") } + + val filteredTools = remember(searchQuery, tools) { + if (searchQuery.isBlank()) { + tools + } else { + tools.filter { tool -> + val name = tool.name ?: tool.toolId ?: "" + name.contains(searchQuery, ignoreCase = true) || + (tool.description ?: "").contains(searchQuery, ignoreCase = true) + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(R.string.agent_tools_title), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.select_tools_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + ) + } + } + + // Search bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text(stringResource(R.string.search_tools_hint)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Tools grid + if (filteredTools.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (searchQuery.isNotBlank()) { + stringResource(R.string.no_tools_matching, searchQuery) + } else { + stringResource(R.string.no_tools_available) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 280.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = filteredTools, + key = { it.toolId ?: it.name ?: it.hashCode().toString() }, + contentType = { "agent_tool" }, + ) { tool -> + val toolId = tool.toolId ?: tool.name ?: return@items + val isInstalled = toolId in selectedToolIds + ToolCard( + tool = tool, + isInstalled = isInstalled, + onToggle = { + if (isInstalled) { + onToolRemoved(toolId) + } else { + onToolAdded(toolId) + } + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun ToolCard( + tool: AgentToolDisplayData, + isInstalled: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Tool icon + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + val iconUrl = tool.icon + if (iconUrl != null) { + AsyncImage( + model = iconUrl, + contentDescription = stringResource(R.string.cd_tool_icon, tool.name ?: ""), + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Default.Build, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Name and button + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = tool.name ?: tool.toolId ?: stringResource(R.string.unknown_author), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (isInstalled) { + OutlinedButton( + onClick = onToggle, + ) { + Text(stringResource(R.string.remove)) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Remove, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + FilledTonalButton( + onClick = onToggle, + ) { + Text(stringResource(R.string.add)) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + + // Description + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = tool.description ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.height(48.dp), + ) + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt new file mode 100644 index 0000000..46498d6 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt @@ -0,0 +1,68 @@ +package com.librechat.android.feature.agents.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.agents.screen.AgentDetailScreen +import com.librechat.android.feature.agents.screen.AgentEditorScreen +import com.librechat.android.feature.agents.screen.AgentMarketplaceScreen + +const val AGENTS_ROUTE = "agents" +const val AGENT_DETAIL_ROUTE = "agents/{agentId}" +const val AGENT_EDITOR_CREATE_ROUTE = "agents/editor/create" +const val AGENT_EDITOR_EDIT_ROUTE = "agents/editor/{agentId}" + +fun NavGraphBuilder.agentsGraph( + onAgentClick: (String) -> Unit, + onBack: () -> Unit, + onStartChat: (String) -> Unit, + onCreateAgent: () -> Unit, + onEditAgent: (String) -> Unit, +) { + composable(AGENTS_ROUTE) { + ScreenTransitionWrapper(transition) { + AgentMarketplaceScreen( + onAgentClick = onAgentClick, + onCreateAgent = onCreateAgent, + ) + } + } + composable( + route = AGENT_DETAIL_ROUTE, + arguments = listOf( + navArgument("agentId") { type = NavType.StringType }, + ), + ) { + ScreenTransitionWrapper(transition) { + AgentDetailScreen( + onBack = onBack, + onStartChat = onStartChat, + onEdit = onEditAgent, + onDuplicated = onAgentClick, + ) + } + } + composable(AGENT_EDITOR_CREATE_ROUTE) { + ScreenTransitionWrapper(transition) { + AgentEditorScreen( + onBack = onBack, + onSaved = { agentId -> onAgentClick(agentId) }, + ) + } + } + composable( + route = AGENT_EDITOR_EDIT_ROUTE, + arguments = listOf( + navArgument("agentId") { type = NavType.StringType }, + ), + ) { + ScreenTransitionWrapper(transition) { + AgentEditorScreen( + onBack = onBack, + onSaved = { agentId -> onAgentClick(agentId) }, + ) + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt new file mode 100644 index 0000000..0b78f03 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt @@ -0,0 +1,362 @@ +package com.librechat.android.feature.agents.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.ui.R as CoreUiR +import com.librechat.android.core.ui.components.AvatarImage +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.agents.AgentDetailDisplayData +import com.librechat.android.feature.agents.viewmodel.AgentDetailEvent +import com.librechat.android.feature.agents.viewmodel.AgentDetailViewModel +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun AgentDetailScreen( + onBack: () -> Unit, + onStartChat: (String) -> Unit, + onEdit: (String) -> Unit, + onDuplicated: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: AgentDetailViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is AgentDetailEvent.Deleted -> onBack() + is AgentDetailEvent.Duplicated -> onDuplicated(event.agentId) + } + } + } + + // Delete confirmation dialog + if (uiState.showDeleteDialog) { + AlertDialog( + onDismissRequest = { viewModel.dismissDeleteConfirmation() }, + title = { Text(stringResource(R.string.delete_agent)) }, + text = { + Text(stringResource(R.string.delete_agent_confirm, uiState.agent?.name ?: stringResource(R.string.delete_this_agent))) + }, + confirmButton = { + TextButton( + onClick = { viewModel.deleteAgent() }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissDeleteConfirmation() }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(uiState.agent?.name ?: stringResource(R.string.agent_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + actions = { + if (uiState.agent != null) { + var menuExpanded by remember { mutableStateOf(false) } + IconButton(onClick = { menuExpanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more_actions), + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit)) }, + onClick = { + menuExpanded = false + uiState.agent?.let { onEdit(it.id) } + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.duplicate)) }, + onClick = { + menuExpanded = false + viewModel.duplicateAgent() + }, + enabled = !uiState.isDuplicating, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + ) + }, + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + viewModel.showDeleteConfirmation() + }, + enabled = !uiState.isDeleting, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { innerPadding -> + when { + uiState.isLoading -> { + LoadingIndicator(modifier = Modifier.padding(innerPadding)) + } + + uiState.error != null -> { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.error_unknown), + modifier = Modifier.padding(innerPadding), + onRetry = { viewModel.loadAgent() }, + ) + } + + uiState.agent != null -> { + val agent = uiState.agent!! + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AvatarImage( + imageUrl = agent.avatarUrl, + size = 80.dp, + fallbackText = agent.name, + fallbackIconRes = CoreUiR.drawable.ic_agents, + tintIcon = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = agent.name, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.by_author, agent.authorName ?: agent.author ?: stringResource(R.string.unknown_author)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { onStartChat(agent.id) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Chat, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(stringResource(R.string.start_chat)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + HorizontalDivider() + + // Description section + AgentDetailSection( + label = stringResource(R.string.label_description), + content = agent.description?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.no_description), + dimContent = agent.description.isNullOrBlank(), + ) + HorizontalDivider() + + // Category section + val category = agent.category + if (!category.isNullOrBlank() && category != "general") { + AgentDetailSection( + label = stringResource(R.string.label_category), + content = category.replaceFirstChar { it.uppercase() }, + ) + HorizontalDivider() + } + + // Model section + AgentDetailSection( + label = stringResource(R.string.label_model), + content = agent.model ?: stringResource(R.string.default_value), + ) + HorizontalDivider() + + // Tools section + val tools = agent.tools + if (!tools.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.label_tools), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + tools.forEach { tool -> + AssistChip( + onClick = {}, + label = { Text(tool) }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + } + + // Conversation Starters section + if (agent.conversationStarters.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.label_conversation_starters), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + agent.conversationStarters.forEach { starter -> + Text( + text = "- $starter", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } +} + +@Composable +private fun AgentDetailSection( + label: String, + content: String, + modifier: Modifier = Modifier, + dimContent: Boolean = false, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = content, + style = MaterialTheme.typography.bodyLarge, + color = if (dimContent) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + ) + } +} + diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt new file mode 100644 index 0000000..5fd6ebe --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt @@ -0,0 +1,622 @@ +package com.librechat.android.feature.agents.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.agents.components.AgentActionsPanel +import com.librechat.android.feature.agents.components.AgentAdvancedPanel +import com.librechat.android.feature.agents.components.AgentAvatarPicker +import com.librechat.android.feature.agents.components.AgentCategorySelector +import com.librechat.android.feature.agents.components.AgentCodeInterpreterSection +import com.librechat.android.feature.agents.components.AgentFileSearchSection +import com.librechat.android.feature.agents.components.AgentHandoffConfig +import com.librechat.android.feature.agents.components.AgentMcpToolsSelector +import com.librechat.android.feature.agents.components.AgentModelPicker +import com.librechat.android.feature.agents.components.AgentSharingSection +import com.librechat.android.feature.agents.components.AgentSupportContactSection +import com.librechat.android.feature.agents.components.AgentVersionHistory +import com.librechat.android.feature.agents.components.ToolSelectDialog +import com.librechat.android.feature.agents.viewmodel.AgentEditorEvent +import com.librechat.android.feature.agents.viewmodel.AgentEditorViewModel +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentEditorScreen( + onBack: () -> Unit, + onSaved: (String) -> Unit, + modifier: Modifier = Modifier, + onDeleted: () -> Unit = onBack, + onDuplicated: (String) -> Unit = onSaved, + viewModel: AgentEditorViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showToolDialog by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is AgentEditorEvent.SaveSuccess -> onSaved(event.agentId) + is AgentEditorEvent.DuplicateSuccess -> onDuplicated(event.agentId) + is AgentEditorEvent.DeleteSuccess -> onDeleted() + } + } + } + + // Delete confirmation dialog + if (uiState.showDeleteConfirm) { + AlertDialog( + onDismissRequest = viewModel::dismissDeleteConfirmation, + title = { Text(stringResource(R.string.delete_agent)) }, + text = { Text(stringResource(R.string.delete_agent_editor_confirm)) }, + confirmButton = { + TextButton(onClick = viewModel::delete) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = viewModel::dismissDeleteConfirmation) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // Duplicate confirmation dialog + if (uiState.showDuplicateConfirm) { + AlertDialog( + onDismissRequest = viewModel::dismissDuplicateConfirmation, + title = { Text(stringResource(R.string.duplicate_agent)) }, + text = { Text(stringResource(R.string.duplicate_agent_confirm)) }, + confirmButton = { + TextButton(onClick = viewModel::duplicate) { + Text(stringResource(R.string.duplicate)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::dismissDuplicateConfirmation) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // Version history sheet + if (uiState.showVersionHistory) { + AgentVersionHistory( + versions = uiState.versions, + onRevert = viewModel::revertToVersion, + onDismiss = viewModel::dismissVersionHistory, + ) + } + + // Tool selection dialog + if (showToolDialog) { + ToolSelectDialog( + tools = uiState.availableTools, + selectedToolIds = uiState.selectedTools, + onToolAdded = viewModel::onToolAdded, + onToolRemoved = viewModel::onToolRemoved, + onDismiss = { showToolDialog = false }, + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(if (uiState.isEditMode) stringResource(R.string.edit_agent) else stringResource(R.string.create_agent)) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + actions = { + if (uiState.isEditMode) { + var menuExpanded by remember { mutableStateOf(false) } + IconButton(onClick = { menuExpanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more_options), + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.duplicate)) }, + onClick = { + menuExpanded = false + viewModel.showDuplicateConfirmation() + }, + leadingIcon = { + Icon( + Icons.Default.ContentCopy, + contentDescription = null, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.version_history)) }, + onClick = { + menuExpanded = false + viewModel.showVersionHistory() + }, + leadingIcon = { + Icon( + Icons.Default.History, + contentDescription = null, + ) + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Text( + stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + viewModel.showDeleteConfirmation() + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { innerPadding -> + when { + uiState.isLoading -> { + LoadingIndicator(modifier = Modifier.padding(innerPadding)) + } + + else -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + if (uiState.error != null) { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.error_unknown), + onRetry = { viewModel.dismissError() }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // ========================================== + // FIELD ORDER MATCHING WEB APP (AgentConfig): + // 1. Avatar + // 2. Name * + // 3. Description + // 4. Category * + // 5. Instructions + // 6. Model * + // 7. Capabilities (Code, Web Search, File Search) + // 8. MCP Tools + // 9. Tools & Actions + // 10. Support Contact + // 11. Conversation Starters (Android-specific, useful) + // 12. Advanced (collapsed) + // ========================================== + + // 1. Avatar picker at top center + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AgentAvatarPicker( + avatarUrl = uiState.avatarUrl, + agentName = uiState.name, + onImageSelected = viewModel::uploadAvatar, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.tap_to_change_avatar), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 2. Name field (required) + OutlinedTextField( + value = uiState.name, + onValueChange = viewModel::onNameChanged, + label = { + Text(stringResource(R.string.agent_name_label)) + }, + placeholder = { Text(stringResource(R.string.agent_name_placeholder)) }, + isError = uiState.nameError != null, + supportingText = uiState.nameError?.let { error -> + { Text(error) } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 3. Description field + OutlinedTextField( + value = uiState.description, + onValueChange = viewModel::onDescriptionChanged, + label = { Text(stringResource(R.string.agent_description_label)) }, + placeholder = { Text(stringResource(R.string.agent_description_placeholder)) }, + minLines = 2, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 4. Category selector (required, defaults to "general") + AgentCategorySelector( + selectedCategory = uiState.category, + categories = uiState.categories, + onCategorySelected = viewModel::onCategoryChanged, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 5. Instructions field (multi-line) + OutlinedTextField( + value = uiState.instructions, + onValueChange = viewModel::onInstructionsChanged, + label = { Text(stringResource(R.string.agent_instructions_label)) }, + placeholder = { Text(stringResource(R.string.agent_instructions_placeholder)) }, + minLines = 4, + maxLines = 10, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 6. Model picker (required) + AgentModelPicker( + selectedModel = uiState.model, + availableModels = uiState.availableModels, + onModelSelected = viewModel::onModelSelected, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 7. Capabilities section + Text( + text = stringResource(R.string.label_capabilities), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Code Interpreter toggle (only shown when the server supports it) + if (uiState.isCodeInterpreterAvailable) { + AgentCodeInterpreterSection( + enabled = uiState.codeInterpreterEnabled, + onToggle = viewModel::onCodeInterpreterToggled, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + // File Search toggle + AgentFileSearchSection( + enabled = uiState.fileSearchEnabled, + onToggle = viewModel::onFileSearchToggled, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 8. MCP Tools selector + if (uiState.mcpTools.isNotEmpty()) { + AgentMcpToolsSelector( + mcpTools = uiState.mcpTools, + selectedToolNames = uiState.selectedMcpTools, + onToolToggled = viewModel::onMcpToolToggled, + ) + + Spacer(modifier = Modifier.height(12.dp)) + } + + // 9. Tools & Actions section + Text( + text = stringResource(R.string.label_tools_and_actions), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Show selected tools as removable items with icon + if (uiState.selectedTools.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + uiState.selectedTools.forEach { toolId -> + val toolData = uiState.availableTools.find { + (it.toolId ?: it.name) == toolId + } + SelectedToolRow( + toolName = toolData?.name ?: toolId, + toolDescription = toolData?.description, + onRemove = { viewModel.onToolRemoved(toolId) }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Show selected actions + if (uiState.actions.isNotEmpty()) { + uiState.actions.forEach { action -> + val actionId = action.actionId + if (actionId != null) { + val authLabel = when (action.authType) { + "service_http" -> stringResource(R.string.auth_api_key) + "oauth" -> stringResource(R.string.auth_oauth) + else -> stringResource(R.string.auth_none) + } + SelectedToolRow( + toolName = action.domain ?: stringResource(R.string.label_action), + toolDescription = stringResource(R.string.action_auth_info, authLabel, action.functionCount), + onRemove = { viewModel.deleteAction(actionId) }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Add Tools button + if (uiState.availableTools.isNotEmpty()) { + OutlinedButton( + onClick = { showToolDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.add_tools)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Actions panel (collapsible with full editor) + AgentActionsPanel( + actions = uiState.actions, + onSaveAction = viewModel::saveAction, + onDeleteAction = viewModel::deleteAction, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 10. Support Contact + AgentSupportContactSection( + supportContact = uiState.supportContact, + onSupportContactChanged = viewModel::onSupportContactChanged, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 11. Conversation starters + Text( + text = stringResource(R.string.label_conversation_starters), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + uiState.conversationStarters.forEachIndexed { index, starter -> + InputChip( + selected = false, + onClick = {}, + label = { Text(starter) }, + trailingIcon = { + IconButton( + onClick = { viewModel.onConversationStarterRemoved(index) }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.remove), + ) + } + }, + ) + } + + var newStarter by rememberSaveable { mutableStateOf("") } + Row( + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = newStarter, + onValueChange = { newStarter = it }, + placeholder = { Text(stringResource(R.string.add_starter_hint)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + viewModel.onConversationStarterAdded(newStarter) + newStarter = "" + }, + enabled = newStarter.isNotBlank(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_add_starter), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Sharing & Permissions + AgentSharingSection( + sharingState = uiState.sharingState, + onSharingChanged = viewModel::onSharingChanged, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Handoff configuration + AgentHandoffConfig( + handoffAgentIds = uiState.handoffAgentIds, + availableAgents = uiState.allAgents, + onAddHandoff = viewModel::addHandoffAgent, + onRemoveHandoff = viewModel::removeHandoffAgent, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 12. Advanced settings (collapsible) + AgentAdvancedPanel( + settings = uiState.advancedSettings, + onSettingsChanged = viewModel::onAdvancedSettingsChanged, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Save button + Button( + onClick = { viewModel.save() }, + enabled = !uiState.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + if (uiState.isSaving) { + CircularProgressIndicator( + modifier = Modifier + .height(20.dp) + .width(20.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + if (uiState.isEditMode) stringResource(R.string.save_changes) else stringResource(R.string.create_agent), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } +} + +/** + * Row showing a selected tool/action with its name, optional description, and a remove button. + */ +@Composable +private fun SelectedToolRow( + toolName: String, + toolDescription: String?, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = toolName, + style = MaterialTheme.typography.bodyMedium, + ) + if (!toolDescription.isNullOrBlank()) { + Text( + text = toolDescription, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_remove_item, toolName), + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt new file mode 100644 index 0000000..76c08b9 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt @@ -0,0 +1,283 @@ +package com.librechat.android.feature.agents.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.ui.R as CoreUiR +import com.librechat.android.core.ui.components.AvatarImage +import com.librechat.android.core.ui.components.EmptyState +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LibreChatTopBar +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.agents.AgentCardDisplayData +import com.librechat.android.feature.agents.viewmodel.AgentMarketplaceViewModel +import com.librechat.android.feature.agents.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentMarketplaceScreen( + onAgentClick: (String) -> Unit, + onCreateAgent: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AgentMarketplaceViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val gridState = rememberLazyGridState() + + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = gridState.layoutInfo + val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = layoutInfo.totalItemsCount + lastVisibleItem >= totalItems - 3 && + uiState.hasMore && + !uiState.isLoadingMore && + !uiState.isLoading + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + viewModel.loadMore() + } + } + + Scaffold( + modifier = modifier, + topBar = { + LibreChatTopBar(title = stringResource(R.string.agents)) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onCreateAgent, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_create_agent), + ) + } + }, + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Search bar + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = viewModel::onSearchQueryChanged, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(R.string.search_agents_hint)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.cd_search), + ) + }, + singleLine = true, + ) + + // Category chips + if (uiState.categories.isNotEmpty()) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = uiState.categories, + key = { it }, + contentType = { "category" }, + ) { category -> + FilterChip( + selected = uiState.selectedCategory == category, + onClick = { viewModel.onCategorySelected(category) }, + label = { Text(category.replaceFirstChar { it.uppercase() }) }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + when { + uiState.isLoading && uiState.agents.isEmpty() -> { + LoadingIndicator() + } + + !uiState.isLoading && uiState.filteredAgents.isEmpty() && uiState.error == null -> { + EmptyState( + title = if (uiState.searchQuery.isNotBlank() || uiState.selectedCategory != null) { + stringResource(R.string.no_agents_found) + } else { + stringResource(R.string.no_agents_available) + }, + description = if (uiState.searchQuery.isNotBlank()) { + stringResource(R.string.try_different_search) + } else { + stringResource(R.string.check_back_later) + }, + icon = Icons.Default.SmartToy, + ) + } + + else -> { + if (uiState.error != null) { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.error_unknown), + onRetry = { + viewModel.dismissError() + viewModel.loadAgents() + }, + ) + } + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + bottom = 80.dp, + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize(), + ) { + items( + items = uiState.filteredAgents, + key = { it.id }, + contentType = { "agent" }, + ) { agent -> + AgentCard( + agent = agent, + onClick = { onAgentClick(agent.id) }, + ) + } + + if (uiState.isLoadingMore) { + item( + span = { GridItemSpan(maxLineSpan) }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun AgentCard( + agent: AgentCardDisplayData, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AvatarImage( + imageUrl = agent.avatarUrl, + size = 40.dp, + fallbackText = agent.name, + fallbackIconRes = CoreUiR.drawable.ic_agents, + tintIcon = true, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = agent.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val description = agent.description + if (description != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = agent.authorName ?: agent.author ?: stringResource(R.string.unknown_author), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt new file mode 100644 index 0000000..2500ac3 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt @@ -0,0 +1,411 @@ +package com.librechat.android.feature.agents.util + +import com.librechat.android.core.model.request.FunctionDefinition +import com.librechat.android.core.model.request.FunctionTool +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.snakeyaml.engine.v2.api.Load +import org.snakeyaml.engine.v2.api.LoadSettings + +/** + * Result of parsing an OpenAPI spec. + */ +data class OpenApiParseResult( + val domain: String, + val functions: List, + val errors: List = emptyList(), +) + +/** + * Represents a parsed function for display in the available actions table. + */ +data class ParsedFunctionInfo( + val name: String, + val method: String, + val path: String, + val description: String, +) + +/** + * Parses an OpenAPI specification and extracts function definitions. + * + * Supports both JSON and YAML formats. The parser first attempts JSON parsing, + * and falls back to YAML if JSON fails. This matches the behavior of the + * official LibreChat web frontend which uses js-yaml as a fallback. + * + * Supports OpenAPI 3.0+ and Swagger 2.0 specs. + * The parser extracts servers[0].url as the domain, then iterates + * over paths to build FunctionTool objects from each operation. + */ +object OpenApiSpecParser { + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + /** + * Attempts to parse the given spec string as an OpenAPI document. + * Tries JSON first, then falls back to YAML. + * + * @param spec The raw OpenAPI specification text (JSON or YAML format). + * @return [OpenApiParseResult] containing the domain, extracted functions, and any errors. + */ + fun parse(spec: String): OpenApiParseResult { + if (spec.isBlank()) { + return OpenApiParseResult( + domain = "", + functions = emptyList(), + errors = listOf("Specification is empty"), + ) + } + + val root: JsonObject = try { + parseSpecToJsonObject(spec) + } catch (e: Exception) { + return OpenApiParseResult( + domain = "", + functions = emptyList(), + errors = listOf("Failed to parse specification: ${e.message}"), + ) + } + + val errors = mutableListOf() + + // Extract domain from servers[0].url + val domain = extractDomain(root) + if (domain.isBlank()) { + errors.add("No server URL found. Add a 'servers' array with at least one entry.") + } + + // Validate it looks like an OpenAPI spec + val hasOpenApi = root.containsKey("openapi") || root.containsKey("swagger") + if (!hasOpenApi) { + errors.add("Missing 'openapi' or 'swagger' version field.") + } + + // Extract paths + val paths = root["paths"]?.jsonObject + if (paths == null || paths.isEmpty()) { + errors.add("No paths found in the specification.") + return OpenApiParseResult(domain = domain, functions = emptyList(), errors = errors) + } + + val functions = mutableListOf() + + for ((pathStr, pathItem) in paths) { + val pathObj = try { + pathItem.jsonObject + } catch (_: Exception) { + continue + } + + // Path-level parameters (shared by all operations on this path) + val pathParameters = pathObj["parameters"]?.let { parseParameterList(it) } ?: emptyList() + + for (method in listOf("get", "post", "put", "patch", "delete", "head", "options")) { + val operation = pathObj[method]?.jsonObject ?: continue + + val operationId = operation["operationId"]?.jsonPrimitive?.content + ?: generateOperationId(method, pathStr) + + val description = operation["summary"]?.jsonPrimitive?.content + ?: operation["description"]?.jsonPrimitive?.content + ?: "$method $pathStr" + + // Merge path-level and operation-level parameters + val operationParameters = operation["parameters"] + ?.let { parseParameterList(it) } + ?: emptyList() + + val allParameters = mergeParameters(pathParameters, operationParameters) + + // Extract request body schema properties + val bodyProperties = extractRequestBodyProperties(operation) + + // Build the combined parameters JSON object + val parametersObj = buildParametersObject(allParameters, bodyProperties) + + functions.add( + FunctionTool( + type = "function", + function = FunctionDefinition( + name = sanitizeOperationId(operationId), + description = description, + parameters = parametersObj, + ), + ), + ) + } + } + + if (functions.isEmpty()) { + errors.add("No operations found in paths.") + } + + return OpenApiParseResult(domain = domain, functions = functions, errors = errors) + } + + /** + * Extracts a display-friendly list of parsed functions showing name, method, and path. + */ + fun extractFunctionInfo(spec: String): List { + if (spec.isBlank()) return emptyList() + + val root: JsonObject = try { + parseSpecToJsonObject(spec) + } catch (_: Exception) { + return emptyList() + } + + val paths = root["paths"]?.jsonObject ?: return emptyList() + val infos = mutableListOf() + + for ((pathStr, pathItem) in paths) { + val pathObj = try { pathItem.jsonObject } catch (_: Exception) { continue } + + for (method in listOf("get", "post", "put", "patch", "delete", "head", "options")) { + val operation = pathObj[method]?.jsonObject ?: continue + + val operationId = operation["operationId"]?.jsonPrimitive?.content + ?: generateOperationId(method, pathStr) + + val description = operation["summary"]?.jsonPrimitive?.content + ?: operation["description"]?.jsonPrimitive?.content + ?: "" + + infos.add( + ParsedFunctionInfo( + name = sanitizeOperationId(operationId), + method = method.uppercase(), + path = pathStr, + description = description, + ), + ) + } + } + + return infos + } + + private fun extractDomain(root: JsonObject): String { + val servers = try { root["servers"]?.jsonArray } catch (_: Exception) { null } + if (servers != null && servers.isNotEmpty()) { + val url = try { + servers[0].jsonObject["url"]?.jsonPrimitive?.content + } catch (_: Exception) { + null + } + if (url != null) return url.trimEnd('/') + } + // Fallback for Swagger 2.0: host + basePath + val host = root["host"]?.jsonPrimitive?.content + val basePath = root["basePath"]?.jsonPrimitive?.content ?: "" + val schemes = try { + root["schemes"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content + } catch (_: Exception) { + null + } ?: "https" + if (host != null) { + return "$schemes://$host${basePath.trimEnd('/')}" + } + return "" + } + + private fun generateOperationId(method: String, path: String): String { + // Convert /api/v1/users/{id}/posts to api_v1_users_id_posts + val sanitized = path + .replace("{", "") + .replace("}", "") + .replace("/", "_") + .replace("-", "_") + .trim('_') + return "${method}_$sanitized" + } + + private fun sanitizeOperationId(operationId: String): String { + // Ensure it's a valid function name: alphanumeric and underscores only + return operationId + .replace(Regex("[^a-zA-Z0-9_]"), "_") + .replace(Regex("_+"), "_") + .trim('_') + } + + private data class ParameterInfo( + val name: String, + val description: String, + val type: String, + val required: Boolean, + val location: String, // "query", "path", "header", "cookie" + ) + + private fun parseParameterList(element: JsonElement): List { + val array = try { element.jsonArray } catch (_: Exception) { return emptyList() } + return array.mapNotNull { param -> + try { + val obj = param.jsonObject + val name = obj["name"]?.jsonPrimitive?.content ?: return@mapNotNull null + val description = obj["description"]?.jsonPrimitive?.content ?: "" + val location = obj["in"]?.jsonPrimitive?.content ?: "query" + val required = obj["required"]?.jsonPrimitive?.content?.toBoolean() ?: (location == "path") + val type = obj["schema"]?.jsonObject?.get("type")?.jsonPrimitive?.content + ?: obj["type"]?.jsonPrimitive?.content + ?: "string" + ParameterInfo(name, description, type, required, location) + } catch (_: Exception) { + null + } + } + } + + /** + * Merge path-level and operation-level parameters. + * Operation-level parameters override path-level parameters with the same name + location. + */ + private fun mergeParameters( + pathParams: List, + operationParams: List, + ): List { + val merged = pathParams.associateBy { "${it.name}:${it.location}" }.toMutableMap() + for (param in operationParams) { + merged["${param.name}:${param.location}"] = param + } + return merged.values.toList() + } + + private fun extractRequestBodyProperties(operation: JsonObject): JsonObject? { + val requestBody = operation["requestBody"]?.jsonObject ?: return null + val content = requestBody["content"]?.jsonObject ?: return null + val jsonContent = content["application/json"]?.jsonObject ?: return null + val schema = jsonContent["schema"]?.jsonObject ?: return null + return schema["properties"]?.jsonObject + } + + private fun buildParametersObject( + parameters: List, + bodyProperties: JsonObject?, + ): JsonObject { + val properties = mutableMapOf() + val required = mutableListOf() + + // Add query/path/header parameters + for (param in parameters) { + properties[param.name] = JsonObject( + buildMap { + put("type", JsonPrimitive(param.type)) + if (param.description.isNotBlank()) { + put("description", JsonPrimitive(param.description)) + } + }, + ) + if (param.required) { + required.add(param.name) + } + } + + // Add body properties + if (bodyProperties != null) { + for ((key, value) in bodyProperties) { + properties[key] = value + } + } + + return JsonObject( + buildMap { + put("type", JsonPrimitive("object")) + if (properties.isNotEmpty()) { + put("properties", JsonObject(properties)) + } + if (required.isNotEmpty()) { + put("required", JsonArray(required.map { JsonPrimitive(it) })) + } + }, + ) + } + + /** + * Parses a spec string to a [JsonObject], trying JSON first and falling back to YAML. + * + * This mirrors the official LibreChat web frontend behavior where `JSON.parse` is + * attempted first and `js-yaml`'s `load()` is used as a fallback. + * + * @throws IllegalArgumentException if neither JSON nor YAML parsing succeeds, or if + * the parsed result is not an object (map). + */ + private fun parseSpecToJsonObject(spec: String): JsonObject { + // Try JSON first + val jsonError: Exception + try { + return json.parseToJsonElement(spec).jsonObject + } catch (e: Exception) { + jsonError = e + } + + // Fall back to YAML (using snakeyaml-engine which is Android-compatible) + try { + val settings = LoadSettings.builder().build() + val loader = Load(settings) + val parsed = loader.loadFromString(spec) + ?: throw IllegalArgumentException("YAML document is empty") + + if (parsed !is Map<*, *>) { + throw IllegalArgumentException( + "Expected a YAML mapping (object) at the root, but got ${parsed::class.simpleName}", + ) + } + + val element = yamlToJsonElement(parsed) + return element.jsonObject + } catch (yamlError: Exception) { + // Both JSON and YAML parsing failed — provide a helpful combined message + throw IllegalArgumentException( + "Not valid JSON or YAML.\n" + + "JSON error: ${jsonError.message}\n" + + "YAML error: ${yamlError.message}", + ) + } + } + + /** + * Recursively converts a SnakeYAML-parsed object (Map, List, or scalar) to a + * kotlinx.serialization [JsonElement]. + * + * SnakeYAML parses YAML into standard Java types: + * - Maps → [JsonObject] + * - Lists → [JsonArray] + * - Strings → [JsonPrimitive] (string) + * - Numbers (Int, Long, Float, Double, BigInteger) → [JsonPrimitive] (number) + * - Booleans → [JsonPrimitive] (boolean) + * - null → [JsonNull] + */ + @Suppress("CyclomaticComplexity") + private fun yamlToJsonElement(value: Any?): JsonElement { + return when (value) { + null -> JsonNull + is Map<*, *> -> { + val entries = value.entries.associate { (k, v) -> + (k?.toString() ?: "null") to yamlToJsonElement(v) + } + JsonObject(entries) + } + is List<*> -> { + JsonArray(value.map { yamlToJsonElement(it) }) + } + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is Number -> JsonPrimitive(value.toLong()) + is String -> JsonPrimitive(value) + else -> { + // Fallback: convert unknown types to their string representation + JsonPrimitive(value.toString()) + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt new file mode 100644 index 0000000..c1cf5d6 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt @@ -0,0 +1,152 @@ +package com.librechat.android.feature.agents.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.AgentRepository +import com.librechat.android.core.model.Agent +import com.librechat.android.feature.agents.AgentDetailDisplayData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class AgentDetailUiState( + val agent: AgentDetailDisplayData? = null, + val isLoading: Boolean = false, + val error: String? = null, + val showDeleteDialog: Boolean = false, + val isDeleting: Boolean = false, + val isDuplicating: Boolean = false, +) + +sealed interface AgentDetailEvent { + data object Deleted : AgentDetailEvent + data class Duplicated(val agentId: String) : AgentDetailEvent +} + +@HiltViewModel +class AgentDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val agentRepository: AgentRepository, + private val serverDataStore: ServerDataStore, +) : ViewModel() { + + private val agentId: String = checkNotNull(savedStateHandle["agentId"]) + + private val _uiState = MutableStateFlow(AgentDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + loadAgent() + } + + fun loadAgent() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + // Try the expanded endpoint first (returns full agent data including + // description, category, tools, conversation_starters). Falls back to + // the standard endpoint if the user lacks edit permission (403). + val result = when (val expanded = agentRepository.getAgentForEditing(agentId)) { + is Result.Success -> expanded + else -> agentRepository.getAgent(agentId) + } + when (result) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + agent = result.data.toDetailDisplayData(), + isLoading = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Failed to load agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun showDeleteConfirmation() { + _uiState.value = _uiState.value.copy(showDeleteDialog = true) + } + + fun dismissDeleteConfirmation() { + _uiState.value = _uiState.value.copy(showDeleteDialog = false) + } + + fun deleteAgent() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isDeleting = true, + showDeleteDialog = false, + error = null, + ) + when (val result = agentRepository.deleteAgent(agentId)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isDeleting = false) + _events.emit(AgentDetailEvent.Deleted) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isDeleting = false, + error = result.message ?: "Failed to delete agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun duplicateAgent() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isDuplicating = true, error = null) + when (val result = agentRepository.duplicateAgent(agentId)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isDuplicating = false) + _events.emit(AgentDetailEvent.Duplicated(result.data.id)) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isDuplicating = false, + error = result.message ?: "Failed to duplicate agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun Agent.toDetailDisplayData(): AgentDetailDisplayData { + val resolvedUrl = avatarUrl?.let { url -> + if (url.startsWith("http")) url + else "${serverDataStore.getBaseUrl()}$url" + } + return AgentDetailDisplayData( + id = id, + name = name ?: "Unnamed Agent", + description = description, + avatarUrl = resolvedUrl, + author = author, + authorName = authorName, + model = model, + category = category, + tools = tools, + conversationStarters = conversationStarters, + ) + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt new file mode 100644 index 0000000..507c8a6 --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt @@ -0,0 +1,960 @@ +package com.librechat.android.feature.agents.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Immutable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.AgentRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.model.Agent +import com.librechat.android.core.model.ActionAuth +import com.librechat.android.core.model.ActionMetadata +import com.librechat.android.core.model.AgentAction +import com.librechat.android.core.model.AgentCategory +import com.librechat.android.core.model.AgentTool +import com.librechat.android.core.model.SupportContact +import com.librechat.android.core.model.mcp.McpTool +import com.librechat.android.core.model.request.CreateActionRequest +import com.librechat.android.core.model.request.CreateAgentRequest +import com.librechat.android.core.model.request.RevertAgentRequest +import com.librechat.android.core.model.request.UpdateAgentRequest +import com.librechat.android.core.model.request.FunctionTool +import com.librechat.android.feature.agents.AgentActionDisplayData +import com.librechat.android.feature.agents.AgentHandoffDisplayData +import com.librechat.android.feature.agents.AgentToolDisplayData +import com.librechat.android.feature.agents.components.AgentAdvancedSettings +import com.librechat.android.feature.agents.util.OpenApiSpecParser +import com.librechat.android.feature.agents.components.AgentCapabilities +import com.librechat.android.feature.agents.components.AgentSharingState +import com.librechat.android.feature.agents.components.AgentVersion +import com.librechat.android.feature.agents.components.AgentVisibility +import com.librechat.android.feature.agents.components.ModelOption +import com.librechat.android.feature.agents.components.SupportContactState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import timber.log.Timber +import javax.inject.Inject + +@Immutable +data class AgentEditorUiState( + val isEditMode: Boolean = false, + val agentId: String? = null, + val name: String = "", + val description: String = "", + val instructions: String = "", + val model: String = "", + val provider: String = "", + val category: String = "general", + val selectedTools: List = emptyList(), + val conversationStarters: List = emptyList(), + val availableTools: List = emptyList(), + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val nameError: String? = null, + // Advanced editor fields + val avatarUrl: String? = null, + val categories: List = emptyList(), + val availableModels: List = emptyList(), + val capabilities: AgentCapabilities = AgentCapabilities(), + val advancedSettings: AgentAdvancedSettings = AgentAdvancedSettings(), + val versions: List = emptyList(), + val showDeleteConfirm: Boolean = false, + val showDuplicateConfirm: Boolean = false, + val showVersionHistory: Boolean = false, + val isDeleting: Boolean = false, + val isDuplicating: Boolean = false, + // Actions + val actions: List = emptyList(), + // MCP tools + val mcpTools: List = emptyList(), + val selectedMcpTools: Set = emptySet(), + // Capabilities toggles + val codeInterpreterEnabled: Boolean = false, + val fileSearchEnabled: Boolean = false, + /** Whether code interpreter is available on this server (from agents endpoint capabilities). */ + val isCodeInterpreterAvailable: Boolean = true, + // Sharing + val sharingState: AgentSharingState = AgentSharingState(), + // Handoff + val handoffAgentIds: List = emptyList(), + val allAgents: List = emptyList(), + // Support contact + val supportContact: SupportContactState = SupportContactState(), +) + +sealed interface AgentEditorEvent { + data class SaveSuccess(val agentId: String) : AgentEditorEvent + data class DuplicateSuccess(val agentId: String) : AgentEditorEvent + data object DeleteSuccess : AgentEditorEvent +} + +@HiltViewModel +class AgentEditorViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val agentRepository: AgentRepository, + private val configRepository: ConfigRepository, + private val mcpRepository: McpRepository, + @ApplicationContext private val appContext: Context, +) : ViewModel() { + + private val editAgentId: String? = savedStateHandle["agentId"] + + private val _uiState = MutableStateFlow( + AgentEditorUiState( + isEditMode = editAgentId != null, + agentId = editAgentId, + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + loadAvailableTools() + loadCategories() + loadModels() + loadMcpTools() + loadAllAgents() + loadCodeInterpreterAvailability() + if (editAgentId != null) { + loadAgent(editAgentId) + loadActions() + } + } + + /** + * Observes the agents endpoint config capabilities to determine + * whether code interpreter (execute_code) is available on this server. + */ + private fun loadCodeInterpreterAvailability() { + viewModelScope.launch { + configRepository.endpointConfigs.collect { configs -> + val agentsCapabilities = configs["agents"]?.capabilities ?: emptyList() + // If capabilities list is non-empty, check for execute_code. + // If empty (no config loaded yet), default to available. + val available = agentsCapabilities.isEmpty() || "execute_code" in agentsCapabilities + _uiState.value = _uiState.value.copy(isCodeInterpreterAvailable = available) + // If code interpreter becomes unavailable, disable it + if (!available && _uiState.value.codeInterpreterEnabled) { + _uiState.value = _uiState.value.copy(codeInterpreterEnabled = false) + } + } + } + } + + private fun loadAgent(agentId: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + Timber.d("AgentEditor: Loading agent for editing: %s", agentId) + // Use getAgentForEditing which calls the /expanded endpoint. + // The standard getAgent endpoint (GET /api/agents/:id) only returns + // basic view-only fields (id, name, description, avatar, model, provider). + // It does NOT return instructions, tools, category, conversation_starters, + // model_parameters, or other configuration needed for the editor. + when (val result = agentRepository.getAgentForEditing(agentId)) { + is Result.Success -> { + val agent = result.data + Timber.d( + "AgentEditor: Loaded agent fields BEFORE mapping - " + + "name=%s, description=%s, instructions=%s, model=%s, " + + "provider=%s, category=%s, tools=%s, " + + "conversationStarters=%s, avatarUrl=%s, " + + "artifacts=%s, recursionLimit=%s, " + + "hideSequentialOutputs=%s, endAfterTools=%s, " + + "isPublic=%s, isCollaborative=%s, " + + "agentIds=%s, supportContact=%s, " + + "modelParameters=%s", + agent.name, agent.description, agent.instructions, + agent.model, agent.provider, agent.category, + agent.tools, agent.conversationStarters, + agent.avatarUrl, agent.artifacts, + agent.recursionLimit, agent.hideSequentialOutputs, + agent.endAfterTools, agent.isPublic, + agent.isCollaborative, agent.agentIds, + agent.supportContact, agent.modelParameters, + ) + val newState = _uiState.value + .applyAgentData(agent) + .copy(isLoading = false) + Timber.d( + "AgentEditor: UI state AFTER mapping - " + + "name=%s, description=%s, instructions=%s, model=%s, " + + "provider=%s, category=%s, selectedTools=%s, " + + "conversationStarters=%s, avatarUrl=%s, " + + "codeInterpreterEnabled=%s, fileSearchEnabled=%s, " + + "capabilities=%s, advancedSettings=%s, " + + "sharingState=%s, handoffAgentIds=%s, " + + "supportContact=%s", + newState.name, newState.description, newState.instructions, + newState.model, newState.provider, newState.category, + newState.selectedTools, newState.conversationStarters, + newState.avatarUrl, newState.codeInterpreterEnabled, + newState.fileSearchEnabled, newState.capabilities, + newState.advancedSettings, newState.sharingState, + newState.handoffAgentIds, newState.supportContact, + ) + _uiState.value = newState + } + is Result.Error -> { + Timber.e( + "AgentEditor: Failed to load agent %s: %s", + agentId, result.message, + ) + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Failed to load agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadAvailableTools() { + viewModelScope.launch { + when (val result = agentRepository.getAvailableTools()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + availableTools = result.data.map { it.toDisplayData() }, + ) + } + is Result.Error -> { /* Tools are optional, ignore errors */ } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadCategories() { + viewModelScope.launch { + when (val result = agentRepository.getAgentCategories()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + categories = result.data, + ) + } + is Result.Error -> { /* Categories are optional */ } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadModels() { + viewModelScope.launch { + when (val result = configRepository.fetchModels()) { + is Result.Success -> { + val modelOptions = result.data.flatMap { (endpoint, models) -> + models.map { modelName -> + ModelOption( + id = modelName, + name = modelName, + endpoint = endpoint, + ) + } + } + _uiState.value = _uiState.value.copy( + availableModels = modelOptions, + ) + } + is Result.Error -> { /* Models loading failed, user can retry */ } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadMcpTools() { + viewModelScope.launch { + when (val result = mcpRepository.getTools()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(mcpTools = result.data) + } + is Result.Error -> { /* MCP tools are optional */ } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadActions() { + viewModelScope.launch { + when (val result = agentRepository.getAgentActions()) { + is Result.Success -> { + val agentId = editAgentId ?: return@launch + val agentActions = result.data + .filter { it.agentId == agentId } + .map { it.toDisplayData() } + _uiState.value = _uiState.value.copy(actions = agentActions) + } + is Result.Error -> { /* Actions are optional */ } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadAllAgents() { + viewModelScope.launch { + when (val result = agentRepository.getAgents()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + allAgents = result.data + .filter { it.id != editAgentId } + .map { it.toHandoffDisplayData() }, + ) + } + is Result.Error -> { /* Agents list is optional for handoff */ } + is Result.Loading -> { /* no-op */ } + } + } + } + + // --- Basic fields --- + + fun onNameChanged(name: String) { + _uiState.value = _uiState.value.copy(name = name, nameError = null) + } + + fun onDescriptionChanged(description: String) { + _uiState.value = _uiState.value.copy(description = description) + } + + fun onInstructionsChanged(instructions: String) { + _uiState.value = _uiState.value.copy(instructions = instructions) + } + + fun onModelChanged(model: String) { + _uiState.value = _uiState.value.copy(model = model) + } + + fun onModelSelected(modelId: String, provider: String) { + _uiState.value = _uiState.value.copy(model = modelId, provider = provider) + } + + fun onCategoryChanged(category: String) { + _uiState.value = _uiState.value.copy(category = category) + } + + fun onToolToggled(toolId: String) { + val current = _uiState.value.selectedTools + val updated = if (toolId in current) { + current - toolId + } else { + current + toolId + } + _uiState.value = _uiState.value.copy(selectedTools = updated) + } + + fun onToolAdded(toolId: String) { + val current = _uiState.value.selectedTools + if (toolId !in current) { + _uiState.value = _uiState.value.copy(selectedTools = current + toolId) + } + } + + fun onToolRemoved(toolId: String) { + _uiState.value = _uiState.value.copy( + selectedTools = _uiState.value.selectedTools - toolId, + ) + } + + fun onConversationStarterAdded(starter: String) { + if (starter.isBlank()) return + _uiState.value = _uiState.value.copy( + conversationStarters = _uiState.value.conversationStarters + starter.trim(), + ) + } + + fun onConversationStarterRemoved(index: Int) { + val updated = _uiState.value.conversationStarters.toMutableList() + if (index in updated.indices) { + updated.removeAt(index) + } + _uiState.value = _uiState.value.copy(conversationStarters = updated) + } + + fun onCapabilitiesChanged(capabilities: AgentCapabilities) { + _uiState.value = _uiState.value.copy(capabilities = capabilities) + } + + fun onAdvancedSettingsChanged(settings: AgentAdvancedSettings) { + _uiState.value = _uiState.value.copy(advancedSettings = settings) + } + + // --- Support Contact --- + + fun onSupportContactChanged(supportContact: SupportContactState) { + _uiState.value = _uiState.value.copy(supportContact = supportContact) + } + + // --- Actions --- + + fun saveAction( + actionId: String?, + metadata: ActionMetadata, + functions: List, + ) { + val agentId = _uiState.value.agentId ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, error = null) + val request = CreateActionRequest( + actionId = actionId, + metadata = metadata, + functions = functions, + ) + when (val result = agentRepository.addOrUpdateAction(agentId, request)) { + is Result.Success -> { + val (_, action) = result.data + val existing = _uiState.value.actions.toMutableList() + val idx = existing.indexOfFirst { it.actionId == action.actionId } + if (idx >= 0) { + existing[idx] = action.toDisplayData() + } else { + existing.add(action.toDisplayData()) + } + _uiState.value = _uiState.value.copy( + actions = existing, + isSaving = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to save action", + isSaving = false, + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deleteAction(actionId: String) { + val agentId = _uiState.value.agentId ?: return + viewModelScope.launch { + when (val result = agentRepository.deleteAction(agentId, actionId)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + actions = _uiState.value.actions.filter { it.actionId != actionId }, + ) + } + + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to delete action", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + // --- MCP Tools --- + + fun onMcpToolToggled(toolName: String) { + val current = _uiState.value.selectedMcpTools + val updated = if (toolName in current) { + current - toolName + } else { + current + toolName + } + _uiState.value = _uiState.value.copy(selectedMcpTools = updated) + } + + // --- Capability toggles --- + + fun onCodeInterpreterToggled(enabled: Boolean) { + _uiState.value = _uiState.value.copy(codeInterpreterEnabled = enabled) + } + + fun onFileSearchToggled(enabled: Boolean) { + _uiState.value = _uiState.value.copy(fileSearchEnabled = enabled) + } + + // --- Sharing --- + + fun onSharingChanged(sharingState: AgentSharingState) { + _uiState.value = _uiState.value.copy(sharingState = sharingState) + } + + // --- Handoff --- + + fun addHandoffAgent(agentId: String) { + val current = _uiState.value.handoffAgentIds + if (agentId !in current) { + _uiState.value = _uiState.value.copy(handoffAgentIds = current + agentId) + } + } + + fun removeHandoffAgent(agentId: String) { + _uiState.value = _uiState.value.copy( + handoffAgentIds = _uiState.value.handoffAgentIds - agentId, + ) + } + + // --- Dialog state --- + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun showDeleteConfirmation() { + _uiState.value = _uiState.value.copy(showDeleteConfirm = true) + } + + fun dismissDeleteConfirmation() { + _uiState.value = _uiState.value.copy(showDeleteConfirm = false) + } + + fun showDuplicateConfirmation() { + _uiState.value = _uiState.value.copy(showDuplicateConfirm = true) + } + + fun dismissDuplicateConfirmation() { + _uiState.value = _uiState.value.copy(showDuplicateConfirm = false) + } + + fun showVersionHistory() { + _uiState.value = _uiState.value.copy(showVersionHistory = true) + } + + fun dismissVersionHistory() { + _uiState.value = _uiState.value.copy(showVersionHistory = false) + } + + // --- Avatar --- + + fun uploadAvatar(uri: Uri) { + val agentId = _uiState.value.agentId ?: return + viewModelScope.launch { + try { + val inputStream = appContext.contentResolver.openInputStream(uri) ?: return@launch + val bytes = inputStream.use { it.readBytes() } + val mimeType = appContext.contentResolver.getType(uri) ?: "image/png" + + when (val result = agentRepository.uploadAgentAvatar(agentId, bytes, mimeType)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + avatarUrl = result.data.avatarUrl, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to upload avatar", + ) + } + is Result.Loading -> { /* no-op */ } + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = "Failed to read image: ${e.message}", + ) + } + } + } + + // --- Duplicate / Delete / Revert --- + + fun duplicate() { + val agentId = _uiState.value.agentId ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isDuplicating = true, + showDuplicateConfirm = false, + ) + when (val result = agentRepository.duplicateAgent(agentId)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isDuplicating = false) + _events.emit(AgentEditorEvent.DuplicateSuccess(result.data.id)) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isDuplicating = false, + error = result.message ?: "Failed to duplicate agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun delete() { + val agentId = _uiState.value.agentId ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isDeleting = true, + showDeleteConfirm = false, + ) + when (val result = agentRepository.deleteAgent(agentId)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isDeleting = false) + _events.emit(AgentEditorEvent.DeleteSuccess) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isDeleting = false, + error = result.message ?: "Failed to delete agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun revertToVersion(version: Int) { + val agentId = _uiState.value.agentId ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(showVersionHistory = false, isLoading = true) + when (val result = agentRepository.revertAgent(agentId, RevertAgentRequest(version))) { + is Result.Success -> { + _uiState.value = _uiState.value + .applyAgentData(result.data) + .copy(isLoading = false) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Failed to revert agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + // --- Save --- + + fun save() { + val state = _uiState.value + + // Validate + if (state.name.isBlank()) { + _uiState.value = state.copy(nameError = "Name is required") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, error = null) + + val isPublic = state.sharingState.visibility == AgentVisibility.PUBLIC + val isCollaborative = state.sharingState.isCollaborative + + val supportContact = if (state.supportContact.name.isNotBlank() || + state.supportContact.email.isNotBlank() + ) { + SupportContact( + name = state.supportContact.name.ifBlank { null }, + email = state.supportContact.email.ifBlank { null }, + ) + } else { + null + } + + // Build the full tools list: user-selected tools + capability tools + MCP server markers + val allTools = buildToolsList(state) + + // Build model_parameters from advanced settings + val modelParameters = buildModelParameters(state.advancedSettings) + + // Artifacts string: the backend stores this as a string, not boolean. + // The AgentCapabilities.artifacts boolean maps to an empty string or non-empty. + val artifacts = if (state.capabilities.artifacts) "artifacts" else null + + val result = if (state.isEditMode && state.agentId != null) { + agentRepository.updateAgent( + id = state.agentId, + request = UpdateAgentRequest( + name = state.name, + description = state.description.ifBlank { null }, + instructions = state.instructions.ifBlank { null }, + model = state.model.ifBlank { null }, + provider = state.provider.ifBlank { null }, + modelParameters = modelParameters, + artifacts = artifacts, + recursionLimit = state.capabilities.recursionLimit, + hideSequentialOutputs = state.capabilities.hideSequentialOutputs, + endAfterTools = state.capabilities.endAfterTools, + category = state.category.ifBlank { null }, + tools = allTools.ifEmpty { null }, + conversationStarters = state.conversationStarters.ifEmpty { null }, + isPublic = isPublic, + isCollaborative = isCollaborative, + supportContact = supportContact, + ), + ) + } else { + agentRepository.createAgent( + request = CreateAgentRequest( + name = state.name, + description = state.description.ifBlank { null }, + instructions = state.instructions.ifBlank { null }, + model = state.model.ifBlank { null }, + provider = state.provider.ifBlank { null }, + modelParameters = modelParameters, + artifacts = artifacts, + recursionLimit = state.capabilities.recursionLimit, + hideSequentialOutputs = state.capabilities.hideSequentialOutputs, + endAfterTools = state.capabilities.endAfterTools, + category = state.category.ifBlank { null }, + tools = allTools.ifEmpty { null }, + conversationStarters = state.conversationStarters.ifEmpty { null }, + isPublic = isPublic, + isCollaborative = isCollaborative, + supportContact = supportContact, + ), + ) + } + + when (result) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isSaving = false) + _events.emit(AgentEditorEvent.SaveSuccess(result.data.id)) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isSaving = false, + error = result.message ?: "Failed to save agent", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + companion object { + + // Tool identifiers that represent capabilities (not user-selectable tools). + // These are stored in the agent's tools list but displayed as capability toggles in the UI. + private val CAPABILITY_TOOLS = setOf( + "execute_code", + "file_search", + "web_search", + "end_after_tools", + "hide_sequential_outputs", + ) + + // MCP server marker prefix: tools starting with "sys__server__sys" or containing "_mcp_" + // are MCP-related entries in the tools list. + private const val MCP_SERVER_MARKER = "sys__server__sys" + private const val MCP_TOOL_SEPARATOR = "_mcp_" + + /** + * Partitions the agent's raw tools list into: + * - regular tools (for the tool selector UI) + * - capability booleans (code interpreter, file search, etc.) + * - selected MCP tool names (tools containing "_mcp_") + * + * This mirrors how the web frontend splits the tools array when loading + * an agent for editing (see AgentSelect.tsx resetAgentForm). + */ + private fun partitionTools( + rawTools: List?, + ): Triple, Set, Set> { + if (rawTools == null) return Triple(emptyList(), emptySet(), emptySet()) + + val regularTools = mutableListOf() + val capabilityTools = mutableSetOf() + val mcpToolNames = mutableSetOf() + + for (tool in rawTools) { + when { + tool in CAPABILITY_TOOLS -> capabilityTools.add(tool) + tool.contains(MCP_TOOL_SEPARATOR) -> { + // MCP tools are stored as "toolName_mcp_serverName" or + // "sys__server__sys_mcp_serverName" in the agent's tools list. + // Extract the tool name part (before _mcp_) for matching against + // the McpTool.name from the MCP tools API. + val toolName = tool.substringBefore(MCP_TOOL_SEPARATOR) + // The sys__server__sys marker means the entire MCP server was + // toggled on -- we still track the server name for display. + if (toolName == MCP_SERVER_MARKER) { + // Server-level toggle: store the server name + val serverName = tool.substringAfter(MCP_TOOL_SEPARATOR) + mcpToolNames.add(serverName) + } else { + mcpToolNames.add(toolName) + } + } + else -> regularTools.add(tool) + } + } + + return Triple(regularTools, capabilityTools, mcpToolNames) + } + + /** + * Applies agent data to the UI state using the copy() function. + * Returns a new AgentEditorUiState with all agent fields populated. + * This is the single source of truth for mapping agent API response data + * to the editor UI, used by both loadAgent() and revertToVersion(). + */ + private fun AgentEditorUiState.applyAgentData(agent: Agent): AgentEditorUiState { + val (regularTools, capabilityTools, mcpToolNames) = partitionTools(agent.tools) + + return copy( + name = agent.name ?: "", + description = agent.description ?: "", + instructions = agent.instructions ?: "", + model = agent.model ?: "", + provider = agent.provider ?: "", + category = agent.category ?: "general", + selectedTools = regularTools, + conversationStarters = agent.conversationStarters, + avatarUrl = agent.avatarUrl, + codeInterpreterEnabled = "execute_code" in capabilityTools, + fileSearchEnabled = "file_search" in capabilityTools, + selectedMcpTools = mcpToolNames, + capabilities = AgentCapabilities( + artifacts = !agent.artifacts.isNullOrBlank(), + endAfterTools = agent.endAfterTools ?: false, + hideSequentialOutputs = agent.hideSequentialOutputs ?: false, + recursionLimit = agent.recursionLimit ?: 25, + ), + advancedSettings = parseModelParameters(agent.modelParameters), + sharingState = AgentSharingState( + visibility = when { + agent.isPublic == true -> AgentVisibility.PUBLIC + agent.isCollaborative == true -> AgentVisibility.TEAM + else -> AgentVisibility.PRIVATE + }, + isCollaborative = agent.isCollaborative ?: false, + ), + supportContact = agent.parseSupportContact(), + handoffAgentIds = agent.agentIds ?: emptyList(), + ) + } + + /** + * Parse model_parameters JsonElement into AgentAdvancedSettings. + * The backend stores these as: { "temperature": 0.7, "top_p": 0.9, "max_tokens": 4096 } + */ + private fun parseModelParameters(params: kotlinx.serialization.json.JsonElement?): AgentAdvancedSettings { + if (params == null) return AgentAdvancedSettings() + return try { + val obj = params.jsonObject + AgentAdvancedSettings( + temperature = obj["temperature"]?.jsonPrimitive?.floatOrNull, + topP = (obj["top_p"] ?: obj["topP"])?.jsonPrimitive?.floatOrNull, + maxTokens = (obj["max_tokens"] ?: obj["maxTokens"])?.jsonPrimitive?.intOrNull, + ) + } catch (_: Exception) { + AgentAdvancedSettings() + } + } + + /** + * Build model_parameters JsonObject from the advanced settings. + * Returns null if no parameters are set (to avoid sending empty objects). + */ + private fun buildModelParameters(settings: AgentAdvancedSettings): JsonObject? { + val map = mutableMapOf() + settings.temperature?.let { map["temperature"] = JsonPrimitive(it) } + settings.topP?.let { map["top_p"] = JsonPrimitive(it) } + settings.maxTokens?.let { map["max_tokens"] = JsonPrimitive(it) } + return if (map.isEmpty()) null else JsonObject(map) + } + + /** + * Build the full tools list for saving, combining: + * - User-selected tools (from tool selector) + * - Capability tools (execute_code, file_search) based on toggle state + * - MCP server markers for selected MCP tools + */ + private fun buildToolsList(state: AgentEditorUiState): List { + val tools = state.selectedTools.toMutableList() + + // Add capability tools based on toggle state + if (state.codeInterpreterEnabled) tools.add("execute_code") + if (state.fileSearchEnabled) tools.add("file_search") + + // Add MCP server markers for each selected MCP tool + for (mcpToolName in state.selectedMcpTools) { + // Check if this is a server name or a tool name by looking at available MCP tools + val matchingTool = state.mcpTools.find { it.name == mcpToolName } + if (matchingTool != null) { + val serverName = matchingTool.serverName + if (serverName != null) { + // Store as "toolName_mcp_serverName" format + tools.add("${mcpToolName}${MCP_TOOL_SEPARATOR}${serverName}") + } else { + tools.add(mcpToolName) + } + } else { + // May be a server name marker + tools.add("${MCP_SERVER_MARKER}${MCP_TOOL_SEPARATOR}${mcpToolName}") + } + } + + return tools + } + + private fun Agent.toHandoffDisplayData() = AgentHandoffDisplayData( + id = id, + name = name ?: id, + ) + + private fun AgentAction.toDisplayData(): AgentActionDisplayData { + val rawSpec = metadata?.rawSpec + val functionCount = if (!rawSpec.isNullOrBlank()) { + try { + OpenApiSpecParser.extractFunctionInfo(rawSpec).size + } catch (_: Exception) { + 0 + } + } else { + 0 + } + return AgentActionDisplayData( + actionId = actionId, + domain = metadata?.domain, + type = type, + authType = metadata?.auth?.type, + rawSpec = metadata?.rawSpec, + functionCount = functionCount, + ) + } + + private fun AgentTool.toDisplayData() = AgentToolDisplayData( + toolId = pluginKey ?: toolId, + name = name, + description = description, + icon = icon, + isAvailable = isAvailable, + ) + + /** + * Parse the support_contact JsonElement from the Agent model into + * a SupportContactState for the UI. + */ + private fun Agent.parseSupportContact(): SupportContactState { + val json = supportContact ?: return SupportContactState() + return try { + val obj = json as? kotlinx.serialization.json.JsonObject + ?: return SupportContactState() + val name = obj["name"] + ?.let { (it as? kotlinx.serialization.json.JsonPrimitive)?.content } + ?: "" + val email = obj["email"] + ?.let { (it as? kotlinx.serialization.json.JsonPrimitive)?.content } + ?: "" + SupportContactState(name = name, email = email) + } catch (_: Exception) { + SupportContactState() + } + } + } +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt new file mode 100644 index 0000000..347904e --- /dev/null +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt @@ -0,0 +1,208 @@ +package com.librechat.android.feature.agents.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.AgentRepository +import com.librechat.android.core.model.Agent +import com.librechat.android.feature.agents.AgentCardDisplayData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class AgentMarketplaceUiState( + val agents: List = emptyList(), + val filteredAgents: List = emptyList(), + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + val selectedCategory: String? = null, + val searchQuery: String = "", + val categories: List = emptyList(), + val hasMore: Boolean = true, + val currentPage: Int = 1, +) + +@HiltViewModel +class AgentMarketplaceViewModel @Inject constructor( + private val agentRepository: AgentRepository, + private val serverDataStore: ServerDataStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(AgentMarketplaceUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var searchJob: Job? = null + + companion object { + private const val PAGE_SIZE = 10 + private const val SEARCH_DEBOUNCE_MS = 500L + } + + private fun Agent.toCardDisplayData(): AgentCardDisplayData { + val resolvedUrl = avatarUrl?.let { url -> + if (url.startsWith("http")) url + else "${serverDataStore.getBaseUrl()}$url" + } + return AgentCardDisplayData( + id = id, + name = name ?: "Unnamed Agent", + description = description, + avatarUrl = resolvedUrl, + author = author, + authorName = authorName, + ) + } + + init { + loadAgents() + loadCategories() + } + + fun loadAgents() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isLoading = true, + error = null, + currentPage = 1, + hasMore = true, + ) + val state = _uiState.value + when (val result = agentRepository.getAgentsPaginated( + page = 1, + limit = PAGE_SIZE, + search = state.searchQuery.ifBlank { null }, + category = state.selectedCategory, + )) { + is Result.Success -> { + val displayAgents = result.data.agents.map { it.toCardDisplayData() } + _uiState.value = _uiState.value.copy( + agents = displayAgents, + filteredAgents = displayAgents, + isLoading = false, + hasMore = result.data.hasMore, + currentPage = 1, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Failed to load agents", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + /** Fetches the next page and appends results. No-ops if already loading or no more pages. */ + fun loadMore() { + val state = _uiState.value + if (!state.hasMore || state.isLoadingMore || state.isLoading) return + + viewModelScope.launch { + val nextPage = state.currentPage + 1 + _uiState.value = state.copy(isLoadingMore = true) + when (val result = agentRepository.getAgentsPaginated( + page = nextPage, + limit = PAGE_SIZE, + search = state.searchQuery.ifBlank { null }, + category = state.selectedCategory, + )) { + is Result.Success -> { + val currentAgents = _uiState.value.agents + val newAgents = currentAgents + result.data.agents.map { it.toCardDisplayData() } + _uiState.value = _uiState.value.copy( + agents = newAgents, + filteredAgents = newAgents, + isLoadingMore = false, + hasMore = result.data.hasMore, + currentPage = nextPage, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoadingMore = false, + error = result.message ?: "Failed to load more agents", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true) + val state = _uiState.value + when (val result = agentRepository.getAgentsPaginated( + page = 1, + limit = PAGE_SIZE, + search = state.searchQuery.ifBlank { null }, + category = state.selectedCategory, + )) { + is Result.Success -> { + val displayAgents = result.data.agents.map { it.toCardDisplayData() } + _uiState.value = _uiState.value.copy( + agents = displayAgents, + filteredAgents = displayAgents, + isRefreshing = false, + hasMore = result.data.hasMore, + currentPage = 1, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = result.message ?: "Failed to refresh agents", + ) + } + is Result.Loading -> { /* no-op */ } + } + loadCategories() + } + } + + fun onSearchQueryChanged(query: String) { + _uiState.value = _uiState.value.copy(searchQuery = query) + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + loadAgents() + } + } + + fun onCategorySelected(category: String?) { + _uiState.value = _uiState.value.copy( + selectedCategory = if (_uiState.value.selectedCategory == category) null else category, + ) + loadAgents() + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } + + private fun loadCategories() { + viewModelScope.launch { + when (val result = agentRepository.getAgentCategories()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + categories = result.data.map { it.value }, + ) + } + is Result.Error -> { /* categories are non-critical */ } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/agents/src/main/res/values/strings.xml b/feature/agents/src/main/res/values/strings.xml new file mode 100644 index 0000000..e04065e --- /dev/null +++ b/feature/agents/src/main/res/values/strings.xml @@ -0,0 +1,207 @@ + + + + Agents + Marketplace + Search agents\u2026 + Start Chat + Edit + Delete + Cancel + Duplicate + Save + Create + Apply + Add + Remove + Revert + Close + + + Create Agent + Search + Back + More actions + More options + Collapse + Expand + Remove %1$s + Edit action + Delete action + Add starter + Agent avatar. Tap to change. + %1$s avatar + %1$s icon + + + Search agents\u2026 + No agents found + No agents available + Try a different search term + Check back later for new agents + An unknown error occurred + Unknown + + + Agent + Delete Agent + Are you sure you want to delete \"%1$s\"? This cannot be undone. + this agent + by %1$s + Description + No description + Category + Model + Default + Tools + Conversation Starters + + + Edit Agent + Create Agent + Duplicate Agent + Are you sure you want to delete this agent? This action cannot be undone. + Create a copy of this agent? + Version History + Tap to change avatar + Name * + Optional: The name of the agent + Description + Optional: Describe your Agent here + Instructions + The system instructions that the agent uses + Capabilities + Tools & Actions + Add Tools + Add starter\u2026 + Save Changes + + + OpenAPI Actions (%1$d) + No actions configured. Add an OpenAPI action to connect external APIs. + Add Action + Edit Action + API Key + OAuth + None + Auth: %1$s | %2$d function(s) + Action + + + Authentication + API Key Authentication + OAuth Authentication + No Authentication + OpenAPI Schema + Paste your OpenAPI specification in JSON or YAML format. + OpenAPI Spec (JSON or YAML) + Validating\u2026 + Domain: %1$s + Available Actions (%1$d) + Privacy Policy URL + %1$s + + + Authentication Type + API Key Settings + API Key + Authorization Type + Basic + Bearer + Custom + Custom Auth Header + OAuth Settings + Client ID + Client Secret + Authorization URL + Token URL + Scope + Token Exchange Method + Default (POST) + Basic Auth Header + + + Name + Method + Path + + + Advanced Settings + Temperature + Controls randomness. Lower = more focused. + Top P + Nucleus sampling cutoff. + Max Tokens + Maximum tokens in the response. Leave empty for default. + + + + + + Artifacts + Enable artifact generation + End After Tools + Stop after tool execution completes + Hide Sequential Outputs + Hide intermediate tool outputs + Recursion Limit + Maximum number of recursive tool calls + + + General + + + Code Interpreter + Allow the agent to write and execute code to solve problems + + + File Search + Enable the agent to search through uploaded files and documents + + + Handoff Agents (%1$d) + Agents this agent can hand conversations off to + No handoff agents configured. + Add Handoff Agent + No more agents available to add. + Select Agent + + + MCP Tools (%1$d selected) + No MCP tools available. Configure MCP servers in settings. + Unknown Server + + + Model * + Select a model\u2026 + No models found + + + Sharing & Permissions + Visibility + Collaborative + Allow other users to modify this agent + + + Private + Team + Public + + + Support Contact + Name + Support contact name + Email + + + No version history available + Version %1$d + Current + + + Agent Tools + Select tools for your agent to use + Search tools\u2026 + No tools matching \"%1$s\" + No tools available + diff --git a/feature/agents/src/test/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt b/feature/agents/src/test/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt new file mode 100644 index 0000000..afe5405 --- /dev/null +++ b/feature/agents/src/test/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt @@ -0,0 +1,282 @@ +package com.librechat.android.feature.agents.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.AgentRepository +import com.librechat.android.core.model.Agent +import com.librechat.android.core.model.AgentCategory +import com.librechat.android.core.model.PaginatedAgents +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AgentMarketplaceViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private val agentRepository = mockk(relaxed = true) + private val serverDataStore = mockk(relaxed = true) + + private lateinit var viewModel: AgentMarketplaceViewModel + + private val testAgents = listOf( + Agent(id = "agent-1", name = "Coding Assistant", description = "Helps with code", category = "coding"), + Agent(id = "agent-2", name = "Writer", description = "Creative writing", category = "writing"), + Agent(id = "agent-3", name = "Math Tutor", description = "Teaches math", category = "education"), + ) + + private val testCategories = listOf( + AgentCategory(value = "coding", label = "Coding", count = 5), + AgentCategory(value = "writing", label = "Writing", count = 3), + AgentCategory(value = "education", label = "Education", count = 2), + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { serverDataStore.getBaseUrl() } returns "https://chat.example.com" + coEvery { + agentRepository.getAgentsPaginated( + page = 1, + limit = any(), + search = any(), + category = any(), + ) + } returns Result.Success(PaginatedAgents(agents = testAgents, hasMore = true, total = 10)) + coEvery { agentRepository.getAgentCategories() } returns Result.Success(testCategories) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = AgentMarketplaceViewModel( + agentRepository = agentRepository, + serverDataStore = serverDataStore, + ) + + @Test + fun `initial state loads agents`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.agents).hasSize(3) + assertThat(state.filteredAgents).hasSize(3) + assertThat(state.isLoading).isFalse() + assertThat(state.hasMore).isTrue() + assertThat(state.currentPage).isEqualTo(1) + } + + @Test + fun `initial state loads categories`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.categories).hasSize(3) + assertThat(state.categories).containsExactly("coding", "writing", "education") + } + + @Test + fun `agents display data has correct names`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val names = viewModel.uiState.value.agents.map { it.name } + assertThat(names).containsExactly("Coding Assistant", "Writer", "Math Tutor") + } + + @Test + fun `loadAgents error surfaces error message`() = runTest { + coEvery { + agentRepository.getAgentsPaginated(page = 1, limit = any(), search = any(), category = any()) + } returns Result.Error(message = "Network error") + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.error).isEqualTo("Network error") + assertThat(state.isLoading).isFalse() + } + + @Test + fun `loadMore fetches next page and appends agents`() = runTest { + val page2Agents = listOf( + Agent(id = "agent-4", name = "Translator", description = "Translates text"), + ) + coEvery { + agentRepository.getAgentsPaginated(page = 2, limit = any(), search = any(), category = any()) + } returns Result.Success(PaginatedAgents(agents = page2Agents, hasMore = false, total = 4)) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.agents).hasSize(4) + assertThat(state.currentPage).isEqualTo(2) + assertThat(state.hasMore).isFalse() + assertThat(state.isLoadingMore).isFalse() + } + + @Test + fun `loadMore is no-op when hasMore is false`() = runTest { + coEvery { + agentRepository.getAgentsPaginated(page = 1, limit = any(), search = any(), category = any()) + } returns Result.Success(PaginatedAgents(agents = testAgents, hasMore = false, total = 3)) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + // Should not fetch page 2 + coVerify(exactly = 0) { + agentRepository.getAgentsPaginated(page = 2, limit = any(), search = any(), category = any()) + } + } + + @Test + fun `loadMore error surfaces error message`() = runTest { + coEvery { + agentRepository.getAgentsPaginated(page = 2, limit = any(), search = any(), category = any()) + } returns Result.Error(message = "Load more failed") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Load more failed") + assertThat(viewModel.uiState.value.isLoadingMore).isFalse() + } + + @Test + fun `onSearchQueryChanged updates query and triggers reload after debounce`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("coding") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.searchQuery).isEqualTo("coding") + } + + @Test + fun `onCategorySelected sets category and reloads`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onCategorySelected("coding") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.selectedCategory).isEqualTo("coding") + } + + @Test + fun `onCategorySelected toggles off already selected category`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onCategorySelected("coding") + advanceUntilIdle() + + viewModel.onCategorySelected("coding") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.selectedCategory).isNull() + } + + @Test + fun `refresh resets to page 1`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.isRefreshing).isFalse() + assertThat(state.currentPage).isEqualTo(1) + } + + @Test + fun `refresh error surfaces error message`() = runTest { + // First call succeeds (init), subsequent calls fail + coEvery { + agentRepository.getAgentsPaginated(page = 1, limit = any(), search = any(), category = any()) + } returns Result.Success(PaginatedAgents(agents = testAgents, hasMore = true, total = 10)) andThen + Result.Error(message = "Refresh failed") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Refresh failed") + assertThat(viewModel.uiState.value.isRefreshing).isFalse() + } + + @Test + fun `dismissError clears error state`() = runTest { + coEvery { + agentRepository.getAgentsPaginated(page = 1, limit = any(), search = any(), category = any()) + } returns Result.Error(message = "Error") + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isNotNull() + + viewModel.dismissError() + + assertThat(viewModel.uiState.value.error).isNull() + } + + @Test + fun `agent with null name shows as Unnamed Agent`() = runTest { + val agentNoName = Agent(id = "agent-x", name = null) + coEvery { + agentRepository.getAgentsPaginated(page = 1, limit = any(), search = any(), category = any()) + } returns Result.Success(PaginatedAgents(agents = listOf(agentNoName), hasMore = false, total = 1)) + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.agents[0].name).isEqualTo("Unnamed Agent") + } + + @Test + fun `categories failure is non-critical and does not show error`() = runTest { + coEvery { agentRepository.getAgentCategories() } returns + Result.Error(message = "Categories unavailable") + + viewModel = createViewModel() + advanceUntilIdle() + + // Error should not be set for category failures + assertThat(viewModel.uiState.value.error).isNull() + assertThat(viewModel.uiState.value.categories).isEmpty() + } +} diff --git a/feature/auth/CLAUDE.md b/feature/auth/CLAUDE.md new file mode 100644 index 0000000..556767b --- /dev/null +++ b/feature/auth/CLAUDE.md @@ -0,0 +1,49 @@ +# feature:auth + +## Screens +- **ServerUrlScreen** -- first-run server URL entry with validation and QR scan option +- **LoginScreen** -- email/password + social OAuth buttons + LDAP username mode +- **RegisterScreen** -- name, username, email, password, confirm password +- **ForgotPasswordScreen** -- email entry for password reset link +- **TwoFactorScreen** -- 6-digit OTP input with backup code fallback + +## Navigation +- Graph route: `AUTH_GRAPH_ROUTE` ("auth_graph"), start destination: `SERVER_URL_ROUTE` +- Flow: ServerUrl -> Login -> (2FA if `tempToken` returned) -> `onAuthComplete` +- Register and ForgotPassword are lateral routes from Login +- `TWO_FACTOR_ROUTE` takes `{tempToken}` nav argument + +## OAuth Flow +- `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}` +- On return (Activity.onResume), `CookieManager.getCookie()` extracts `refreshToken=` cookie +- Cookie is cleared after extraction to prevent stale reads +- Supported providers configured by server: Google, GitHub, Discord, Facebook, Apple, OpenID + +## Token Storage +- Tokens stored in `EncryptedSharedPreferences` via `TokenDataStore` in `:core:data` +- Refresh token sent as Cookie header (backend reads `cookies.parse(req.headers.cookie)`) +- Access token sent as Bearer header +- Token refresh is an explicit POST, not automatic cookie-based + +## ViewModels +- One ViewModel per screen: `ServerUrlViewModel`, `LoginViewModel`, `RegisterViewModel`, `ForgotPasswordViewModel`, `TwoFactorViewModel` +- All use `AuthRepository` from `:core:data` + +## Key Implementation Notes +- If server URL is already stored, skip ServerUrl screen on launch +- Social logins must use Custom Tabs, not WebView (cookie sharing requirement) +- `openidAutoRedirect` from server config triggers automatic redirect instead of showing login form +- LDAP mode: show "Username" field instead of "Email" (check server config) + +### Terms Screen +- `TermsScreen` + `TermsViewModel` — displays server terms, "I Accept" button +- Route: `TERMS_ROUTE = "auth/terms"` in `AuthNavigation.kt` +- Loads terms text via `UserRepository`, posts acceptance on confirm +- `TermsViewModel.consumeAccepted()` resets navigation trigger +- **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true +- **Note**: `VerifyEmailScreen` already existed pre-Round 2 + +### Localization +- `strings.xml` created for all 8 modules (app, core/ui, feature/auth, chat, conversations, settings, agents, files) +- Contains key toolbar titles, button labels, section headers — NOT exhaustive extraction +- Full string extraction is a future pass diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts new file mode 100644 index 0000000..36ef1b7 --- /dev/null +++ b/feature/auth/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("librechat.android.feature") +} + +android { + namespace = "com.librechat.android.feature.auth" +} + +dependencies { + implementation(project(":core:network")) + implementation(libs.browser) + testImplementation(libs.kotlinx.serialization.json) +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt new file mode 100644 index 0000000..dc2b309 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt @@ -0,0 +1,124 @@ +package com.librechat.android.feature.auth.navigation + +import android.net.Uri +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navigation +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.auth.screen.ForgotPasswordScreen +import com.librechat.android.feature.auth.screen.LoginScreen +import com.librechat.android.feature.auth.screen.RegisterScreen +import com.librechat.android.feature.auth.screen.ResetPasswordScreen +import com.librechat.android.feature.auth.screen.ServerUrlScreen +import com.librechat.android.feature.auth.screen.TermsScreen +import com.librechat.android.feature.auth.screen.TwoFactorScreen +import com.librechat.android.feature.auth.screen.VerifyEmailScreen + +const val AUTH_GRAPH_ROUTE = "auth_graph" +const val SERVER_URL_ROUTE = "server_url" +const val LOGIN_ROUTE = "login" +const val REGISTER_ROUTE = "register" +const val FORGOT_PASSWORD_ROUTE = "forgot_password" +const val TWO_FACTOR_ROUTE = "two_factor/{tempToken}" +const val VERIFY_EMAIL_ROUTE = "verify_email/{email}" +const val TERMS_ROUTE = "auth/terms" +const val RESET_PASSWORD_ROUTE = "reset_password/{userId}/{token}" + +fun NavController.navigateToVerifyEmail(email: String) { + navigate("verify_email/${Uri.encode(email)}") +} + +fun NavController.navigateToResetPassword(userId: String, token: String) { + navigate("reset_password/${Uri.encode(userId)}/${Uri.encode(token)}") +} + +fun NavGraphBuilder.authGraph( + navController: NavController, + onAuthComplete: () -> Unit, +) { + navigation(startDestination = SERVER_URL_ROUTE, route = AUTH_GRAPH_ROUTE) { + composable(SERVER_URL_ROUTE) { + ScreenTransitionWrapper(transition) { + ServerUrlScreen( + onServerValidated = { navController.navigate(LOGIN_ROUTE) }, + ) + } + } + composable(LOGIN_ROUTE) { + ScreenTransitionWrapper(transition) { + LoginScreen( + onLoginSuccess = onAuthComplete, + onNavigateToRegister = { navController.navigate(REGISTER_ROUTE) }, + onNavigateToForgotPassword = { navController.navigate(FORGOT_PASSWORD_ROUTE) }, + onNavigateToTwoFactor = { tempToken -> + navController.navigate("two_factor/$tempToken") + }, + ) + } + } + composable(REGISTER_ROUTE) { + ScreenTransitionWrapper(transition) { + RegisterScreen( + onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, + onNavigateToLogin = { navController.popBackStack() }, + ) + } + } + composable(FORGOT_PASSWORD_ROUTE) { + ScreenTransitionWrapper(transition) { + ForgotPasswordScreen( + onBack = { navController.popBackStack() }, + ) + } + } + composable( + route = TWO_FACTOR_ROUTE, + arguments = listOf(navArgument("tempToken") { type = NavType.StringType }), + ) { + ScreenTransitionWrapper(transition) { + TwoFactorScreen( + onVerified = onAuthComplete, + onBack = { navController.popBackStack() }, + ) + } + } + composable( + route = VERIFY_EMAIL_ROUTE, + arguments = listOf(navArgument("email") { type = NavType.StringType }), + ) { backStackEntry -> + val email = backStackEntry.arguments?.getString("email") ?: "" + ScreenTransitionWrapper(transition) { + VerifyEmailScreen( + email = email, + onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, + onBack = { navController.popBackStack() }, + ) + } + } + composable(TERMS_ROUTE) { + ScreenTransitionWrapper(transition) { + TermsScreen( + onAccepted = onAuthComplete, + onBack = { navController.popBackStack() }, + ) + } + } + composable( + route = RESET_PASSWORD_ROUTE, + arguments = listOf( + navArgument("userId") { type = NavType.StringType }, + navArgument("token") { type = NavType.StringType }, + ), + ) { + ScreenTransitionWrapper(transition) { + ResetPasswordScreen( + onResetComplete = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, + onBack = { navController.popBackStack() }, + ) + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/oauth/OAuthManager.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/oauth/OAuthManager.kt new file mode 100644 index 0000000..ce53d5d --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/oauth/OAuthManager.kt @@ -0,0 +1,51 @@ +package com.librechat.android.feature.auth.oauth + +import android.content.Context +import android.net.Uri +import android.webkit.CookieManager +import androidx.browser.customtabs.CustomTabsIntent +import com.librechat.android.core.data.datastore.ServerDataStore +import javax.inject.Inject + +class OAuthManager @Inject constructor( + private val serverDataStore: ServerDataStore, +) { + /** + * Opens Chrome Custom Tab for OAuth provider. + * URL: {serverUrl}/api/oauth/{provider} + * The server handles the OAuth flow and redirects back with cookies set. + */ + fun launchOAuth(context: Context, provider: String) { + val serverUrl = serverDataStore.getBaseUrl() + val oauthUrl = "$serverUrl/api/oauth/$provider" + val customTabsIntent = CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + customTabsIntent.launchUrl(context, Uri.parse(oauthUrl)) + } + + /** + * After OAuth redirect, extract refresh token from cookies. + * Called from Activity.onResume() after Custom Tab closes. + * + * CookieManager shares cookie jar with Chrome Custom Tabs when the + * default browser is Chrome. httpOnly cookies are readable via native + * CookieManager.getCookie() despite the httpOnly flag. + */ + fun extractTokenFromCookies(serverUrl: String): String? { + val cookies = CookieManager.getInstance().getCookie(serverUrl) ?: return null + return cookies.split(";") + .map { it.trim() } + .firstOrNull { it.startsWith("refreshToken=") } + ?.substringAfter("refreshToken=") + } + + /** + * Clear the refresh token cookie after successful extraction to avoid + * stale cookie reads on subsequent onResume calls. + */ + fun clearOAuthCookie(serverUrl: String) { + CookieManager.getInstance() + .setCookie(serverUrl, "refreshToken=; expires=Thu, 01 Jan 1970 00:00:00 GMT") + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt new file mode 100644 index 0000000..c05753a --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt @@ -0,0 +1,127 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.ForgotPasswordViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ForgotPasswordScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ForgotPasswordViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.reset_password_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (uiState.isSent) { + Text( + text = stringResource(R.string.check_your_email), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.reset_link_sent, uiState.email), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onBack) { + Text(stringResource(R.string.back_to_sign_in)) + } + } else { + Text( + text = stringResource(R.string.forgot_your_password), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.enter_email_for_reset), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = uiState.email, + onValueChange = viewModel::onEmailChanged, + label = { Text(stringResource(R.string.email_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = uiState.error != null, + supportingText = uiState.error?.let { error -> + { Text(text = error, color = MaterialTheme.colorScheme.error) } + }, + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::requestReset, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading && uiState.email.isNotBlank(), + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.send_reset_link)) + } + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt new file mode 100644 index 0000000..bfb1d3c --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt @@ -0,0 +1,185 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.LoginViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + onNavigateToRegister: () -> Unit, + onNavigateToForgotPassword: () -> Unit = {}, + onNavigateToTwoFactor: (String) -> Unit = {}, + modifier: Modifier = Modifier, + viewModel: LoginViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + // Check for OAuth result when returning from Chrome Custom Tab + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.checkOAuthResult() + } + + LaunchedEffect(uiState.isLoggedIn) { + if (uiState.isLoggedIn) { + onLoginSuccess() + } + } + + LaunchedEffect(uiState.twoFactorTempToken) { + val tempToken = uiState.twoFactorTempToken + if (tempToken != null) { + onNavigateToTwoFactor(tempToken) + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.login_title), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = uiState.email, + onValueChange = viewModel::onEmailChanged, + label = { Text(stringResource(R.string.email_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.password, + onValueChange = viewModel::onPasswordChanged, + label = { Text(stringResource(R.string.password_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = !uiState.isLoading, + ) + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::login, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading, + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.continue_button)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton(onClick = onNavigateToForgotPassword) { + Text(stringResource(R.string.forgot_password)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (uiState.registrationEnabled) { + TextButton(onClick = onNavigateToRegister) { + Text(stringResource(R.string.no_account_sign_up)) + } + } + + // OAuth social login buttons + val socialLogins = uiState.socialLogins + if (uiState.socialLoginEnabled && socialLogins.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.or_continue_with), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + socialLogins.forEach { provider -> + OutlinedButton( + onClick = { viewModel.launchOAuth(context, provider) }, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading, + ) { + Text(oAuthProviderLabel(provider)) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +private fun oAuthProviderLabel(provider: String): String = when (provider.lowercase()) { + "google" -> "Continue with Google" + "github" -> "Continue with GitHub" + "discord" -> "Continue with Discord" + "facebook" -> "Continue with Facebook" + "apple" -> "Continue with Apple" + "openid" -> "Continue with OpenID" + else -> "Continue with ${provider.replaceFirstChar { it.uppercase() }}" +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt new file mode 100644 index 0000000..90757d8 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt @@ -0,0 +1,152 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.RegisterViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +@Composable +fun RegisterScreen( + onRegistered: () -> Unit, + onNavigateToLogin: () -> Unit, + modifier: Modifier = Modifier, + viewModel: RegisterViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.isRegistered) { + if (uiState.isRegistered) { + onRegistered() + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.register_title), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = uiState.name, + onValueChange = viewModel::onNameChanged, + label = { Text(stringResource(R.string.full_name_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.username, + onValueChange = viewModel::onUsernameChanged, + label = { Text(stringResource(R.string.username_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.email, + onValueChange = viewModel::onEmailChanged, + label = { Text(stringResource(R.string.email_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.password, + onValueChange = viewModel::onPasswordChanged, + label = { Text(stringResource(R.string.password_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.confirmPassword, + onValueChange = viewModel::onConfirmPasswordChanged, + label = { Text(stringResource(R.string.confirm_password_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = !uiState.isLoading, + ) + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::register, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading, + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.sign_up)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = onNavigateToLogin) { + Text(stringResource(R.string.have_account_sign_in)) + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt new file mode 100644 index 0000000..71af1e3 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt @@ -0,0 +1,161 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.ResetPasswordViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResetPasswordScreen( + onResetComplete: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ResetPasswordViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.reset_password_screen_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (uiState.isReset) { + Text( + text = stringResource(R.string.password_reset_successful), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.password_reset_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onResetComplete, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.back_to_sign_in)) + } + } else { + Text( + text = stringResource(R.string.set_new_password), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.enter_new_password_hint), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = uiState.password, + onValueChange = viewModel::onPasswordChanged, + label = { Text(stringResource(R.string.new_password_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.confirmPassword, + onValueChange = viewModel::onConfirmPasswordChanged, + label = { Text(stringResource(R.string.confirm_new_password_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = !uiState.isLoading, + ) + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::resetPassword, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading, + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.reset_password_button)) + } + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt new file mode 100644 index 0000000..9641836 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt @@ -0,0 +1,140 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.ServerUrlViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +@Composable +fun ServerUrlScreen( + onServerValidated: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ServerUrlViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + if (viewModel.skipIfAlreadyConfigured()) { + onServerValidated() + } + } + + LaunchedEffect(uiState.isValidated) { + if (uiState.isValidated) { + onServerValidated() + } + } + + if (uiState.showHttpWarning) { + HttpWarningDialog( + onConfirm = viewModel::confirmHttpConnection, + onDismiss = viewModel::dismissHttpWarning, + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.connect_to_librechat), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.enter_server_url_hint), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = uiState.url, + onValueChange = viewModel::onUrlChanged, + label = { Text(stringResource(R.string.server_url_label)) }, + placeholder = { Text(stringResource(R.string.server_url_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = uiState.error != null, + supportingText = uiState.error?.let { error -> + { Text(text = error, color = MaterialTheme.colorScheme.error) } + }, + enabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::validateAndConnect, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading && uiState.url.isNotBlank(), + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.server_url_connect)) + } + } + } +} + +@Composable +private fun HttpWarningDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.insecure_connection_title)) }, + text = { + Text(stringResource(R.string.insecure_connection_message)) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(R.string.connect_anyway), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt new file mode 100644 index 0000000..0e39b64 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt @@ -0,0 +1,163 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.TermsViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +/** Displays server terms of service loaded from the backend; gates navigation on user acceptance. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TermsScreen( + onAccepted: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: TermsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.isAccepted) { + if (uiState.isAccepted) { + viewModel.consumeAccepted() + onAccepted() + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.terms_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + uiState.isLoading && uiState.termsText.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + uiState.error != null && uiState.termsText.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp), + ) { + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton(onClick = viewModel::loadTerms) { + Text(stringResource(R.string.retry)) + } + } + } + } + else -> { + Text( + text = stringResource(R.string.terms_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 16.dp) + .semantics { heading() }, + ) + + Text( + text = uiState.termsText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + ) + + if (uiState.error != null) { + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = viewModel::acceptTerms, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + enabled = !uiState.isLoading, + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.i_accept)) + } + } + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt new file mode 100644 index 0000000..4170660 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt @@ -0,0 +1,216 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.TwoFactorViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TwoFactorScreen( + onVerified: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: TwoFactorViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.isVerified) { + if (uiState.isVerified) onVerified() + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.two_factor_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = if (uiState.isBackupMode) stringResource(R.string.enter_backup_code) else stringResource(R.string.enter_verification_code), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (uiState.isBackupMode) { + stringResource(R.string.backup_code_prompt) + } else { + stringResource(R.string.authenticator_prompt) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (uiState.isBackupMode) { + BackupCodeInput( + code = uiState.backupCode, + onCodeChanged = viewModel::onBackupCodeChanged, + enabled = !uiState.isLoading, + ) + } else { + DigitBoxes( + digits = uiState.digits, + onDigitChanged = viewModel::onDigitChanged, + enabled = !uiState.isLoading, + ) + } + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (uiState.isBackupMode) { + Button( + onClick = viewModel::submit, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading && uiState.backupCode.isNotBlank(), + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.verify)) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = viewModel::toggleBackupMode) { + Text( + if (uiState.isBackupMode) stringResource(R.string.use_authenticator_code) else stringResource(R.string.use_backup_code), + ) + } + } + } +} + +@Composable +private fun DigitBoxes( + digits: List, + onDigitChanged: (Int, String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val focusRequesters = remember { List(6) { FocusRequester() } } + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + digits.forEachIndexed { index, digit -> + if (index > 0) Spacer(modifier = Modifier.width(8.dp)) + OutlinedTextField( + value = digit, + onValueChange = { value -> + val filtered = value.filter { it.isDigit() }.take(1) + onDigitChanged(index, filtered) + if (filtered.isNotEmpty() && index < 5) { + focusRequesters[index + 1].requestFocus() + } + }, + modifier = Modifier + .width(48.dp) + .focusRequester(focusRequesters[index]), + enabled = enabled, + singleLine = true, + textStyle = MaterialTheme.typography.headlineMedium.copy( + textAlign = TextAlign.Center, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + } + + // Focus first box on initial composition + LaunchedEffect(Unit) { + focusRequesters[0].requestFocus() + } +} + +@Composable +private fun BackupCodeInput( + code: String, + onCodeChanged: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = code, + onValueChange = onCodeChanged, + modifier = modifier.fillMaxWidth(), + enabled = enabled, + singleLine = true, + label = { Text(stringResource(R.string.backup_code_label)) }, + ) +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt new file mode 100644 index 0000000..cd96fc0 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt @@ -0,0 +1,166 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.auth.viewmodel.VerifyEmailViewModel +import com.librechat.android.feature.auth.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerifyEmailScreen( + email: String, + onVerified: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: VerifyEmailViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(email) { + viewModel.setEmail(email) + } + + LaunchedEffect(uiState.isVerified) { + if (uiState.isVerified) { + viewModel.consumeVerified() + onVerified() + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.verify_email_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.check_your_email), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.verification_link_sent), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + if (email.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = email, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.click_link_to_verify), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + + if (uiState.resendSuccess) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.verification_email_sent), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedButton( + onClick = viewModel::resendVerification, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading && uiState.resendCooldownSeconds == 0, + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + ) + } else if (uiState.resendCooldownSeconds > 0) { + Text(stringResource(R.string.resend_in_seconds, uiState.resendCooldownSeconds)) + } else { + Text(stringResource(R.string.resend_verification_email)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onBack, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.back_to_sign_in)) + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt new file mode 100644 index 0000000..cd11fef --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt @@ -0,0 +1,59 @@ +package com.librechat.android.feature.auth.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import androidx.compose.runtime.Immutable +import com.librechat.android.core.data.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class ForgotPasswordUiState( + val email: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isSent: Boolean = false, +) + +@HiltViewModel +class ForgotPasswordViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ForgotPasswordUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEmailChanged(email: String) { + _uiState.value = _uiState.value.copy(email = email, error = null) + } + + fun requestReset() { + val email = _uiState.value.email.trim() + if (email.isBlank()) { + _uiState.value = _uiState.value.copy(error = "Please enter your email") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (val result = authRepository.requestPasswordReset(email)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isLoading = false, isSent = true) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message + ?: "Could not send reset email. Please check your connection and try again.", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt new file mode 100644 index 0000000..dab6243 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt @@ -0,0 +1,137 @@ +package com.librechat.android.feature.auth.viewmodel + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.model.LoginOutcome +import com.librechat.android.feature.auth.oauth.OAuthManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class LoginUiState( + val email: String = "", + val password: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isLoggedIn: Boolean = false, + val twoFactorTempToken: String? = null, + val registrationEnabled: Boolean = false, + val socialLoginEnabled: Boolean = false, + val socialLogins: List = emptyList(), +) + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val configRepository: ConfigRepository, + private val oAuthManager: OAuthManager, + private val serverDataStore: ServerDataStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + configRepository.startupConfig.collect { config -> + if (config != null) { + _uiState.value = _uiState.value.copy( + registrationEnabled = config.registrationEnabled, + socialLoginEnabled = config.socialLoginEnabled, + socialLogins = config.socialLogins.orEmpty(), + ) + } + } + } + } + + fun onEmailChanged(email: String) { + _uiState.value = _uiState.value.copy(email = email, error = null) + } + + fun onPasswordChanged(password: String) { + _uiState.value = _uiState.value.copy(password = password, error = null) + } + + fun login() { + val state = _uiState.value + if (state.email.isBlank() || state.password.isBlank()) { + _uiState.value = state.copy(error = "Please enter email and password") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + when (val result = authRepository.login(state.email, state.password)) { + is Result.Success -> { + when (val outcome = result.data) { + is LoginOutcome.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoggedIn = true, + ) + } + is LoginOutcome.TwoFactorRequired -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + twoFactorTempToken = outcome.tempToken, + ) + } + } + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Login failed", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun launchOAuth(context: Context, provider: String) { + oAuthManager.launchOAuth(context, provider) + } + + fun checkOAuthResult() { + val serverUrl = serverDataStore.getBaseUrl() + if (serverUrl.isBlank()) return + + val refreshToken = oAuthManager.extractTokenFromCookies(serverUrl) ?: return + + // Clear the cookie immediately to avoid re-reading on next onResume + oAuthManager.clearOAuthCookie(serverUrl) + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + when (val result = authRepository.loginWithOAuthToken(refreshToken)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoggedIn = true, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "OAuth login failed", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt new file mode 100644 index 0000000..b9ab81e --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt @@ -0,0 +1,106 @@ +package com.librechat.android.feature.auth.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.AuthRepository +import androidx.compose.runtime.Immutable +import com.librechat.android.core.data.repository.ConfigRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class RegisterUiState( + val name: String = "", + val email: String = "", + val username: String = "", + val password: String = "", + val confirmPassword: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isRegistered: Boolean = false, + val minPasswordLength: Int = 8, +) + +@HiltViewModel +class RegisterViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val configRepository: ConfigRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(RegisterUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + configRepository.startupConfig.collect { config -> + if (config != null) { + _uiState.value = _uiState.value.copy( + minPasswordLength = config.minPasswordLength ?: 8, + ) + } + } + } + } + + fun onNameChanged(name: String) { + _uiState.value = _uiState.value.copy(name = name, error = null) + } + + fun onEmailChanged(email: String) { + _uiState.value = _uiState.value.copy(email = email, error = null) + } + + fun onUsernameChanged(username: String) { + _uiState.value = _uiState.value.copy(username = username, error = null) + } + + fun onPasswordChanged(password: String) { + _uiState.value = _uiState.value.copy(password = password, error = null) + } + + fun onConfirmPasswordChanged(confirmPassword: String) { + _uiState.value = _uiState.value.copy(confirmPassword = confirmPassword, error = null) + } + + fun register() { + val state = _uiState.value + + if (state.name.isBlank() || state.email.isBlank() || state.username.isBlank() || state.password.isBlank()) { + _uiState.value = state.copy(error = "All fields are required") + return + } + if (state.password.length < state.minPasswordLength) { + _uiState.value = state.copy(error = "Password must be at least ${state.minPasswordLength} characters") + return + } + if (state.password != state.confirmPassword) { + _uiState.value = state.copy(error = "Passwords do not match") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + when (val result = authRepository.register(state.name, state.email, state.username, state.password)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isRegistered = true, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Registration failed", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt new file mode 100644 index 0000000..aadf617 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt @@ -0,0 +1,84 @@ +package com.librechat.android.feature.auth.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import androidx.compose.runtime.Immutable +import com.librechat.android.core.data.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class ResetPasswordUiState( + val password: String = "", + val confirmPassword: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isReset: Boolean = false, +) + +@HiltViewModel +class ResetPasswordViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, +) : ViewModel() { + + private val userId: String = savedStateHandle["userId"] ?: "" + private val token: String = savedStateHandle["token"] ?: "" + + private val _uiState = MutableStateFlow(ResetPasswordUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onPasswordChanged(password: String) { + _uiState.value = _uiState.value.copy(password = password, error = null) + } + + fun onConfirmPasswordChanged(confirmPassword: String) { + _uiState.value = _uiState.value.copy(confirmPassword = confirmPassword, error = null) + } + + fun resetPassword() { + val state = _uiState.value + + if (state.password.isBlank()) { + _uiState.value = state.copy(error = "Please enter a new password") + return + } + if (state.password.length < 8) { + _uiState.value = state.copy(error = "Password must be at least 8 characters") + return + } + if (state.password != state.confirmPassword) { + _uiState.value = state.copy(error = "Passwords do not match") + return + } + if (userId.isBlank() || token.isBlank()) { + _uiState.value = state.copy(error = "Invalid reset link. Please request a new one.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (authRepository.resetPassword(userId, token, state.password, state.confirmPassword)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isReset = true, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Could not reset password. The link may have expired.", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt new file mode 100644 index 0000000..bca93fe --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt @@ -0,0 +1,116 @@ +package com.librechat.android.feature.auth.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import androidx.compose.runtime.Immutable +import com.librechat.android.core.data.repository.ConfigRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class ServerUrlUiState( + val url: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isValidated: Boolean = false, + val hasExistingUrl: Boolean = false, + val showHttpWarning: Boolean = false, +) + +@HiltViewModel +class ServerUrlViewModel @Inject constructor( + private val serverDataStore: ServerDataStore, + private val configRepository: ConfigRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ServerUrlUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val existingUrl = serverDataStore.getBaseUrl() + if (existingUrl.isNotBlank()) { + _uiState.value = _uiState.value.copy( + url = existingUrl, + hasExistingUrl = true, + ) + } + } + } + + fun onUrlChanged(url: String) { + _uiState.value = _uiState.value.copy(url = url, error = null) + } + + fun validateAndConnect() { + val url = _uiState.value.url.trim().trimEnd('/') + if (url.isBlank()) { + _uiState.value = _uiState.value.copy(error = "Please enter a server URL") + return + } + + // Show warning dialog if user enters an HTTP URL + if (url.startsWith("http://", ignoreCase = true)) { + _uiState.value = _uiState.value.copy(showHttpWarning = true) + return + } + + // Auto-add https:// if no scheme provided + val normalizedUrl = if (!url.startsWith("http://", ignoreCase = true) && + !url.startsWith("https://", ignoreCase = true) + ) { + "https://$url" + } else { + url + } + + doValidateAndConnect(normalizedUrl) + } + + fun confirmHttpConnection() { + _uiState.value = _uiState.value.copy(showHttpWarning = false) + val url = _uiState.value.url.trim().trimEnd('/') + doValidateAndConnect(url) + } + + fun dismissHttpWarning() { + _uiState.value = _uiState.value.copy(showHttpWarning = false) + } + + private fun doValidateAndConnect(url: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + // Set the URL first so API calls use it + serverDataStore.setServerUrl(url) + + when (val result = configRepository.validateServerUrl(url)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isValidated = true, + ) + } + is Result.Error -> { + // Reset URL on failure + serverDataStore.setServerUrl("") + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Could not connect to server", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun skipIfAlreadyConfigured(): Boolean { + return _uiState.value.hasExistingUrl + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt new file mode 100644 index 0000000..0debd23 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt @@ -0,0 +1,81 @@ +package com.librechat.android.feature.auth.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import androidx.compose.runtime.Immutable +import com.librechat.android.core.data.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class TermsUiState( + val termsText: String = "", + val isLoading: Boolean = false, + val isAccepted: Boolean = false, + val error: String? = null, +) + +/** Loads and accepts server terms of service; use consumeAccepted() to reset the navigation trigger. */ +@HiltViewModel +class TermsViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(TermsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadTerms() + } + + fun loadTerms() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (val result = userRepository.getTerms()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + termsText = result.data.termsOfService ?: "", + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Could not load terms of service. Please try again.", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun acceptTerms() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (userRepository.acceptTerms()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isAccepted = true, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Could not accept terms. Please try again.", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun consumeAccepted() { + _uiState.value = _uiState.value.copy(isAccepted = false) + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt new file mode 100644 index 0000000..173bdd8 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt @@ -0,0 +1,94 @@ +package com.librechat.android.feature.auth.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import androidx.compose.runtime.Immutable +import com.librechat.android.core.data.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class TwoFactorUiState( + val digits: List = List(6) { "" }, + val isBackupMode: Boolean = false, + val backupCode: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isVerified: Boolean = false, +) + +@HiltViewModel +class TwoFactorViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, +) : ViewModel() { + + private val tempToken: String = savedStateHandle["tempToken"] ?: "" + + private val _uiState = MutableStateFlow(TwoFactorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onDigitChanged(index: Int, value: String) { + if (value.length > 1) return + val newDigits = _uiState.value.digits.toMutableList() + newDigits[index] = value + _uiState.value = _uiState.value.copy(digits = newDigits, error = null) + + // Auto-submit when all 6 digits are entered + if (newDigits.all { it.isNotEmpty() }) { + submit() + } + } + + fun onBackupCodeChanged(code: String) { + _uiState.value = _uiState.value.copy(backupCode = code, error = null) + } + + fun toggleBackupMode() { + _uiState.value = _uiState.value.copy( + isBackupMode = !_uiState.value.isBackupMode, + error = null, + digits = List(6) { "" }, + backupCode = "", + ) + } + + fun submit() { + val state = _uiState.value + val code = if (state.isBackupMode) { + state.backupCode.trim() + } else { + state.digits.joinToString("") + } + + if (code.isBlank()) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + when (authRepository.verifyTwoFactor(tempToken, code)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isVerified = true, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Invalid code. Please try again.", + digits = List(6) { "" }, + backupCode = "", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt new file mode 100644 index 0000000..68e281d --- /dev/null +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt @@ -0,0 +1,105 @@ +package com.librechat.android.feature.auth.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import androidx.compose.runtime.Immutable +import com.librechat.android.core.data.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class VerifyEmailUiState( + val email: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isVerified: Boolean = false, + val resendCooldownSeconds: Int = 0, + val resendSuccess: Boolean = false, +) + +@HiltViewModel +class VerifyEmailViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(VerifyEmailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var cooldownJob: Job? = null + + fun setEmail(email: String) { + _uiState.value = _uiState.value.copy(email = email) + } + + fun verifyEmail(token: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (userRepository.verifyEmail(token)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isVerified = true, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Verification failed. The link may have expired.", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun consumeVerified() { + _uiState.value = _uiState.value.copy(isVerified = false) + } + + fun resendVerification() { + val email = _uiState.value.email + if (email.isBlank() || _uiState.value.resendCooldownSeconds > 0) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null, resendSuccess = false) + when (userRepository.resendVerification(email)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + resendSuccess = true, + ) + startCooldown() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Could not resend verification email. Please try again later.", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun startCooldown() { + cooldownJob?.cancel() + cooldownJob = viewModelScope.launch { + for (seconds in COOLDOWN_DURATION_SECONDS downTo 1) { + _uiState.value = _uiState.value.copy(resendCooldownSeconds = seconds) + delay(1000L) + } + _uiState.value = _uiState.value.copy(resendCooldownSeconds = 0) + } + } + + companion object { + private const val COOLDOWN_DURATION_SECONDS = 60 + } +} diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml new file mode 100644 index 0000000..9fae962 --- /dev/null +++ b/feature/auth/src/main/res/values/strings.xml @@ -0,0 +1,85 @@ + + + + Server URL + https://your-server.com + Connect + Connect to LibreChat + Enter your server URL to get started + Insecure connection + You are connecting over HTTP. Your login credentials and messages will be sent unencrypted and could be intercepted by others on the network.\n\nOnly use HTTP for local development servers. + Connect anyway + Cancel + + + Sign in + Email + Password + Continue + Forgot password? + Don\'t have an account? Sign up + Or continue with + + + Continue with Google + Continue with GitHub + Continue with Discord + Continue with Facebook + Continue with Apple + Continue with OpenID + Continue with %1$s + + + Create account + Full name + Username + Confirm password + Sign up + Already have an account? Sign in + + + Reset password + Forgot your password? + Enter your email to receive a reset link + Send reset link + Check your email + We\'ve sent a password reset link to %1$s + Back to sign in + + + Reset Password + Password reset successful + Your password has been updated. You can now sign in with your new password. + Set new password + Enter your new password below + New password + Confirm new password + Reset password + + + Two-Factor Authentication + Enter verification code + Enter backup code + Enter the 6-digit code from your authenticator app + Enter one of your backup codes + Verify + Use authenticator code + Use backup code + Backup code + + + Verify Email + We\'ve sent a verification link to + Click the link in the email to verify your account. + Verification email sent! + Resend verification email + Resend in %1$ds + + + Terms of Service + I Accept + Retry + + + Back + diff --git a/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt b/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt new file mode 100644 index 0000000..63f9700 --- /dev/null +++ b/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt @@ -0,0 +1,242 @@ +package com.librechat.android.feature.auth.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.model.LoginOutcome +import com.librechat.android.core.model.StartupConfig +import com.librechat.android.core.model.User +import com.librechat.android.feature.auth.oauth.OAuthManager +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private val authRepository = mockk(relaxed = true) + private val configRepository = mockk(relaxed = true) + private val oAuthManager = mockk(relaxed = true) + private val serverDataStore = mockk(relaxed = true) + + private val configFlow = MutableStateFlow(null) + + private lateinit var viewModel: LoginViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { configRepository.startupConfig } returns configFlow + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = LoginViewModel( + authRepository = authRepository, + configRepository = configRepository, + oAuthManager = oAuthManager, + serverDataStore = serverDataStore, + ) + + @Test + fun `initial state has empty fields`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.email).isEmpty() + assertThat(state.password).isEmpty() + assertThat(state.isLoading).isFalse() + assertThat(state.error).isNull() + assertThat(state.isLoggedIn).isFalse() + } + + @Test + fun `onEmailChanged updates email and clears error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("user@example.com") + + assertThat(viewModel.uiState.value.email).isEqualTo("user@example.com") + assertThat(viewModel.uiState.value.error).isNull() + } + + @Test + fun `onPasswordChanged updates password and clears error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onPasswordChanged("secret123") + + assertThat(viewModel.uiState.value.password).isEqualTo("secret123") + assertThat(viewModel.uiState.value.error).isNull() + } + + @Test + fun `login with blank email shows error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onPasswordChanged("secret123") + viewModel.login() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Please enter email and password") + assertThat(viewModel.uiState.value.isLoading).isFalse() + } + + @Test + fun `login with blank password shows error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("user@example.com") + viewModel.login() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Please enter email and password") + } + + @Test + fun `successful login sets isLoggedIn true`() = runTest { + val user = User(email = "user@example.com", name = "Test User") + coEvery { authRepository.login("user@example.com", "password123") } returns + Result.Success(LoginOutcome.Success(user)) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("user@example.com") + viewModel.onPasswordChanged("password123") + viewModel.login() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.isLoggedIn).isTrue() + assertThat(state.isLoading).isFalse() + assertThat(state.error).isNull() + } + + @Test + fun `login requiring 2FA sets twoFactorTempToken`() = runTest { + coEvery { authRepository.login("user@example.com", "password123") } returns + Result.Success(LoginOutcome.TwoFactorRequired("temp-token-123")) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("user@example.com") + viewModel.onPasswordChanged("password123") + viewModel.login() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.twoFactorTempToken).isEqualTo("temp-token-123") + assertThat(state.isLoggedIn).isFalse() + assertThat(state.isLoading).isFalse() + } + + @Test + fun `login failure shows error message`() = runTest { + coEvery { authRepository.login("user@example.com", "wrong") } returns + Result.Error(message = "Invalid credentials") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("user@example.com") + viewModel.onPasswordChanged("wrong") + viewModel.login() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.error).isEqualTo("Invalid credentials") + assertThat(state.isLoading).isFalse() + assertThat(state.isLoggedIn).isFalse() + } + + @Test + fun `login failure with null message uses default`() = runTest { + coEvery { authRepository.login("user@example.com", "wrong") } returns + Result.Error(message = null) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("user@example.com") + viewModel.onPasswordChanged("wrong") + viewModel.login() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Login failed") + } + + @Test + fun `startup config updates registration and social login settings`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + configFlow.value = StartupConfig( + registrationEnabled = true, + socialLoginEnabled = true, + socialLogins = listOf("google", "github"), + ) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.registrationEnabled).isTrue() + assertThat(state.socialLoginEnabled).isTrue() + assertThat(state.socialLogins).containsExactly("google", "github") + } + + @Test + fun `startup config with null socialLogins defaults to empty`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + configFlow.value = StartupConfig(socialLogins = null) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.socialLogins).isEmpty() + } + + @Test + fun `login shows loading state during request`() = runTest { + coEvery { authRepository.login(any(), any()) } returns + Result.Success(LoginOutcome.Success(User(email = "user@example.com"))) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("user@example.com") + viewModel.onPasswordChanged("password123") + + // Before login completes, loading should be false initially + assertThat(viewModel.uiState.value.isLoading).isFalse() + + viewModel.login() + advanceUntilIdle() + + // After login completes, loading should be false again + assertThat(viewModel.uiState.value.isLoading).isFalse() + } +} diff --git a/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt b/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt new file mode 100644 index 0000000..2290b0b --- /dev/null +++ b/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt @@ -0,0 +1,267 @@ +package com.librechat.android.feature.auth.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.model.StartupConfig +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RegisterViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private val authRepository = mockk(relaxed = true) + private val configRepository = mockk(relaxed = true) + + private val configFlow = MutableStateFlow(null) + + private lateinit var viewModel: RegisterViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { configRepository.startupConfig } returns configFlow + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = RegisterViewModel( + authRepository = authRepository, + configRepository = configRepository, + ) + + private fun fillValidFields() { + viewModel.onNameChanged("Test User") + viewModel.onEmailChanged("test@example.com") + viewModel.onUsernameChanged("testuser") + viewModel.onPasswordChanged("password123") + viewModel.onConfirmPasswordChanged("password123") + } + + @Test + fun `initial state has empty fields`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.name).isEmpty() + assertThat(state.email).isEmpty() + assertThat(state.username).isEmpty() + assertThat(state.password).isEmpty() + assertThat(state.confirmPassword).isEmpty() + assertThat(state.isLoading).isFalse() + assertThat(state.error).isNull() + assertThat(state.isRegistered).isFalse() + } + + @Test + fun `field changes update state and clear error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onNameChanged("John") + assertThat(viewModel.uiState.value.name).isEqualTo("John") + + viewModel.onEmailChanged("john@test.com") + assertThat(viewModel.uiState.value.email).isEqualTo("john@test.com") + + viewModel.onUsernameChanged("johndoe") + assertThat(viewModel.uiState.value.username).isEqualTo("johndoe") + + viewModel.onPasswordChanged("pass123") + assertThat(viewModel.uiState.value.password).isEqualTo("pass123") + + viewModel.onConfirmPasswordChanged("pass123") + assertThat(viewModel.uiState.value.confirmPassword).isEqualTo("pass123") + } + + @Test + fun `register with blank fields shows error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.register() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("All fields are required") + } + + @Test + fun `register with blank name shows error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onEmailChanged("test@test.com") + viewModel.onUsernameChanged("testuser") + viewModel.onPasswordChanged("password123") + viewModel.onConfirmPasswordChanged("password123") + viewModel.register() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("All fields are required") + } + + @Test + fun `register with short password shows error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onNameChanged("Test") + viewModel.onEmailChanged("test@test.com") + viewModel.onUsernameChanged("testuser") + viewModel.onPasswordChanged("short") + viewModel.onConfirmPasswordChanged("short") + viewModel.register() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Password must be at least 8 characters") + } + + @Test + fun `register with password mismatch shows error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onNameChanged("Test") + viewModel.onEmailChanged("test@test.com") + viewModel.onUsernameChanged("testuser") + viewModel.onPasswordChanged("password123") + viewModel.onConfirmPasswordChanged("different456") + viewModel.register() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Passwords do not match") + } + + @Test + fun `successful registration sets isRegistered true`() = runTest { + coEvery { + authRepository.register("Test User", "test@example.com", "testuser", "password123") + } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + fillValidFields() + viewModel.register() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.isRegistered).isTrue() + assertThat(state.isLoading).isFalse() + assertThat(state.error).isNull() + } + + @Test + fun `registration failure shows error message`() = runTest { + coEvery { + authRepository.register(any(), any(), any(), any()) + } returns Result.Error(message = "Email already exists") + + viewModel = createViewModel() + advanceUntilIdle() + + fillValidFields() + viewModel.register() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.error).isEqualTo("Email already exists") + assertThat(state.isRegistered).isFalse() + assertThat(state.isLoading).isFalse() + } + + @Test + fun `registration failure with null message uses default`() = runTest { + coEvery { + authRepository.register(any(), any(), any(), any()) + } returns Result.Error(message = null) + + viewModel = createViewModel() + advanceUntilIdle() + + fillValidFields() + viewModel.register() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Registration failed") + } + + @Test + fun `startup config updates minPasswordLength`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + configFlow.value = StartupConfig(minPasswordLength = 12) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.minPasswordLength).isEqualTo(12) + } + + @Test + fun `custom minPasswordLength is enforced during validation`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + configFlow.value = StartupConfig(minPasswordLength = 12) + advanceUntilIdle() + + viewModel.onNameChanged("Test") + viewModel.onEmailChanged("test@test.com") + viewModel.onUsernameChanged("testuser") + viewModel.onPasswordChanged("short1234") // 9 chars, less than 12 + viewModel.onConfirmPasswordChanged("short1234") + viewModel.register() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Password must be at least 12 characters") + } + + @Test + fun `register calls authRepository with correct parameters`() = runTest { + coEvery { authRepository.register(any(), any(), any(), any()) } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + fillValidFields() + viewModel.register() + advanceUntilIdle() + + coVerify { + authRepository.register("Test User", "test@example.com", "testuser", "password123") + } + } + + @Test + fun `changing field after error clears the error`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.register() // triggers "All fields are required" + advanceUntilIdle() + assertThat(viewModel.uiState.value.error).isNotNull() + + viewModel.onNameChanged("Test") + assertThat(viewModel.uiState.value.error).isNull() + } +} diff --git a/feature/chat/CLAUDE.md b/feature/chat/CLAUDE.md new file mode 100644 index 0000000..0df2fe0 --- /dev/null +++ b/feature/chat/CLAUDE.md @@ -0,0 +1,101 @@ +# feature:chat + +## Screen States +`ChatScreenState` enum: `LANDING` | `LOADING` | `ACTIVE` +- **Landing**: no conversation selected, shows greeting + model icon + optional conversation starters +- **Loading**: spinner while fetching messages for a conversation +- **Active**: message list + streaming content + input area + +## Navigation +- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}") +- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen +- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `chat/{id}` after the first stream completes (data persisted to Room), keeping `new_chat` in the back stack +- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat` + +## Message Tree +- Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID) +- `MessageTree.kt` builds tree via `buildActiveMessagePath(messages, activeBranches)` +- `activeBranches: Map` tracks which sibling is displayed per parent +- `switchBranch(parentMessageId, siblingIndex)` updates branch selection and rebuilds display list +- Editing a user message creates a new sibling (new branch); regenerating an AI message also creates a sibling + +## Content Rendering +- `ContentPartRenderer` dispatches by `ContentPart.type`: + - `text` -> `MarkdownContent` (custom regex parser with LaTeX support) + - `think` -> collapsible thinking block + - `tool_call` -> `StreamingToolCallCard` (expandable, shows args/output) + - `image_file` / `image_url` -> inline AsyncImage, tap opens `FullscreenImageViewer` + - `error` -> red-styled error display +- `CodeBlock` renders fenced code with syntax highlighting, language badge, and copy button (3s checkmark) +- LaTeX: `LatexBlock` (block math) and `LatexInline` (inline math) via AndroidMath native rendering + - Supports `$$...$$`, `\[...\]` (block) and `$...$`, `\(...\)` (inline) + - `$...$` requires heuristic check (`looksLikeLatex()`) to avoid false positives on dollar amounts + - Falls back to monospace text if AndroidMath can't parse the expression + +## Streaming +- `ChatViewModel.sendMessage()` calls `ChatRepository.startChat()` which returns `Flow` +- Two-phase: POST /api/agents/chat -> streamId, then GET SSE stream +- `streamingBuffer: StringBuilder` accumulates text deltas +- Tool calls tracked in `activeToolCalls` list (start -> complete lifecycle) +- `stopGeneration()` cancels stream job and calls `chatRepository.abortChat()` +- `onPause()`/`onResume()` handle app backgrounding: checks stream status, resumes if still active + +## Key Components +| Component | Purpose | +|-----------|---------| +| `ChatInput` | Auto-growing text field, send/stop button, file attach, voice | +| `MessageBubble` | Icon + content column + action bar + sibling nav | +| `MessageList` | LazyColumn of MessageBubbles with scroll-to-bottom FAB | +| `ModelSelectorSheet` | Bottom sheet: search + endpoint-grouped model list | +| `ModelSelectorButton` | Header chip showing current model | +| `SiblingNavigator` | "1/3" with chevrons for branch switching | +| `LandingContent` | Greeting, entity icon, conversation starters | +| `StreamingIndicator` | Blinking cursor during active generation | +| `PresetPicker` | Preset selection menu | +| `SavePresetDialog` | Dialog to name and save current config as preset | + +## Prompts +- `PromptsLibraryScreen` and `PromptDetailScreen` live in `chat/prompts/` +- `PromptsViewModel` loads prompt groups from `PromptRepository` +- `handlePromptMention()` on ChatViewModel inserts prompt command text at `@` position + +## Chat Input Toolbar +- `ChatInputToolbar` renders toggles for enabled tools (web search, code interpreter, file search) +- Tool state tracked as `Set` in `ChatUiState.enabledTools` +- `ToolsDropdownMenu` provides overflow for additional tool options +- Toolbar wired into `ChatInput` above the text field + +## Content Cards +- `ImageGenCard` renders DALL-E / image generation results (spinner while generating, AsyncImage when done) +- `LogContentCard` renders expandable log output blocks with monospace text +- Both dispatched from `ContentPartRenderer` — tool calls with name containing `dall` route to ImageGenCard + +## Artifacts +- `ArtifactDetector` parses remark-directive format `:::artifact{identifier="id" type="..." title="..."}` with backtick-fenced content; `groupArtifactVersions()` groups by identifier +- Supported types: `text/html`, `image/svg+xml`, `application/vnd.react`, `application/vnd.mermaid`, `text/markdown`/`text/md`, `text/plain`, `application/vnd.code-html` +- `MermaidWebContent` renders Mermaid diagrams via CDN mermaid.js with zoom controls and dark theme +- `MarkdownWebContent` renders Markdown via CDN marked.js + highlight.js with GFM and syntax highlighting +- HTML/React/SVG templates include Tailwind CDN, theme CSS vars, and error handling +- React template preprocesses import/export statements for browser-compatible execution +- `ArtifactPanel` supports fullscreen Dialog mode, version switching, loading indicator, and WebView error overlay +- `ArtifactButton` shows type-specific icons and subtitle (e.g. "Mermaid Diagram", "React Component") +- `ContentPartRenderer` wires `groupArtifactVersions()` to pass version lists to ArtifactButton/ArtifactPanel +- `ArtifactVersionNav` provides prev/next arrows with "v2/3" indicator +- `ArtifactDownloadHelper` shares artifacts via FileProvider temp file + system share sheet. Maps 25+ language extensions including `.mmd` for Mermaid. Sanitizes filenames to 100 chars +- **Gotcha**: FileProvider authority must match app's declared authority in AndroidManifest + +## Media Players +- `VideoContentPlayer` uses ExoPlayer (media3) — 16:9 aspect ratio Card, lifecycle-aware release +- `AudioContentPlayer` uses MediaPlayer — play/pause + seekbar, 250ms polling for progress +- `AudioContentPlayerFromBytes` variant writes bytes to temp file for MediaPlayer +- **Gotcha**: Both players must release resources on dispose — use `DisposableEffect` + +## Feedback Comment Dialog +- Thumbs-down opens `FeedbackCommentDialog` before submitting; thumbs-up fires immediately +- Comment is optional — empty string is a valid submission +- Toggling off an existing thumbs-down (already selected) calls `onFeedback(null)` directly, skipping the dialog + +## Message Timestamps +- `MessageTimestamp` shows relative/absolute time, toggled on tap +- Parses multiple ISO 8601 format variants; falls back gracefully on invalid input +- Integrated into `MessageBubble` action row diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts new file mode 100644 index 0000000..bd0b9b0 --- /dev/null +++ b/feature/chat/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("librechat.android.feature") +} + +android { + namespace = "com.librechat.android.feature.chat" +} + +dependencies { + implementation(project(":core:network")) + implementation(libs.timber) + implementation(libs.kotlinx.serialization.json) + implementation(libs.coil.compose) + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + implementation(libs.media3.datasource) + implementation(libs.fuzzywuzzy) + implementation(libs.markdown.renderer.m3) + implementation(libs.markdown.renderer.coil3) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ChatDisplayData.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ChatDisplayData.kt new file mode 100644 index 0000000..c0e6e74 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ChatDisplayData.kt @@ -0,0 +1,26 @@ +package com.librechat.android.feature.chat + +import androidx.compose.runtime.Immutable + +@Immutable +data class PresetDisplayData( + val presetId: String?, + val title: String, + val endpointLabel: String?, + val model: String?, +) + +@Immutable +data class PromptMentionDisplayData( + val name: String, + val command: String?, + val oneliner: String?, +) + +@Immutable +data class McpServerDisplayData( + val name: String, + val title: String?, + val description: String?, + val isConnected: Boolean, +) diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt new file mode 100644 index 0000000..a9895d1 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt @@ -0,0 +1,64 @@ +package com.librechat.android.feature.chat + +import android.net.Uri +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Data representing content shared to the app via Android share intents. + * + * @param text Shared text content (from EXTRA_TEXT). Pre-filled in the message input. + * @param fileUris URIs of shared files/images (from EXTRA_STREAM). Attached as pending files. + */ +data class SharedContent( + val text: String? = null, + val fileUris: List = emptyList(), +) + +/** + * Singleton that holds pending shared content from Android share intents. + * + * The app module's MainActivity writes to this when it receives a share intent, + * and ChatViewModel reads from it when initializing a new chat. + * The data is cleared after consumption to prevent re-processing. + * + * This lives in the feature:chat module (rather than app) so that ChatViewModel + * can access it without a circular dependency. The app module writes to it + * via the public [setPendingShare] method. + * + * Active ChatViewModels observe [shareAvailable] to reactively consume shared + * content when the user is already on a chat screen (instead of always forcing + * navigation to a new chat). + */ +object ShareIntentConsumer { + + @Volatile + private var pendingShare: SharedContent? = null + + /** + * Emits a Unit each time new shared content is set, allowing active + * ChatViewModels to reactively consume it without polling. + */ + private val _shareAvailable = MutableSharedFlow(extraBufferCapacity = 1) + val shareAvailable: SharedFlow = _shareAvailable.asSharedFlow() + + /** + * Sets pending shared content. Called by MainActivity when a share intent arrives. + * Emits on [shareAvailable] so any active ChatViewModel can pick it up. + */ + fun setPendingShare(data: SharedContent) { + pendingShare = data + _shareAvailable.tryEmit(Unit) + } + + /** + * Atomically retrieves and clears the pending shared content. + * Returns null if no share data is pending. + */ + fun consume(): SharedContent? { + val data = pendingShare + pendingShare = null + return data + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt new file mode 100644 index 0000000..be3e872 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt @@ -0,0 +1,114 @@ +package com.librechat.android.feature.chat.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import java.io.File + +/** + * Manages audio recording using Android's MediaRecorder. + * Records to a temporary file in the app's cache directory, returning + * the raw bytes when stopped. Uses OGG/Opus on API 29+ and 3GP/AMR_NB on older devices. + */ +class VoiceRecorder(private val context: Context) { + + companion object { + private const val TAG = "VoiceRecorder" + } + + private var mediaRecorder: MediaRecorder? = null + private var outputFile: File? = null + private var isCurrentlyRecording = false + + val isRecording: Boolean + get() = isCurrentlyRecording + + val mimeType: String + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "audio/ogg" + } else { + "audio/3gpp" + } + + fun start() { + if (isCurrentlyRecording) return + + val extension = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "ogg" else "3gp" + outputFile = File(context.cacheDir, "voice_recording_${System.currentTimeMillis()}.$extension") + + try { + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + + recorder.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setOutputFormat(MediaRecorder.OutputFormat.OGG) + setAudioEncoder(MediaRecorder.AudioEncoder.OPUS) + } else { + setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP) + setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB) + } + setAudioSamplingRate(16_000) + setAudioChannels(1) + setOutputFile(outputFile?.absolutePath) + prepare() + start() + } + + mediaRecorder = recorder + isCurrentlyRecording = true + } catch (e: Exception) { + Log.e(TAG, "Failed to start voice recording", e) + cleanup() + throw e + } + } + + fun stop(): ByteArray? { + if (!isCurrentlyRecording) return null + + return try { + mediaRecorder?.apply { + stop() + release() + } + mediaRecorder = null + isCurrentlyRecording = false + + val file = outputFile + val bytes = file?.readBytes() + file?.delete() + outputFile = null + bytes + } catch (e: Exception) { + Log.e(TAG, "Failed to stop voice recording", e) + cleanup() + null + } + } + + fun cancel() { + cleanup() + } + + private fun cleanup() { + try { + mediaRecorder?.apply { + stop() + release() + } + } catch (_: Exception) { + // Recorder may not have been started + } + mediaRecorder = null + isCurrentlyRecording = false + outputFile?.delete() + outputFile = null + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AgentHandoffCard.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AgentHandoffCard.kt new file mode 100644 index 0000000..51df160 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AgentHandoffCard.kt @@ -0,0 +1,134 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Data class representing an agent handoff event. + */ +data class AgentHandoff( + val fromAgent: String?, + val toAgent: String?, + val reason: String?, +) + +/** + * Card displaying an agent handoff as a timeline: + * [FromAgent] -> arrow -> [ToAgent] with reason text below. + */ +@Composable +fun AgentHandoffCard( + handoff: AgentHandoff, + modifier: Modifier = Modifier, +) { + val unknownAgent = stringResource(R.string.agent_unknown) + val handoffCd = stringResource(R.string.cd_agent_handoff, handoff.fromAgent ?: unknownAgent, handoff.toAgent ?: unknownAgent) + Card( + modifier = modifier + .fillMaxWidth() + .semantics { + contentDescription = handoffCd + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Timeline row: [From] -> [To] + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + // From agent + AgentLabel( + name = handoff.fromAgent ?: stringResource(R.string.agent_previous), + modifier = Modifier.weight(1f), + ) + + // Arrow + Icon( + imageVector = Icons.Default.SwapHoriz, + contentDescription = stringResource(R.string.cd_handed_off_to), + modifier = Modifier + .padding(horizontal = 8.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + // To agent + AgentLabel( + name = handoff.toAgent ?: stringResource(R.string.agent_next), + modifier = Modifier.weight(1f), + ) + } + + // Reason text + if (!handoff.reason.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = handoff.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun AgentLabel( + name: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.SmartToy, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = name, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AttachmentChipsRow.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AttachmentChipsRow.kt new file mode 100644 index 0000000..254c13b --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AttachmentChipsRow.kt @@ -0,0 +1,341 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.FindInPage +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.TravelExplore +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.feature.chat.McpServerDisplayData +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Floating row of Material 3 chips above the chat input field. + * Shows indicator chips for attached files, enabled tools (Web Search, Code, File Search), + * and selected MCP servers. Only visible when there is at least one chip to display. + */ +@Composable +fun AttachmentChipsRow( + attachedFiles: List, + enabledTools: Set, + onRemoveFile: (AttachedFile) -> Unit, + onToggleTool: (String) -> Unit, + modifier: Modifier = Modifier, + mcpServers: List = emptyList(), + selectedMcpServerNames: Set = emptySet(), + onToggleMcpServer: (String) -> Unit = {}, +) { + val hasFiles = attachedFiles.isNotEmpty() + val hasWebSearch = ToolConstants.WEB_SEARCH in enabledTools + val hasCode = ToolConstants.CODE_INTERPRETER in enabledTools + val hasFileSearch = ToolConstants.FILE_SEARCH in enabledTools + val selectedMcpServers = mcpServers.filter { it.name in selectedMcpServerNames } + val hasMcp = selectedMcpServers.isNotEmpty() + val hasAnyChip = hasFiles || hasWebSearch || hasCode || hasFileSearch || hasMcp + + var showFilePreview by remember { mutableStateOf(false) } + + // Dismiss preview when all files are removed + if (!hasFiles) { + showFilePreview = false + } + + AnimatedVisibility( + visible = hasAnyChip, + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut(), + modifier = modifier, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Inline file preview row — appears ABOVE the chips row + AnimatedVisibility( + visible = showFilePreview && hasFiles, + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut(), + ) { + InlineFilePreviewRow( + files = attachedFiles, + onRemoveFile = onRemoveFile, + ) + } + + // Chips row + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Files chip + if (hasFiles) { + FilesChip( + files = attachedFiles, + onClick = { showFilePreview = !showFilePreview }, + ) + } + + // Web Search chip + if (hasWebSearch) { + ToolIndicatorChip( + label = stringResource(R.string.tool_web_search), + icon = Icons.Default.TravelExplore, + semanticDescription = stringResource(R.string.cd_web_search_enabled), + ) + } + + // Code chip + if (hasCode) { + ToolIndicatorChip( + label = stringResource(R.string.tool_code), + icon = Icons.Default.Code, + semanticDescription = stringResource(R.string.cd_code_enabled), + ) + } + + // File Search chip + if (hasFileSearch) { + ToolIndicatorChip( + label = stringResource(R.string.tool_file_search), + icon = Icons.Default.FindInPage, + semanticDescription = stringResource(R.string.cd_file_search_enabled), + ) + } + + // MCP server chips — one chip per selected server + selectedMcpServers.forEach { server -> + ToolIndicatorChip( + label = server.title ?: server.name, + icon = Icons.Default.Extension, + semanticDescription = "${server.title ?: server.name} MCP server enabled", + ) + } + } + } + } +} + +/** + * Chip for attached files showing count. Tapping toggles the inline preview row. + */ +@Composable +private fun FilesChip( + files: List, + onClick: () -> Unit, +) { + AssistChip( + onClick = onClick, + label = { + Text( + text = stringResource(R.string.files_count, files.size), + style = MaterialTheme.typography.labelMedium, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + leadingIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + modifier = Modifier.semantics { + contentDescription = "${files.size} files attached, tap to preview" + role = Role.Button + }, + ) +} + +/** + * Display-only tool indicator chip. Shows that a tool or MCP server is enabled. + * Not interactive — users manage tools via the tools bottom sheet. + */ +@Composable +private fun ToolIndicatorChip( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + semanticDescription: String, +) { + AssistChip( + onClick = {}, + label = { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + ) + }, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + leadingIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + modifier = Modifier.semantics { + contentDescription = semanticDescription + }, + ) +} + +/** + * Inline row of file thumbnails/icons shown above the chips row. + * Each preview has a remove (X) button. + */ +@Composable +private fun InlineFilePreviewRow( + files: List, + onRemoveFile: (AttachedFile) -> Unit, +) { + Surface( + shape = RoundedCornerShape(12.dp), + tonalElevation = 3.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 4.dp), + ) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + files.forEach { file -> + FilePreviewItem( + file = file, + onRemove = { onRemoveFile(file) }, + ) + } + } + } +} + +/** + * Individual file preview: image thumbnail or file icon with filename. + * Has a small circular X button in the top-right corner. + */ +@Composable +private fun FilePreviewItem( + file: AttachedFile, + onRemove: () -> Unit, +) { + val attachedFileCd = stringResource(R.string.cd_attached_file, file.name) + Box( + modifier = Modifier + .size(56.dp) + .semantics { + contentDescription = attachedFileCd + }, + ) { + if (file.isImage) { + AsyncImage( + model = file.uri, + contentDescription = stringResource(R.string.cd_preview_file, file.name), + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Column( + modifier = Modifier + .size(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(8.dp), + ) + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = file.name, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Remove button + IconButton( + onClick = onRemove, + modifier = Modifier + .size(20.dp) + .align(Alignment.TopEnd), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_remove_file, file.name), + modifier = Modifier.size(12.dp), + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt new file mode 100644 index 0000000..bd73780 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt @@ -0,0 +1,90 @@ +package com.librechat.android.feature.chat.components + +import android.util.Base64 +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.ByteArrayDataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.ui.PlayerView + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun AudioContent( + data: String?, + format: String?, + modifier: Modifier = Modifier, +) { + if (data.isNullOrBlank()) return + + val context = LocalContext.current + val mimeType = when (format?.lowercase()) { + "wav" -> "audio/wav" + "mp3" -> "audio/mpeg" + "ogg" -> "audio/ogg" + "flac" -> "audio/flac" + "webm" -> "audio/webm" + else -> "audio/wav" + } + + val exoPlayer = remember(data) { + val audioBytes = try { + Base64.decode(data, Base64.DEFAULT) + } catch (_: IllegalArgumentException) { + return@remember null + } + + val dataSource = ByteArrayDataSource(audioBytes) + val dataSpec = DataSpec(android.net.Uri.parse("data:$mimeType;base64,")) + + ExoPlayer.Builder(context).build().apply { + val mediaSource = ProgressiveMediaSource.Factory { dataSource } + .createMediaSource(MediaItem.fromUri("data:$mimeType;base64,placeholder")) + setMediaSource(mediaSource) + prepare() + } + } + + if (exoPlayer == null) return + + DisposableEffect(exoPlayer) { + onDispose { + exoPlayer.release() + } + } + + AndroidView( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + factory = { + PlayerView(it).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + player = exoPlayer + useController = true + controllerShowTimeoutMs = 0 + showController() + } + }, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt new file mode 100644 index 0000000..d35896b --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt @@ -0,0 +1,211 @@ +package com.librechat.android.feature.chat.components + +import android.media.MediaPlayer +import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import java.io.File +import java.util.Locale +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** Plays audio via MediaPlayer with play/pause and seekbar. Polls progress every 250ms. Releases on dispose. */ +@Composable +fun AudioContentPlayer( + audioUrl: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var durationMs by remember { mutableIntStateOf(0) } + var currentMs by remember { mutableIntStateOf(0) } + var isPrepared by remember { mutableStateOf(false) } + + val mediaPlayer = remember { + MediaPlayer().apply { + setOnCompletionListener { + isPlaying = false + progress = 0f + currentMs = 0 + seekTo(0) + } + setOnPreparedListener { + isPrepared = true + durationMs = it.duration + } + setOnErrorListener { _, what, extra -> + Log.e("AudioContentPlayer", "MediaPlayer error: what=$what, extra=$extra") + isPlaying = false + true + } + } + } + + // Prepare media source + LaunchedEffect(audioUrl) { + try { + mediaPlayer.reset() + isPrepared = false + mediaPlayer.setDataSource(audioUrl) + mediaPlayer.prepareAsync() + } catch (e: Exception) { + Log.e("AudioContentPlayer", "Failed to set data source", e) + } + } + + // Progress polling while playing + LaunchedEffect(isPlaying) { + while (isPlaying) { + try { + val pos = mediaPlayer.currentPosition + currentMs = pos + val dur = mediaPlayer.duration + if (dur > 0) { + progress = pos.toFloat() / dur + } + } catch (_: IllegalStateException) { + break + } + delay(250L) + } + } + + DisposableEffect(Unit) { + onDispose { + mediaPlayer.release() + } + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { + if (!isPrepared) return@IconButton + if (isPlaying) { + mediaPlayer.pause() + isPlaying = false + } else { + mediaPlayer.start() + isPlaying = true + } + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = stringResource(if (isPlaying) R.string.cd_pause else R.string.cd_play), + modifier = Modifier.size(24.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) { + Text( + text = formatDuration(currentMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = formatDuration(durationMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +/** Variant of [AudioContentPlayer] that writes raw bytes to a temp file before playback. */ +@Composable +fun AudioContentPlayerFromBytes( + audioBytes: ByteArray, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val tempFile = remember(audioBytes) { + File.createTempFile("audio_", ".mp3", context.cacheDir).apply { + writeBytes(audioBytes) + } + } + DisposableEffect(tempFile) { + onDispose { + tempFile.delete() + } + } + AudioContentPlayer( + audioUrl = tempFile.absolutePath, + modifier = modifier, + ) +} + +private fun formatDuration(ms: Int): String { + val totalSeconds = ms / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return String.format(Locale.US, "%d:%02d", minutes, seconds) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt new file mode 100644 index 0000000..f313a35 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt @@ -0,0 +1,614 @@ +package com.librechat.android.feature.chat.components + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.chat.McpServerDisplayData +import java.io.File +import com.librechat.android.feature.chat.PromptMentionDisplayData +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +data class AttachedFile( + val uri: Uri, + val name: String, + val isImage: Boolean = false, + val uploadProgress: Float? = null, + /** Server-assigned file ID after successful upload. Null while uploading. */ + val fileId: String? = null, + /** Server file path returned from upload. */ + val filepath: String? = null, + /** MIME type of the file. */ + val type: String? = null, + /** Image width in pixels (if applicable). */ + val width: Int? = null, + /** Image height in pixels (if applicable). */ + val height: Int? = null, + /** Whether the upload has failed. */ + val uploadFailed: Boolean = false, +) + +@Composable +fun ChatInput( + inputText: String, + isStreaming: Boolean, + onInputChanged: (String) -> Unit, + onSend: () -> Unit, + onStop: () -> Unit, + modifier: Modifier = Modifier, + attachedFiles: List = emptyList(), + onFilesSelected: (List) -> Unit = {}, + onRemoveFile: (AttachedFile) -> Unit = {}, + promptSuggestions: List = emptyList(), + onPromptSelected: (PromptMentionDisplayData) -> Unit = {}, + onSlashCommandSelected: (PromptMentionDisplayData) -> Unit = {}, + isRecording: Boolean = false, + isTranscribing: Boolean = false, + onStartRecording: () -> Unit = {}, + onStopRecording: () -> Unit = {}, + onImagePasted: ((Uri) -> Unit)? = null, + enabledTools: Set = emptySet(), + onToggleTool: (String) -> Unit = {}, + mcpServers: List = emptyList(), + selectedMcpServerNames: Set = emptySet(), + onToggleMcpServer: (String) -> Unit = {}, + onOpenModelParameters: () -> Unit = {}, + onOpenModelSelector: () -> Unit = {}, + selectedModelDisplay: String? = null, + isCodeInterpreterAvailable: Boolean = true, +) { + val cdOpenToolsMenu = stringResource(R.string.cd_open_tools_menu) + val cdPasteImage = stringResource(R.string.cd_paste_image) + val cdMessageInput = stringResource(R.string.cd_message_input) + val cdStopVoiceRec = stringResource(R.string.cd_stop_voice_recording) + val cdStartVoiceRec = stringResource(R.string.cd_start_voice_recording) + val cdStopGen = stringResource(R.string.cd_stop_generation) + val cdSendMsg = stringResource(R.string.cd_send_message) + val context = LocalContext.current + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> + if (uris.isNotEmpty()) { + onFilesSelected(uris) + } + } + + // Photo picker (gallery) launcher using modern PickMultipleVisualMedia + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(), + ) { uris -> + if (uris.isNotEmpty()) { + onFilesSelected(uris) + } + } + + // Camera launcher: stores the photo in a temp file via FileProvider + var cameraPhotoUri by remember { mutableStateOf(null) } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + ) { success -> + val uri = cameraPhotoUri + if (success && uri != null) { + onFilesSelected(listOf(uri)) + } + cameraPhotoUri = null + } + + // Camera permission launcher + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + val photoFile = createCameraPhotoFile(context) + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile, + ) + cameraPhotoUri = uri + cameraLauncher.launch(uri) + } + } + + val onTakePhoto: () -> Unit = { + val hasCameraPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCameraPermission) { + val photoFile = createCameraPhotoFile(context) + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile, + ) + cameraPhotoUri = uri + cameraLauncher.launch(uri) + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + val onPickPhotos: () -> Unit = { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + + val focusRequester = remember { FocusRequester() } + val clipboardManager = LocalClipboardManager.current + var showToolsSheet by remember { mutableStateOf(false) } + + // Use TextFieldValue internally so we can control cursor position. + // When inputText changes externally (e.g. STT result, prompt insertion), + // place the cursor at the end of the new text. + var textFieldValue by remember { + mutableStateOf(TextFieldValue(inputText, selection = TextRange(inputText.length))) + } + if (textFieldValue.text != inputText) { + textFieldValue = TextFieldValue(inputText, selection = TextRange(inputText.length)) + } + + val hasActiveTools by remember(enabledTools, selectedMcpServerNames) { + derivedStateOf { + enabledTools.isNotEmpty() || selectedMcpServerNames.isNotEmpty() + } + } + + val surfaceColor = MaterialTheme.colorScheme.surface + Box( + modifier = modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + surfaceColor.copy(alpha = 0.7f), + surfaceColor.copy(alpha = 0.95f), + ), + ), + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + // Floating attachment and tool indicator chips + AttachmentChipsRow( + attachedFiles = attachedFiles, + enabledTools = enabledTools, + onRemoveFile = onRemoveFile, + onToggleTool = onToggleTool, + mcpServers = mcpServers, + selectedMcpServerNames = selectedMcpServerNames, + onToggleMcpServer = onToggleMcpServer, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + // "+" button to open tools bottom sheet + Box { + FilledTonalIconButton( + onClick = { showToolsSheet = true }, + modifier = Modifier + .size(48.dp) + .semantics { + contentDescription = cdOpenToolsMenu + role = Role.Button + }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + } + // Active tools badge + if (hasActiveTools) { + Box( + modifier = Modifier + .size(10.dp) + .align(Alignment.TopEnd) + .offset(x = (-2).dp, y = 2.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) + } + } + + // Paste image button (shown only when clipboard has image content) + if (onImagePasted != null) { + IconButton( + onClick = { + // Clipboard image pasting is handled by the screen + onImagePasted(Uri.EMPTY) + }, + modifier = Modifier + .size(48.dp) + .semantics { + contentDescription = cdPasteImage + role = Role.Button + }, + ) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Detect @mention query from input text + val mentionQuery by remember(inputText) { + derivedStateOf { + val atIndex = inputText.lastIndexOf('@') + if (atIndex >= 0) { + val afterAt = inputText.substring(atIndex + 1) + // Only show suggestions if there's no space after @ (still typing the mention) + if (!afterAt.contains(' ')) afterAt else null + } else null + } + } + + val filteredPrompts by remember(mentionQuery, promptSuggestions) { + derivedStateOf { + val query = mentionQuery + if (query != null && promptSuggestions.isNotEmpty()) { + promptSuggestions.filter { group -> + group.name.contains(query, ignoreCase = true) || + group.command?.contains(query, ignoreCase = true) == true + }.take(5) + } else emptyList() + } + } + + // Detect slash command query: "/" at position 0 + val slashQuery by remember(inputText) { + derivedStateOf { + if (inputText.startsWith("/")) { + val afterSlash = inputText.substring(1) + // Only show suggestions if there's no space (still typing the command) + if (!afterSlash.contains(' ')) afterSlash else null + } else null + } + } + + val filteredSlashCommands by remember(slashQuery, promptSuggestions) { + derivedStateOf { + val query = slashQuery + if (query != null && promptSuggestions.isNotEmpty()) { + promptSuggestions.filter { group -> + val cmd = group.command + cmd != null && cmd.contains(query, ignoreCase = true) + }.take(5) + } else emptyList() + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + Box(modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = textFieldValue, + onValueChange = { newValue -> + textFieldValue = newValue + onInputChanged(newValue.text) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp, max = 160.dp) + .focusRequester(focusRequester) + .semantics { + contentDescription = cdMessageInput + }, + placeholder = { + Text( + text = if (isRecording) { + stringResource(R.string.recording) + } else if (!selectedModelDisplay.isNullOrBlank()) { + stringResource(R.string.hint_message_model, selectedModelDisplay) + } else { + stringResource(R.string.hint_message) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + textStyle = MaterialTheme.typography.bodyLarge, + shape = RoundedCornerShape(24.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Default, + capitalization = KeyboardCapitalization.Sentences, + ), + keyboardActions = KeyboardActions.Default, + maxLines = 6, + trailingIcon = { + IconButton( + onClick = { + if (isRecording) { + onStopRecording() + } else { + onStartRecording() + } + }, + modifier = Modifier + .size(40.dp) + .semantics { + contentDescription = if (isRecording) { + cdStopVoiceRec + } else { + cdStartVoiceRec + } + role = Role.Button + }, + enabled = !isTranscribing, + ) { + if (isTranscribing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else if (isRecording) { + val infiniteTransition = rememberInfiniteTransition( + label = "recording_pulse", + ) + val pulseAlpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = tween(600), + repeatMode = RepeatMode.Reverse, + ), + label = "pulse_alpha", + ) + Box( + modifier = Modifier + .size(20.dp) + .alpha(pulseAlpha) + .background( + color = MaterialTheme.colorScheme.error, + shape = CircleShape, + ), + ) + } else { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) + + // @mention dropdown + DropdownMenu( + expanded = filteredPrompts.isNotEmpty() && filteredSlashCommands.isEmpty(), + onDismissRequest = { /* Dismissed by typing or selecting */ }, + ) { + filteredPrompts.forEach { group -> + DropdownMenuItem( + text = { + Column { + Text( + text = group.name, + style = MaterialTheme.typography.bodyMedium, + ) + val oneliner = group.oneliner + if (!oneliner.isNullOrBlank()) { + Text( + text = oneliner, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + onClick = { onPromptSelected(group) }, + ) + } + } + + // Slash command dropdown + SlashCommandMenu( + filteredCommands = filteredSlashCommands, + onCommandSelected = onSlashCommandSelected, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Send / Stop button + AnimatedContent( + targetState = isStreaming, + transitionSpec = { + (fadeIn() + scaleIn()).togetherWith(fadeOut() + scaleOut()) + }, + label = "send_stop_toggle", + ) { streaming -> + if (streaming) { + IconButton( + onClick = onStop, + modifier = Modifier + .size(56.dp) + .semantics { + contentDescription = cdStopGen + role = Role.Button + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = null, + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.error, + shape = CircleShape, + ), + ) + } + } else { + val canSend = inputText.isNotBlank() || attachedFiles.isNotEmpty() + IconButton( + onClick = onSend, + modifier = Modifier + .size(56.dp) + .semantics { + contentDescription = cdSendMsg + role = Role.Button + }, + enabled = canSend, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (canSend) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + contentColor = if (canSend) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = null, + ) + } + } + } + } + } + } + + // Tools bottom sheet + if (showToolsSheet) { + ChatToolsBottomSheet( + enabledTools = enabledTools, + onToggleTool = onToggleTool, + mcpServers = mcpServers, + selectedMcpServerNames = selectedMcpServerNames, + onToggleMcpServer = onToggleMcpServer, + onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) }, + onTakePhoto = onTakePhoto, + onPickPhotos = onPickPhotos, + onOpenModelParameters = onOpenModelParameters, + onOpenModelSelector = onOpenModelSelector, + selectedModelDisplay = selectedModelDisplay, + onDismiss = { showToolsSheet = false }, + isCodeInterpreterAvailable = isCodeInterpreterAvailable, + ) + } +} + +/** + * Creates a temporary file for the camera to write a photo into. + * Stored in the app's cache directory under `camera_photos/` which is + * registered in the FileProvider paths XML. + */ +private fun createCameraPhotoFile(context: android.content.Context): File { + val cameraDir = File(context.cacheDir, "camera_photos") + if (!cameraDir.exists()) { + cameraDir.mkdirs() + } + return File.createTempFile("photo_", ".jpg", cameraDir) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInputToolbar.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInputToolbar.kt new file mode 100644 index 0000000..946971c --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInputToolbar.kt @@ -0,0 +1,147 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.FindInPage +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.feature.chat.McpServerDisplayData +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +data class ToolToggle( + val id: String, + val label: String, + val icon: ImageVector, +) + +private val defaultTools = listOf( + ToolToggle(ToolConstants.WEB_SEARCH, "Web Search", Icons.Default.Search), + ToolToggle(ToolConstants.CODE_INTERPRETER, "Code", Icons.Default.Code), + ToolToggle(ToolConstants.FILE_SEARCH, "File Search", Icons.Default.FindInPage), +) + +/** Renders a toggleable toolbar for chat tool selection. Tool IDs must match backend tool identifiers. */ +@Composable +fun ChatInputToolbar( + enabledTools: Set, + onToggleTool: (String) -> Unit, + modifier: Modifier = Modifier, + mcpServers: List = emptyList(), + selectedMcpServerNames: Set = emptySet(), + onToggleMcpServer: (String) -> Unit = {}, + isCodeInterpreterAvailable: Boolean = true, +) { + var showMoreTools by remember { mutableStateOf(false) } + + val visibleTools = remember(isCodeInterpreterAvailable) { + if (isCodeInterpreterAvailable) { + defaultTools + } else { + defaultTools.filter { it.id != ToolConstants.CODE_INTERPRETER } + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + visibleTools.forEach { tool -> + FilterChip( + selected = tool.id in enabledTools, + onClick = { onToggleTool(tool.id) }, + label = { + Text( + text = tool.label, + style = MaterialTheme.typography.labelMedium, + ) + }, + leadingIcon = { + Icon( + imageVector = tool.icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.semantics { + contentDescription = if (tool.id in enabledTools) { + "${tool.label} enabled" + } else { + "${tool.label} disabled" + } + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + if (mcpServers.isNotEmpty()) { + McpServerSelector( + servers = mcpServers, + selectedServerNames = selectedMcpServerNames, + onToggleServer = onToggleMcpServer, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + IconButton( + onClick = { showMoreTools = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.MoreHoriz, + contentDescription = stringResource(R.string.cd_more_tools), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility( + visible = showMoreTools, + enter = fadeIn(), + exit = fadeOut(), + ) { + ToolsDropdownMenu( + enabledTools = enabledTools, + onToggleTool = onToggleTool, + onDismiss = { showMoreTools = false }, + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatToolsBottomSheet.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatToolsBottomSheet.kt new file mode 100644 index 0000000..9605598 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatToolsBottomSheet.kt @@ -0,0 +1,435 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.FindInPage +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.feature.chat.McpServerDisplayData +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatToolsBottomSheet( + enabledTools: Set, + onToggleTool: (String) -> Unit, + mcpServers: List, + selectedMcpServerNames: Set, + onToggleMcpServer: (String) -> Unit, + onAttachFiles: () -> Unit, + onTakePhoto: () -> Unit, + onPickPhotos: () -> Unit, + onOpenModelParameters: () -> Unit, + onOpenModelSelector: () -> Unit, + selectedModelDisplay: String?, + onDismiss: () -> Unit, + isCodeInterpreterAvailable: Boolean = true, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showMcpServers by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + // Top section: Camera, Photos, Files cards + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AttachmentOptionCard( + icon = Icons.Default.PhotoCamera, + label = stringResource(R.string.tool_camera), + onClick = { + onTakePhoto() + onDismiss() + }, + ) + AttachmentOptionCard( + icon = Icons.Default.PhotoLibrary, + label = stringResource(R.string.tool_photos), + onClick = { + onPickPhotos() + onDismiss() + }, + ) + AttachmentOptionCard( + icon = Icons.Default.AttachFile, + label = stringResource(R.string.tool_files), + onClick = { + onAttachFiles() + onDismiss() + }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + // Model selector row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onOpenModelSelector() + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.SmartToy, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.tool_model), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = selectedModelDisplay ?: stringResource(R.string.select_model), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Model Parameters + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onOpenModelParameters() + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.tool_model_parameters), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.tool_model_parameters_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + // Tool toggle items + ToolToggleRow( + icon = Icons.Default.Search, + title = stringResource(R.string.tool_web_search), + subtitle = stringResource(R.string.tool_web_search_desc), + isEnabled = ToolConstants.WEB_SEARCH in enabledTools, + onToggle = { onToggleTool(ToolConstants.WEB_SEARCH) }, + ) + + if (isCodeInterpreterAvailable) { + ToolToggleRow( + icon = Icons.Default.Code, + title = stringResource(R.string.tool_code), + subtitle = stringResource(R.string.tool_code_desc), + isEnabled = ToolConstants.CODE_INTERPRETER in enabledTools, + onToggle = { onToggleTool(ToolConstants.CODE_INTERPRETER) }, + ) + } + + ToolToggleRow( + icon = Icons.Default.FindInPage, + title = stringResource(R.string.tool_file_search), + subtitle = stringResource(R.string.tool_file_search_desc), + isEnabled = ToolConstants.FILE_SEARCH in enabledTools, + onToggle = { onToggleTool(ToolConstants.FILE_SEARCH) }, + ) + + // MCP section + if (mcpServers.isNotEmpty()) { + val anyMcpSelected = selectedMcpServerNames.isNotEmpty() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showMcpServers = !showMcpServers } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Extension, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (anyMcpSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.tool_mcp), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.tool_mcp_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (anyMcpSelected) { + Text( + text = "${selectedMcpServerNames.size}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 8.dp), + ) + } + } + + // MCP server sub-list + if (showMcpServers) { + Column( + modifier = Modifier.padding(start = 40.dp), + ) { + mcpServers.forEach { server -> + McpServerToggleRow( + server = server, + isSelected = server.name in selectedMcpServerNames, + onToggle = { onToggleMcpServer(server.name) }, + ) + } + } + } + } + + } + } +} + +@Composable +private fun ToolToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + isEnabled: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 12.dp) + .semantics { + contentDescription = if (isEnabled) { + "$title enabled" + } else { + "$title disabled" + } + role = Role.Switch + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (isEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isEnabled, + onCheckedChange = { onToggle() }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } +} + +@Composable +private fun AttachmentOptionCard( + icon: ImageVector, + label: String, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier.size(80.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier + .size(80.dp) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun McpServerToggleRow( + server: McpServerDisplayData, + isSelected: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Connection status indicator + Box( + modifier = Modifier + .size(8.dp) + .background( + color = if (server.isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + shape = CircleShape, + ), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.title ?: server.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val description = server.description + if (!description.isNullOrBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Checkbox( + checked = isSelected, + onCheckedChange = { onToggle() }, + ) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CitationChip.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CitationChip.kt new file mode 100644 index 0000000..29c15a4 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CitationChip.kt @@ -0,0 +1,296 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Parsed citation found in message text. + * + * @property title Display title extracted from the source text (portion before any URL). + * @property url URL extracted from the source text, if present. + */ +data class Citation( + val number: Int, + val source: String?, + val title: String?, + val url: String?, + val fullMatch: String, + val range: IntRange, +) + +// Matches patterns like [1], [2], or unicode citation markers like 【1†source】 +private val CITATION_REGEX = Regex( + """\[(\d+)]|\u3010(\d+)\u2020([^\u3011]*)\u3011""", +) + +/** Extracts a URL from a source string if one is embedded. */ +private val URL_REGEX = Regex("""https?://\S+""") + +private const val CITATION_TAG = "citation" + +/** + * Parses text for citation markers and returns an annotated string with + * clickable citation chips, along with the list of detected citations. + */ +fun parseCitations(text: String): Pair> { + val citations = mutableListOf() + + CITATION_REGEX.findAll(text).forEach { match -> + val bracketNum = match.groupValues[1] + val unicodeNum = match.groupValues[2] + val source = match.groupValues[3].takeIf { it.isNotBlank() } + + val number = (bracketNum.ifEmpty { unicodeNum }).toIntOrNull() ?: return@forEach + + // Extract URL from source if present + val sourceText = source?.trim() + val extractedUrl = sourceText?.let { URL_REGEX.find(it)?.value } + val title = if (extractedUrl != null) { + sourceText?.replace(extractedUrl, "")?.trim()?.ifEmpty { null } + } else { + sourceText + } + + citations.add( + Citation( + number = number, + source = sourceText, + title = title, + url = extractedUrl, + fullMatch = match.value, + range = match.range, + ), + ) + } + + if (citations.isEmpty()) { + return AnnotatedString(text) to emptyList() + } + + val annotated = buildAnnotatedString { + var lastIndex = 0 + citations.forEach { citation -> + if (citation.range.first > lastIndex) { + append(text.substring(lastIndex, citation.range.first)) + } + pushStringAnnotation(tag = CITATION_TAG, annotation = citation.number.toString()) + withStyle( + SpanStyle( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp, + ), + ) { + append("[${citation.number}]") + } + pop() + lastIndex = citation.range.last + 1 + } + if (lastIndex < text.length) { + append(text.substring(lastIndex)) + } + } + + return annotated to citations +} + +/** + * Renders text with inline citation chips that show details on tap. + */ +@Composable +fun CitationText( + text: String, + fontSizeMultiplier: Float = 1.0f, + modifier: Modifier = Modifier, +) { + val (annotatedString, citations) = remember(text) { parseCitations(text) } + var activeCitation by remember { mutableStateOf(null) } + + val chipColor = MaterialTheme.colorScheme.primary + val styledAnnotated = remember(annotatedString, chipColor) { + buildAnnotatedString { + append(annotatedString) + annotatedString.getStringAnnotations(CITATION_TAG, 0, annotatedString.length) + .forEach { annotation -> + addStyle( + SpanStyle( + color = chipColor, + background = chipColor.copy(alpha = 0.12f), + ), + annotation.start, + annotation.end, + ) + } + } + } + + val baseStyle = MaterialTheme.typography.bodyLarge.let { style -> + if (fontSizeMultiplier == 1.0f) style + else style.copy( + fontSize = (style.fontSize.value * fontSizeMultiplier).sp, + lineHeight = (style.lineHeight.value * fontSizeMultiplier).sp, + ) + } + + if (citations.isEmpty()) { + Text( + text = text, + style = baseStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) + return + } + + @Suppress("DEPRECATION") + ClickableText( + text = styledAnnotated, + style = baseStyle.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + modifier = modifier, + onClick = { offset -> + styledAnnotated.getStringAnnotations(CITATION_TAG, offset, offset) + .firstOrNull() + ?.let { annotation -> + val citationNum = annotation.item.toIntOrNull() + activeCitation = citations.find { it.number == citationNum } + } + }, + ) + + activeCitation?.let { citation -> + Popup( + onDismissRequest = { activeCitation = null }, + properties = PopupProperties(focusable = true), + ) { + CitationPopup( + citation = citation, + onDismiss = { activeCitation = null }, + ) + } + } +} + +@Composable +private fun CitationPopup( + citation: Citation, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + + Surface( + modifier = modifier.padding(8.dp), + shape = RoundedCornerShape(8.dp), + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = stringResource(R.string.citation_number, citation.number), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + // Show title if available (extracted separately from URL) + val displayTitle = citation.title + if (displayTitle != null) { + Text( + text = displayTitle, + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 4.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Show source text as fallback if no separate title was extracted + if (displayTitle == null && citation.source != null) { + Text( + text = citation.source, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Show URL with clickable "Open" link + val citationUrl = citation.url + if (citationUrl != null) { + Text( + text = citationUrl, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier + .padding(top = 6.dp) + .clickable { + uriHandler.openUri(citationUrl) + onDismiss() + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.cd_open_citation_url), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.action_open), + style = MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeBlock.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeBlock.kt new file mode 100644 index 0000000..3b35735 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeBlock.kt @@ -0,0 +1,580 @@ +package com.librechat.android.feature.chat.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun CodeBlock( + code: String, + language: String?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + var showCopied by remember { mutableStateOf(false) } + + LaunchedEffect(showCopied) { + if (showCopied) { + delay(3000L) + showCopied = false + } + } + + val languageLabel = language?.lowercase() ?: "code" + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .semantics { + contentDescription = "$languageLabel code block" + }, + ) { + // Language header with copy button + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = language?.lowercase() ?: "code", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = { + clipboardManager.setPrimaryClip(ClipData.newPlainText("Code", code)) + showCopied = true + }, + ) { + AnimatedContent( + targetState = showCopied, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "copy_button", + ) { copied -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (copied) Icons.Default.Check else Icons.Default.ContentCopy, + contentDescription = stringResource(if (copied) R.string.cd_copied else R.string.cd_copy_code), + tint = if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(if (copied) R.string.action_copied else R.string.action_copy_code), + style = MaterialTheme.typography.labelSmall, + color = if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + + // Code content with syntax highlighting + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(12.dp), + ) { + val highlightedCode = remember(code, language) { + highlightSyntax(code, language?.lowercase()) + } + Text( + text = highlightedCode, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + lineHeight = 20.sp, + ), + ) + } + } +} + +// --- Syntax highlighting engine --- + +/** + * Simple regex-based syntax highlighting. Colors keywords, strings, comments, + * and numbers for common languages. Uses a priority-ordered token list so that + * earlier matches (e.g. comments) take precedence over later ones (e.g. keywords). + */ +private fun highlightSyntax(code: String, language: String?): AnnotatedString { + val tokenRules = getTokenRules(language) ?: return AnnotatedString(code) + return buildAnnotatedString { + // Collect all matches, sort by position, resolve overlaps + val tokens = mutableListOf() + for (rule in tokenRules) { + rule.regex.findAll(code).forEach { match -> + val range = if (rule.captureGroup > 0 && match.groups[rule.captureGroup] != null) { + match.groups[rule.captureGroup]!!.range + } else { + match.range + } + tokens.add( + SyntaxToken( + start = range.first, + end = range.last + 1, + style = rule.style, + priority = rule.priority, + ), + ) + } + } + + // Sort by start position; for overlaps, prefer higher priority (lower number) + tokens.sortWith(compareBy({ it.start }, { it.priority })) + + // Remove overlapping tokens (first one wins) + val resolved = mutableListOf() + var lastEnd = 0 + for (token in tokens) { + if (token.start >= lastEnd) { + resolved.add(token) + lastEnd = token.end + } + } + + // Build annotated string + var pos = 0 + for (token in resolved) { + if (token.start > pos) { + append(code.substring(pos, token.start)) + } + withStyle(token.style) { + append(code.substring(token.start, token.end)) + } + pos = token.end + } + if (pos < code.length) { + append(code.substring(pos)) + } + } +} + +private data class SyntaxToken( + val start: Int, + val end: Int, + val style: SpanStyle, + val priority: Int, +) + +private data class TokenRule( + val regex: Regex, + val style: SpanStyle, + val priority: Int, + val captureGroup: Int = 0, +) + +// Color palette for syntax highlighting (works on both light and dark themes) +// These are chosen to be visible on dark backgrounds (surfaceContainerHighest) +// and also acceptable on light backgrounds. +private val commentStyle = SpanStyle(color = Color(0xFF6A9955), fontStyle = FontStyle.Italic) +private val stringStyle = SpanStyle(color = Color(0xFFCE9178)) +private val keywordStyle = SpanStyle(color = Color(0xFF569CD6)) +private val numberStyle = SpanStyle(color = Color(0xFFB5CEA8)) +private val typeStyle = SpanStyle(color = Color(0xFF4EC9B0)) +private val functionStyle = SpanStyle(color = Color(0xFFDCDCAA)) +private val punctuationStyle = SpanStyle(color = Color(0xFFD4D4D4)) +private val annotationStyle = SpanStyle(color = Color(0xFFD7BA7D)) +private val constantStyle = SpanStyle(color = Color(0xFF4FC1FF)) + +// Cache token rules per language to avoid recreating Regex objects on every code block render. +private val tokenRulesCache = mutableMapOf>() + +private fun getTokenRules(language: String?): List? { + if (language == null || language == "markdown" || language == "md") return null + val key = when (language) { + "kt" -> "kotlin" + "py" -> "python" + "js" -> "javascript" + "ts" -> "typescript" + "sh", "shell", "zsh" -> "bash" + "html" -> "xml" + "golang" -> "go" + "rs" -> "rust" + "cpp", "c++", "h", "hpp" -> "c" + "rb" -> "ruby" + "yml" -> "yaml" + "docker" -> "dockerfile" + else -> language + } + return tokenRulesCache.getOrPut(key) { + when (key) { + "kotlin" -> kotlinRules() + "java" -> javaRules() + "python" -> pythonRules() + "javascript" -> jsRules() + "typescript" -> tsRules() + "json" -> jsonRules() + "bash" -> bashRules() + "xml" -> xmlRules() + "css" -> cssRules() + "sql" -> sqlRules() + "go" -> goRules() + "rust" -> rustRules() + "c" -> cRules() + "swift" -> swiftRules() + "ruby" -> rubyRules() + "yaml" -> yamlRules() + "toml" -> tomlRules() + "dockerfile" -> dockerRules() + else -> genericRules() + } + } +} + +// --- Language rule sets --- + +private fun kotlinRules(): List { + val keywords = "val|var|fun|class|object|interface|enum|sealed|data|annotation|companion|" + + "if|else|when|for|while|do|return|break|continue|throw|try|catch|finally|" + + "import|package|is|as|in|out|by|init|constructor|super|this|null|true|false|" + + "private|protected|internal|public|open|abstract|override|final|suspend|inline|" + + "crossinline|noinline|reified|typealias|lateinit|const|vararg|operator|infix|tailrec" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("\"\"\"[\\s\\S]*?\"\"\""), stringStyle, 1), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)'"), stringStyle, 1), + TokenRule(Regex("@\\w+"), annotationStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*[fFLl]?\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun javaRules(): List { + val keywords = "public|private|protected|static|final|abstract|synchronized|volatile|" + + "transient|native|strictfp|class|interface|enum|extends|implements|" + + "if|else|for|while|do|switch|case|default|break|continue|return|throw|" + + "try|catch|finally|new|instanceof|import|package|this|super|void|" + + "boolean|byte|char|short|int|long|float|double|null|true|false" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)'"), stringStyle, 1), + TokenRule(Regex("@\\w+"), annotationStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*[fFdDlL]?\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun pythonRules(): List { + val keywords = "def|class|if|elif|else|for|while|return|yield|break|continue|pass|" + + "import|from|as|try|except|finally|raise|with|lambda|and|or|not|is|in|" + + "True|False|None|del|global|nonlocal|assert|async|await" + return listOf( + TokenRule(Regex("#.*"), commentStyle, 0), + TokenRule(Regex("\"\"\"[\\s\\S]*?\"\"\""), stringStyle, 1), + TokenRule(Regex("'''[\\s\\S]*?'''"), stringStyle, 1), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("@\\w+"), annotationStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*[jJ]?\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun jsRules(): List { + val keywords = "var|let|const|function|return|if|else|for|while|do|switch|case|default|" + + "break|continue|throw|try|catch|finally|new|delete|typeof|instanceof|in|of|" + + "class|extends|super|import|export|from|as|default|async|await|yield|" + + "this|null|undefined|true|false|void|static|get|set" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("`[\\s\\S]*?`"), stringStyle, 1), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun tsRules(): List { + val keywords = "var|let|const|function|return|if|else|for|while|do|switch|case|default|" + + "break|continue|throw|try|catch|finally|new|delete|typeof|instanceof|in|of|" + + "class|extends|super|import|export|from|as|default|async|await|yield|" + + "this|null|undefined|true|false|void|static|get|set|" + + "type|interface|enum|implements|namespace|declare|abstract|readonly|keyof|" + + "infer|never|unknown|any|string|number|boolean|symbol|bigint" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("`[\\s\\S]*?`"), stringStyle, 1), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("@\\w+"), annotationStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun jsonRules(): List { + return listOf( + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\"\\s*:"), constantStyle, 1), // keys + TokenRule(Regex(":\\s*\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 2), // string values + TokenRule(Regex("\\b(true|false|null)\\b"), keywordStyle, 3), + TokenRule(Regex("-?\\b\\d+\\.?\\d*([eE][+-]?\\d+)?\\b"), numberStyle, 4), + ) +} + +private fun bashRules(): List { + val keywords = "if|then|else|elif|fi|for|while|do|done|case|esac|function|return|" + + "in|select|until|local|export|source|alias|unalias|set|unset|declare|" + + "readonly|shift|exit|echo|printf|read|cd|pwd|ls|cp|mv|rm|mkdir|chmod|" + + "chown|grep|sed|awk|find|sort|cat|head|tail|wc|cut|tr|xargs" + return listOf( + TokenRule(Regex("#.*"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'[^']*'"), stringStyle, 1), + TokenRule(Regex("\\$\\{?\\w+\\}?"), constantStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b\\d+\\b"), numberStyle, 5), + ) +} + +private fun xmlRules(): List { + return listOf( + TokenRule(Regex(""), commentStyle, 0), + TokenRule(Regex("\"[^\"]*\""), stringStyle, 1), + TokenRule(Regex("'[^']*'"), stringStyle, 1), + TokenRule(Regex(""), keywordStyle, 2), + TokenRule(Regex("\\b\\w+(?==)"), typeStyle, 3), + ) +} + +private fun cssRules(): List { + return listOf( + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("#[0-9a-fA-F]{3,8}\\b"), numberStyle, 2), + TokenRule(Regex("\\b\\d+\\.?\\d*(px|em|rem|vh|vw|%|s|ms)?\\b"), numberStyle, 2), + TokenRule(Regex("[.#][\\w-]+"), typeStyle, 3), + TokenRule(Regex("@\\w+"), annotationStyle, 3), + TokenRule(Regex("[\\w-]+(?=\\s*:)"), constantStyle, 4), + ) +} + +private fun sqlRules(): List { + val keywords = "SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|DROP|" + + "ALTER|TABLE|INDEX|VIEW|JOIN|INNER|LEFT|RIGHT|OUTER|ON|AND|OR|NOT|IN|" + + "BETWEEN|LIKE|IS|NULL|AS|ORDER|BY|GROUP|HAVING|LIMIT|OFFSET|UNION|ALL|" + + "DISTINCT|EXISTS|CASE|WHEN|THEN|ELSE|END|BEGIN|COMMIT|ROLLBACK|PRIMARY|" + + "KEY|FOREIGN|REFERENCES|CONSTRAINT|DEFAULT|CHECK|UNIQUE|AUTO_INCREMENT" + return listOf( + TokenRule(Regex("--.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("\\b($keywords)\\b", RegexOption.IGNORE_CASE), keywordStyle, 3), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + ) +} + +private fun goRules(): List { + val keywords = "break|case|chan|const|continue|default|defer|else|fallthrough|for|func|" + + "go|goto|if|import|interface|map|package|range|return|select|struct|switch|" + + "type|var|true|false|nil|iota" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("`[\\s\\S]*?`"), stringStyle, 1), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun rustRules(): List { + val keywords = "as|async|await|break|const|continue|crate|dyn|else|enum|extern|false|" + + "fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|" + + "static|struct|super|trait|true|type|unsafe|use|where|while|yield" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)'"), stringStyle, 1), + TokenRule(Regex("#\\[.*?]"), annotationStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*(_\\d+)*([fui]\\d+)?\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*[!(]"), functionStyle, 6, captureGroup = 1), + ) +} + +private fun cRules(): List { + val keywords = "auto|break|case|char|const|continue|default|do|double|else|enum|extern|" + + "float|for|goto|if|int|long|register|return|short|signed|sizeof|static|" + + "struct|switch|typedef|union|unsigned|void|volatile|while|" + + "class|namespace|template|typename|virtual|override|public|private|protected|" + + "try|catch|throw|new|delete|nullptr|true|false|this|using|include|define|" + + "ifdef|ifndef|endif|pragma" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("#\\s*\\w+"), annotationStyle, 1), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 2), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*[fFlLuU]*\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun swiftRules(): List { + val keywords = "class|struct|enum|protocol|extension|func|var|let|if|else|guard|switch|" + + "case|default|for|while|repeat|return|break|continue|throw|throws|rethrows|" + + "try|catch|do|import|as|is|in|self|Self|super|init|deinit|nil|true|false|" + + "public|private|internal|fileprivate|open|static|override|mutating|lazy|weak|" + + "unowned|optional|required|convenience|final|where|typealias|associatedtype|" + + "async|await|actor" + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("@\\w+"), annotationStyle, 2), + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + TokenRule(Regex("\\b(\\w+)\\s*\\("), functionStyle, 6, captureGroup = 1), + ) +} + +private fun rubyRules(): List { + val keywords = "def|class|module|if|elsif|else|unless|case|when|while|until|for|do|" + + "begin|end|rescue|ensure|raise|return|yield|break|next|redo|retry|" + + "require|include|extend|attr_accessor|attr_reader|attr_writer|" + + "self|super|nil|true|false|and|or|not|in|then|puts|print" + return listOf( + TokenRule(Regex("#.*"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex(":\\w+"), constantStyle, 2), // symbols + TokenRule(Regex("\\b($keywords)\\b"), keywordStyle, 3), + TokenRule(Regex("\\b[A-Z][A-Za-z0-9_]*\\b"), typeStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + ) +} + +private fun yamlRules(): List { + return listOf( + TokenRule(Regex("#.*"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("\\b(true|false|yes|no|null|on|off)\\b", RegexOption.IGNORE_CASE), keywordStyle, 3), + TokenRule(Regex("^\\s*[\\w.-]+(?=\\s*:)", RegexOption.MULTILINE), constantStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + ) +} + +private fun tomlRules(): List { + return listOf( + TokenRule(Regex("#.*"), commentStyle, 0), + TokenRule(Regex("\"\"\"[\\s\\S]*?\"\"\""), stringStyle, 1), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'[^']*'"), stringStyle, 1), + TokenRule(Regex("\\b(true|false)\\b"), keywordStyle, 3), + TokenRule(Regex("\\[+[\\w.-]+]+"), typeStyle, 4), + TokenRule(Regex("^\\s*[\\w.-]+(?=\\s*=)", RegexOption.MULTILINE), constantStyle, 4), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + ) +} + +private fun dockerRules(): List { + val keywords = "FROM|RUN|CMD|LABEL|MAINTAINER|EXPOSE|ENV|ADD|COPY|ENTRYPOINT|VOLUME|" + + "USER|WORKDIR|ARG|ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL|AS" + return listOf( + TokenRule(Regex("#.*"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'[^']*'"), stringStyle, 1), + TokenRule(Regex("\\b($keywords)\\b", RegexOption.IGNORE_CASE), keywordStyle, 3), + TokenRule(Regex("\\$\\{?\\w+\\}?"), constantStyle, 4), + ) +} + +private fun genericRules(): List { + // Minimal highlighting for unknown languages: strings, comments, numbers + return listOf( + TokenRule(Regex("//.*"), commentStyle, 0), + TokenRule(Regex("#.*"), commentStyle, 0), + TokenRule(Regex("/\\*[\\s\\S]*?\\*/"), commentStyle, 0), + TokenRule(Regex("\"(?:[^\"\\\\]|\\\\.)*\""), stringStyle, 1), + TokenRule(Regex("'(?:[^'\\\\]|\\\\.)*'"), stringStyle, 1), + TokenRule(Regex("\\b\\d+\\.?\\d*\\b"), numberStyle, 5), + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeExecutionCard.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeExecutionCard.kt new file mode 100644 index 0000000..274fb99 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/CodeExecutionCard.kt @@ -0,0 +1,232 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Data class representing a code execution result parsed from tool call output. + */ +data class CodeExecutionResult( + val language: String?, + val code: String?, + val output: String?, + val error: String?, + val exitCode: Int?, +) + +/** + * Card displaying code execution results with a language badge, collapsible code section, + * output section, and exit code indicator. + */ +@Composable +fun CodeExecutionCard( + result: CodeExecutionResult, + modifier: Modifier = Modifier, +) { + val codeExecCd = stringResource(R.string.cd_code_execution_result) + var isCodeExpanded by remember { mutableStateOf(false) } + val codeToggleCd = stringResource(if (isCodeExpanded) R.string.cd_collapse_code else R.string.cd_expand_code) + + Card( + modifier = modifier + .fillMaxWidth() + .semantics { + contentDescription = codeExecCd + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Header: language badge + exit code + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + + // Language badge chip + val languageLabel = result.language ?: "code" + SuggestionChip( + onClick = {}, + label = { + Text( + text = languageLabel, + style = MaterialTheme.typography.labelSmall, + ) + }, + modifier = Modifier.height(24.dp), + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Exit code indicator + val exitCode = result.exitCode + if (exitCode != null) { + val isSuccess = exitCode == 0 + Icon( + imageVector = if (isSuccess) Icons.Default.CheckCircle else Icons.Default.Cancel, + contentDescription = stringResource(if (isSuccess) R.string.cd_exit_code_success else R.string.cd_exit_code_failure, exitCode), + modifier = Modifier.size(18.dp), + tint = if (isSuccess) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.exit_code, exitCode), + style = MaterialTheme.typography.labelSmall, + color = if (isSuccess) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + } + } + + // Collapsible code section + if (!result.code.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isCodeExpanded = !isCodeExpanded } + .semantics { + role = Role.Button + contentDescription = codeToggleCd + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.label_code), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (isCodeExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility( + visible = isCodeExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(4.dp)) + CodeBlock( + code = result.code, + language = result.language, + ) + } + } + } + + // Output section + if (!result.output.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.label_output), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = result.output, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(8.dp), + ) + } + + // Error section + if (!result.error.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.label_error), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = result.error, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(8.dp), + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonDualPane.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonDualPane.kt new file mode 100644 index 0000000..378949e --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonDualPane.kt @@ -0,0 +1,106 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.chat.R + +/** + * Tablet-layout comparison view with two side-by-side panes. + * Each pane has a model selector header and its own MessageList content. + * + * @param primaryModelSelector Composable for the primary model selector button + * @param secondaryModelSelector Composable for the secondary model selector button + * @param primaryContent Composable content for the primary conversation pane + * @param secondaryContent Composable content for the secondary conversation pane + */ +@Composable +fun ComparisonDualPane( + primaryModelSelector: @Composable () -> Unit, + secondaryModelSelector: @Composable () -> Unit, + primaryContent: @Composable () -> Unit, + secondaryContent: @Composable () -> Unit, + onContinueWithPrimary: (() -> Unit)? = null, + onContinueWithSecondary: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier.fillMaxSize()) { + // Primary pane + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.CenterStart, + ) { + primaryModelSelector() + } + if (onContinueWithPrimary != null) { + TextButton( + onClick = onContinueWithPrimary, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Text(stringResource(R.string.continue_with_response)) + } + } + HorizontalDivider() + Box(modifier = Modifier.weight(1f)) { + primaryContent() + } + } + + // Divider + VerticalDivider( + modifier = Modifier.fillMaxHeight(), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + // Secondary pane + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.CenterStart, + ) { + secondaryModelSelector() + } + if (onContinueWithSecondary != null) { + TextButton( + onClick = onContinueWithSecondary, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Text(stringResource(R.string.continue_with_response)) + } + } + HorizontalDivider() + Box(modifier = Modifier.weight(1f)) { + secondaryContent() + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonTabBar.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonTabBar.kt new file mode 100644 index 0000000..2a2adbc --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ComparisonTabBar.kt @@ -0,0 +1,127 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.chat.R +import kotlinx.coroutines.launch + +/** + * Phone-layout comparison view with tabs and a horizontal pager. + * Tab 0 = primary model, Tab 1 = secondary model. + * The pager allows swiping between the two conversation panes. + * + * @param primaryModelName Display name for the primary model tab + * @param secondaryModelName Display name for the secondary model tab + * @param primaryContent Composable content for the primary conversation pane + * @param secondaryContent Composable content for the secondary conversation pane + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ComparisonTabBar( + primaryModelName: String, + secondaryModelName: String, + primaryContent: @Composable () -> Unit, + secondaryContent: @Composable () -> Unit, + onContinueWithPrimary: (() -> Unit)? = null, + onContinueWithSecondary: (() -> Unit)? = null, + onTabChanged: ((Int) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + onTabChanged?.invoke(page) + } + } + + Column(modifier = modifier.fillMaxSize()) { + TabRow( + selectedTabIndex = pagerState.currentPage, + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(0) } + }, + text = { + Text( + text = primaryModelName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(1) } + }, + text = { + Text( + text = secondaryModelName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .weight(1f), + ) { page -> + Column(modifier = Modifier.fillMaxSize()) { + val continueCallback = when (page) { + 0 -> onContinueWithPrimary + 1 -> onContinueWithSecondary + else -> null + } + if (continueCallback != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart, + ) { + TextButton(onClick = continueCallback) { + Text(stringResource(R.string.continue_with_response)) + } + } + } + Box(modifier = Modifier.weight(1f)) { + when (page) { + 0 -> primaryContent() + 1 -> secondaryContent() + } + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt new file mode 100644 index 0000000..6542d50 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt @@ -0,0 +1,901 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.core.model.AgentToolCall +import com.librechat.android.core.model.ContentType +import com.librechat.android.core.model.MessageContentPart +import com.librechat.android.feature.chat.components.artifact.ArtifactButton +import com.librechat.android.feature.chat.components.artifact.ArtifactPanel +import com.librechat.android.feature.chat.components.artifact.ArtifactSegment +import com.librechat.android.feature.chat.components.artifact.detectArtifacts +import com.librechat.android.feature.chat.components.artifact.groupArtifactVersions +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true } + +@Composable +fun ContentPartRenderer( + part: MessageContentPart, + baseUrl: String = "", + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + attachments: List = emptyList(), + showImageDescriptions: Boolean = true, + searchQuery: String? = null, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val constrainedModifier = modifier.fillMaxWidth() + when (part.type) { + ContentType.TEXT, ContentType.TEXT_DELTA -> { + TextContentPart( + text = part.text.orEmpty(), + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + modifier = constrainedModifier, + ) + } + ContentType.THINK -> { + ThinkingContentPart( + thinkingText = part.think.orEmpty(), + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + modifier = constrainedModifier, + ) + } + ContentType.TOOL_CALL -> { + val toolCall = part.toolCall + val toolName = toolCall?.name ?: toolCall?.function?.name ?: "Tool Call" + val toolNameLower = toolName.lowercase() + val output = toolCall?.output ?: toolCall?.function?.output + + when { + toolNameLower.contains("search") || toolNameLower.contains(ToolConstants.WEB_SEARCH) -> { + val results = remember(output) { parseWebSearchResults(output) } + if (results.isNotEmpty()) { + WebSearchResultList( + results = results, + modifier = constrainedModifier, + ) + } else { + ToolCallContentPart( + toolName = toolName, + args = toolCall?.function?.arguments, + output = output, + modifier = constrainedModifier, + ) + } + } + toolNameLower.contains(ToolConstants.CODE_INTERPRETER) || toolNameLower.contains("execute") -> { + val result = remember(toolCall) { parseCodeExecution(toolCall) } + if (result != null) { + CodeExecutionCard( + result = result, + modifier = constrainedModifier, + ) + } else { + ToolCallContentPart( + toolName = toolName, + args = toolCall?.function?.arguments, + output = output, + modifier = constrainedModifier, + ) + } + } + toolNameLower.contains("memory") -> { + val artifact = remember(output) { parseMemoryArtifact(output) } + if (artifact != null) { + MemoryArtifactCard( + artifact = artifact, + modifier = constrainedModifier, + ) + } else { + ToolCallContentPart( + toolName = toolName, + args = toolCall?.function?.arguments, + output = output, + modifier = constrainedModifier, + ) + } + } + toolNameLower.contains("mcp") -> { + val resources = remember(output) { parseMcpResources(output) } + if (resources.isNotEmpty()) { + McpResourceCarousel( + resources = resources, + modifier = constrainedModifier, + ) + } else { + ToolCallContentPart( + toolName = toolName, + args = toolCall?.function?.arguments, + output = output, + modifier = constrainedModifier, + ) + } + } + isImageGenToolCall(toolNameLower) -> { + val imageResult = remember(toolCall, baseUrl, attachments) { + parseImageGenResult(toolCall, baseUrl, attachments) + } + ImageGenCard( + result = imageResult, + showDescription = showImageDescriptions, + modifier = constrainedModifier, + ) + } + toolNameLower.contains("log") -> { + val logContent = remember(toolCall) { parseLogContent(toolCall) } + LogContentCard( + log = logContent, + modifier = constrainedModifier, + ) + } + else -> { + ToolCallContentPart( + toolName = toolName, + args = toolCall?.function?.arguments, + output = output, + modifier = constrainedModifier, + ) + } + } + } + ContentType.ERROR -> { + ErrorContentPart( + errorText = part.error.orEmpty(), + modifier = constrainedModifier, + ) + } + ContentType.IMAGE_FILE -> { + val imageUrl = part.imageFile?.filepath?.let { filepath -> + when { + filepath.startsWith("http") -> filepath + filepath.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + baseUrl.isNotBlank() -> "$baseUrl/api/files/$filepath" + else -> filepath + } + } ?: part.imageFile?.fileId?.let { fileId -> + if (baseUrl.isNotBlank()) "$baseUrl/api/files/$fileId" else null + } + ImageContentPart( + imageUrl = imageUrl, + modifier = constrainedModifier, + ) + } + ContentType.IMAGE_URL -> { + ImageContentPart( + imageUrl = part.imageUrl?.url, + modifier = constrainedModifier, + ) + } + ContentType.VIDEO_URL -> { + val videoUrl = part.videoUrl?.url + if (videoUrl != null) { + VideoContent( + url = videoUrl, + modifier = constrainedModifier, + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = constrainedModifier, + ) { + Icon( + imageVector = Icons.Filled.Videocam, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.video_not_supported), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + ContentType.INPUT_AUDIO -> { + AudioContent( + data = part.inputAudio?.data, + format = part.inputAudio?.format, + modifier = constrainedModifier, + ) + } + ContentType.AGENT_UPDATE -> { + val agentUpdate = part.agentUpdate + AgentHandoffCard( + handoff = AgentHandoff( + fromAgent = agentUpdate?.agentId?.let { stringResource(R.string.cd_agent_handoff).format(it, "") }, + toAgent = part.agentId?.let { "Agent $it" }, + reason = agentUpdate?.runId?.let { "Run: $it" }, + ), + modifier = constrainedModifier, + ) + } + } +} + +@Composable +private fun TextContentPart( + text: String, + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + searchQuery: String? = null, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + if (text.isBlank()) return + + val segments = remember(text) { detectArtifacts(text) } + val hasArtifacts = remember(segments) { segments.any { it is ArtifactSegment.ArtifactReference } } + + if (!hasArtifacts) { + MarkdownContent( + text = text, + modifier = modifier, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + ) + return + } + + val versionMap = remember(segments) { groupArtifactVersions(segments) } + var activeArtifact by remember { + mutableStateOf(null) + } + + Column(modifier = modifier) { + segments.forEach { segment -> + when (segment) { + is ArtifactSegment.Text -> { + MarkdownContent( + text = segment.text, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + ) + } + is ArtifactSegment.ArtifactReference -> { + val versions = versionMap[segment.artifact.identifier] ?: listOf(segment.artifact) + Spacer(modifier = Modifier.height(8.dp)) + ArtifactButton( + artifact = segment.artifact, + onClick = { activeArtifact = segment.artifact }, + versionCount = versions.size, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + activeArtifact?.let { artifact -> + val versions = versionMap[artifact.identifier] ?: listOf(artifact) + ArtifactPanel( + artifact = artifact, + onDismiss = { activeArtifact = null }, + versions = versions, + ) + } +} + +@Composable +private fun ThinkingContentPart( + thinkingText: String, + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + searchQuery: String? = null, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + val thinkingToggleCd = stringResource(if (isExpanded) R.string.cd_collapse_thinking else R.string.cd_expand_thinking) + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable { isExpanded = !isExpanded } + .padding(12.dp) + .semantics { + role = Role.Button + contentDescription = thinkingToggleCd + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Psychology, + contentDescription = stringResource(R.string.cd_thinking_indicator), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.label_thinking), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = stringResource(if (isExpanded) R.string.cd_collapse else R.string.cd_expand), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Spacer(modifier = Modifier.height(8.dp)) + MarkdownContent( + text = thinkingText, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + ) + } + } + } +} + +@Composable +private fun ToolCallContentPart( + toolName: String, + args: String?, + output: String?, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + val toolCallCd = stringResource(if (isExpanded) R.string.cd_collapse_tool_call else R.string.cd_expand_tool_call, toolName) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .semantics { + role = Role.Button + contentDescription = toolCallCd + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = stringResource(R.string.cd_tool_call), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = toolName, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = stringResource(if (isExpanded) R.string.cd_collapse else R.string.cd_expand), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + if (!args.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.label_input), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + CodeBlock(code = args, language = "json") + } + if (!output.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.label_output), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + CodeBlock(code = output, language = null) + } + } + } + } + } +} + +@Composable +private fun ImageContentPart( + imageUrl: String?, + modifier: Modifier = Modifier, +) { + if (imageUrl == null) return + + var showFullscreen by remember { mutableStateOf(false) } + + SubcomposeAsyncImage( + model = imageUrl, + contentDescription = stringResource(R.string.cd_embedded_image), + contentScale = ContentScale.FillWidth, + modifier = modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { showFullscreen = true } + .semantics { + role = Role.Image + }, + loading = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + }, + error = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = stringResource(R.string.cd_failed_to_load_image), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + + if (showFullscreen) { + FullscreenImageViewer( + imageUrl = imageUrl, + onDismiss = { showFullscreen = false }, + ) + } +} + +@Composable +private fun ErrorContentPart( + errorText: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = errorText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + +// --- Tool name classification helpers --- + +/** + * Set of exact tool names that are image-generation tools, matching the + * official LibreChat web app's `Part.tsx` routing logic. + */ +private val IMAGE_GEN_EXACT_NAMES = setOf( + "image_gen_oai", + "image_edit_oai", + "gemini_image_gen", +) + +/** + * Set of legacy tool names from the `imageGenTools` set in the official repo + * (`data-provider/src/config.ts`). These are matched by containment. + */ +private val IMAGE_GEN_CONTAINS = listOf( + "dall", + "image_gen", + "stable-diffusion", + "flux", +) + +/** + * Returns true if the given (lowercased) tool name represents an image + * generation or editing tool that should be rendered with [ImageGenCard]. + */ +private fun isImageGenToolCall(toolNameLower: String): Boolean { + if (toolNameLower in IMAGE_GEN_EXACT_NAMES) return true + return IMAGE_GEN_CONTAINS.any { toolNameLower.contains(it) } +} + +// --- JSON parsing helpers for specialized tool call cards --- + +/** + * Attempts to parse web search results from tool call output JSON. + * Supports both a JSON array of results and a JSON object with a "results" array field. + */ +private fun parseWebSearchResults(output: String?): List { + if (output.isNullOrBlank()) return emptyList() + return try { + val element = lenientJson.parseToJsonElement(output) + val resultsArray = when (element) { + is JsonArray -> element + is JsonObject -> element["results"]?.jsonArray ?: return emptyList() + else -> return emptyList() + } + resultsArray.mapNotNull { item -> + try { + val obj = item.jsonObject + WebSearchResult( + title = obj["title"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null, + url = obj["url"]?.jsonPrimitive?.contentOrNull + ?: obj["link"]?.jsonPrimitive?.contentOrNull + ?: return@mapNotNull null, + snippet = obj["snippet"]?.jsonPrimitive?.contentOrNull + ?: obj["description"]?.jsonPrimitive?.contentOrNull + ?: obj["content"]?.jsonPrimitive?.contentOrNull + ?: "", + favicon = obj["favicon"]?.jsonPrimitive?.contentOrNull, + ) + } catch (_: Exception) { + null + } + } + } catch (_: Exception) { + emptyList() + } +} + +/** + * Attempts to parse code execution result from a tool call. + * Looks at the tool call args for code/language and output for results. + */ +private fun parseCodeExecution(toolCall: AgentToolCall?): CodeExecutionResult? { + if (toolCall == null) return null + return try { + val argsElement = toolCall.args + val argsObj = argsElement?.jsonObject + val code = argsObj?.get("code")?.jsonPrimitive?.contentOrNull + ?: toolCall.function?.arguments?.let { argsStr -> + try { + lenientJson.parseToJsonElement(argsStr).jsonObject["code"]?.jsonPrimitive?.contentOrNull + } catch (_: Exception) { + null + } + } + val language = argsObj?.get("language")?.jsonPrimitive?.contentOrNull + ?: argsObj?.get("lang")?.jsonPrimitive?.contentOrNull + ?: toolCall.function?.arguments?.let { argsStr -> + try { + val parsed = lenientJson.parseToJsonElement(argsStr).jsonObject + parsed["language"]?.jsonPrimitive?.contentOrNull + ?: parsed["lang"]?.jsonPrimitive?.contentOrNull + } catch (_: Exception) { + null + } + } + + val outputStr = toolCall.output ?: toolCall.function?.output + var outputText: String? = null + var errorText: String? = null + var exitCode: Int? = null + + if (!outputStr.isNullOrBlank()) { + try { + val outputObj = lenientJson.parseToJsonElement(outputStr).jsonObject + outputText = outputObj["output"]?.jsonPrimitive?.contentOrNull + ?: outputObj["stdout"]?.jsonPrimitive?.contentOrNull + errorText = outputObj["error"]?.jsonPrimitive?.contentOrNull + ?: outputObj["stderr"]?.jsonPrimitive?.contentOrNull + exitCode = outputObj["exit_code"]?.jsonPrimitive?.intOrNull + ?: outputObj["exitCode"]?.jsonPrimitive?.intOrNull + ?: outputObj["status"]?.jsonPrimitive?.intOrNull + } catch (_: Exception) { + // Output is plain text, not JSON + outputText = outputStr + } + } + + CodeExecutionResult( + language = language, + code = code, + output = outputText, + error = errorText, + exitCode = exitCode, + ) + } catch (_: Exception) { + null + } +} + +/** + * Attempts to parse a memory artifact from tool call output JSON. + */ +private fun parseMemoryArtifact(output: String?): MemoryArtifact? { + if (output.isNullOrBlank()) return null + return try { + val obj = lenientJson.parseToJsonElement(output).jsonObject + MemoryArtifact( + title = obj["title"]?.jsonPrimitive?.contentOrNull + ?: obj["key"]?.jsonPrimitive?.contentOrNull, + content = obj["content"]?.jsonPrimitive?.contentOrNull + ?: obj["value"]?.jsonPrimitive?.contentOrNull + ?: obj["text"]?.jsonPrimitive?.contentOrNull, + key = obj["key"]?.jsonPrimitive?.contentOrNull, + ) + } catch (_: Exception) { + // Fall back to treating the whole output as content + MemoryArtifact( + title = null, + content = output, + ) + } +} + +/** + * Attempts to parse MCP resources from tool call output JSON. + */ +private fun parseMcpResources(output: String?): List { + if (output.isNullOrBlank()) return emptyList() + return try { + val element = lenientJson.parseToJsonElement(output) + val resourcesArray = when (element) { + is JsonArray -> element + is JsonObject -> element["resources"]?.jsonArray + ?: element["contents"]?.jsonArray + ?: return emptyList() + else -> return emptyList() + } + resourcesArray.mapNotNull { item -> + try { + val obj = item.jsonObject + McpResource( + title = obj["title"]?.jsonPrimitive?.contentOrNull + ?: obj["name"]?.jsonPrimitive?.contentOrNull + ?: "Resource", + uri = obj["uri"]?.jsonPrimitive?.contentOrNull + ?: obj["url"]?.jsonPrimitive?.contentOrNull + ?: return@mapNotNull null, + preview = obj["preview"]?.jsonPrimitive?.contentOrNull + ?: obj["description"]?.jsonPrimitive?.contentOrNull + ?: obj["text"]?.jsonPrimitive?.contentOrNull, + ) + } catch (_: Exception) { + null + } + } + } catch (_: Exception) { + emptyList() + } +} + +/** + * Parses image generation result from a DALL-E or image_gen tool call. + * Handles various backend response formats: url, image_url, result, filepath, file_id. + */ +private fun parseImageGenResult( + toolCall: AgentToolCall?, + baseUrl: String = "", + attachments: List = emptyList(), +): ImageGenResult { + if (toolCall == null) return ImageGenResult() + + // Extract prompt from args — args may be a JsonObject or a JsonPrimitive string + val prompt = try { + val argsElement = toolCall.args + when { + argsElement is kotlinx.serialization.json.JsonObject -> + argsElement["prompt"]?.jsonPrimitive?.contentOrNull + argsElement is kotlinx.serialization.json.JsonPrimitive && argsElement.isString -> + lenientJson.parseToJsonElement(argsElement.content).jsonObject["prompt"]?.jsonPrimitive?.contentOrNull + else -> null + } ?: toolCall.function?.arguments?.let { argsStr -> + try { + lenientJson.parseToJsonElement(argsStr).jsonObject["prompt"]?.jsonPrimitive?.contentOrNull + } catch (_: Exception) { null } + } + } catch (_: Exception) { null } + + val outputStr = toolCall.output ?: toolCall.function?.output + var imageUrl: String? = null + + // Primary source: match attachment by toolCallId — this is where + // the backend stores the actual generated image filepath. + val toolCallId = toolCall.id + if (toolCallId != null) { + val attachment = attachments.firstOrNull { it.toolCallId == toolCallId } + val filepath = attachment?.filepath + if (filepath != null) { + imageUrl = when { + filepath.startsWith("http") -> filepath + filepath.startsWith("/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + baseUrl.isNotBlank() -> "$baseUrl/$filepath" + else -> filepath + } + } + if (imageUrl == null) { + val fileId = attachment?.fileId + if (fileId != null && baseUrl.isNotBlank()) { + imageUrl = "$baseUrl/api/files/$fileId" + } + } + } + + // Fallback: try parsing the tool output JSON for url/filepath/file_id fields + if (imageUrl == null && !outputStr.isNullOrBlank()) { + try { + val outputObj = lenientJson.parseToJsonElement(outputStr).jsonObject + imageUrl = outputObj["url"]?.jsonPrimitive?.contentOrNull + ?: outputObj["image_url"]?.jsonPrimitive?.contentOrNull + ?: outputObj["result"]?.jsonPrimitive?.contentOrNull + if (imageUrl == null) { + val filepath = outputObj["filepath"]?.jsonPrimitive?.contentOrNull + if (filepath != null) { + imageUrl = when { + filepath.startsWith("http") -> filepath + filepath.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + baseUrl.isNotBlank() -> "$baseUrl/images/$filepath" + else -> filepath + } + } + } + if (imageUrl == null) { + val fileId = outputObj["file_id"]?.jsonPrimitive?.contentOrNull + if (fileId != null && baseUrl.isNotBlank()) { + imageUrl = "$baseUrl/api/files/$fileId" + } + } + } catch (_: Exception) { + // Output might be a plain URL + if (outputStr.startsWith("http")) { + imageUrl = outputStr.trim() + } + } + } + + return ImageGenResult( + imageUrl = imageUrl, + prompt = prompt, + isGenerating = outputStr.isNullOrBlank() && imageUrl == null, + ) +} + +/** + * Parses log content from a log-type tool call. + */ +private fun parseLogContent(toolCall: AgentToolCall?): LogContent { + if (toolCall == null) return LogContent() + return try { + val outputStr = toolCall.output ?: toolCall.function?.output + val toolName = toolCall.name ?: toolCall.function?.name ?: "Log Output" + + if (!outputStr.isNullOrBlank()) { + try { + val outputObj = lenientJson.parseToJsonElement(outputStr).jsonObject + val title = outputObj["title"]?.jsonPrimitive?.contentOrNull ?: toolName + val content = outputObj["content"]?.jsonPrimitive?.contentOrNull + ?: outputObj["log"]?.jsonPrimitive?.contentOrNull + ?: outputObj["text"]?.jsonPrimitive?.contentOrNull + ?: outputStr + LogContent(title = title, content = content) + } catch (_: Exception) { + LogContent(title = toolName, content = outputStr) + } + } else { + LogContent(title = toolName) + } + } catch (_: Exception) { + LogContent() + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FeedbackCommentDialog.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FeedbackCommentDialog.kt new file mode 100644 index 0000000..6aec7e9 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FeedbackCommentDialog.kt @@ -0,0 +1,71 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Dialog shown when a user taps thumbs-down on an AI message. + * Provides an optional text field for the user to explain what was wrong. + * The comment is optional — submitting with an empty string is valid. + */ +@Composable +fun FeedbackCommentDialog( + onSubmit: (comment: String) -> Unit, + onDismiss: () -> Unit, +) { + var comment by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_feedback)) }, + text = { + Column { + Text( + text = stringResource(R.string.feedback_question), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.hint_optional_comment)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + minLines = 2, + maxLines = 5, + ) + } + }, + confirmButton = { + TextButton(onClick = { onSubmit(comment) }) { + Text(stringResource(R.string.action_submit)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ForkOptionsBottomSheet.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ForkOptionsBottomSheet.kt new file mode 100644 index 0000000..eb32be0 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ForkOptionsBottomSheet.kt @@ -0,0 +1,143 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.LinearScale +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.request.ForkOption +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ForkOptionsBottomSheet( + onDismiss: () -> Unit, + onFork: (option: String, splitAtTarget: Boolean) -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + var splitAtTarget by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier.padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.fork_conversation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + + ForkOptionRow( + icon = Icons.Default.LinearScale, + label = stringResource(R.string.fork_visible_messages), + description = stringResource(R.string.fork_visible_messages_desc), + onClick = { onFork(ForkOption.DIRECT_PATH, splitAtTarget) }, + ) + + ForkOptionRow( + icon = Icons.AutoMirrored.Filled.CallSplit, + label = stringResource(R.string.fork_include_branches), + description = stringResource(R.string.fork_include_branches_desc), + onClick = { onFork(ForkOption.INCLUDE_BRANCHES, splitAtTarget) }, + ) + + ForkOptionRow( + icon = Icons.Default.AccountTree, + label = stringResource(R.string.fork_all_to_target), + description = stringResource(R.string.fork_all_to_target_desc), + onClick = { onFork(ForkOption.TARGET_LEVEL, splitAtTarget) }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { splitAtTarget = !splitAtTarget } + .padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = splitAtTarget, + onCheckedChange = { splitAtTarget = it }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = stringResource(R.string.fork_start_new), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.fork_start_new_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ForkOptionRow( + icon: ImageVector, + label: String, + description: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt new file mode 100644 index 0000000..2e82493 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt @@ -0,0 +1,319 @@ +package com.librechat.android.feature.chat.components + +import android.Manifest +import android.content.ContentValues +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.SaveAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun FullscreenImageViewer( + imageUrl: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val saveImageToGallery: () -> Unit = remember(imageUrl) { + { + scope.launch { + try { + val request = ImageRequest.Builder(context) + .data(imageUrl) + .build() + val result = context.imageLoader.execute(request) + val bitmap = (result.drawable as? BitmapDrawable)?.bitmap + if (bitmap == null) { + withContext(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.toast_failed_to_load_image), Toast.LENGTH_SHORT) + .show() + } + return@launch + } + + val fileName = "librechat_${System.currentTimeMillis()}.png" + + try { + withContext(Dispatchers.IO) { + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + put(MediaStore.Images.Media.MIME_TYPE, "image/png") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put( + MediaStore.Images.Media.RELATIVE_PATH, + Environment.DIRECTORY_PICTURES + "/LibreChat", + ) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues, + ) + + if (uri == null) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.toast_failed_to_save_image), + Toast.LENGTH_SHORT, + ).show() + } + return@withContext + } + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val updateValues = ContentValues().apply { + put(MediaStore.Images.Media.IS_PENDING, 0) + } + context.contentResolver.update(uri, updateValues, null, null) + } + } + } finally { + bitmap.recycle() + } + + withContext(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT) + .show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.toast_failed_to_save_with_error, e.localizedMessage ?: ""), + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + + var pendingSaveAfterPermission by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted && pendingSaveAfterPermission) { + pendingSaveAfterPermission = false + saveImageToGallery() + } else if (!isGranted) { + pendingSaveAfterPermission = false + Toast.makeText(context, context.getString(R.string.toast_storage_permission_required), Toast.LENGTH_SHORT) + .show() + } + } + + val onSaveClick: () -> Unit = remember(imageUrl) { + { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveImageToGallery() + } else { + val permission = Manifest.permission.WRITE_EXTERNAL_STORAGE + if (ContextCompat.checkSelfPermission(context, permission) == + PackageManager.PERMISSION_GRANTED + ) { + saveImageToGallery() + } else { + pendingSaveAfterPermission = true + permissionLauncher.launch(permission) + } + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.9f)), + ) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + AsyncImage( + model = imageUrl, + contentDescription = stringResource(R.string.cd_fullscreen_image), + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y, + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 5f) + offset = Offset( + x = offset.x + pan.x, + y = offset.y + pan.y, + ) + } + }, + ) + + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(16.dp) + .size(40.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close), + ) + } + + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(16.dp), + ) { + IconButton( + onClick = { onSaveClick() }, + modifier = Modifier.size(40.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Outlined.SaveAlt, + contentDescription = stringResource(R.string.cd_save_to_device), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { + scope.launch { + try { + val request = ImageRequest.Builder(context) + .data(imageUrl) + .build() + val result = context.imageLoader.execute(request) + val bitmap = (result.drawable as? BitmapDrawable)?.bitmap + ?: return@launch + + val imagesDir = File(context.cacheDir, "shared_images") + imagesDir.mkdirs() + val imageFile = File( + imagesDir, + "shared_image_${System.currentTimeMillis()}.png", + ) + try { + imageFile.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + } finally { + bitmap.recycle() + } + + val contentUri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + imageFile, + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, contentUri) + type = "image/png" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(shareIntent, null)) + } catch (_: Exception) { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, imageUrl) + type = "text/plain" + } + context.startActivity(Intent.createChooser(sendIntent, null)) + } + } + }, + modifier = Modifier.size(40.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.cd_share_image), + ) + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ImageGenCard.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ImageGenCard.kt new file mode 100644 index 0000000..48f0fb2 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ImageGenCard.kt @@ -0,0 +1,171 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +data class ImageGenResult( + val imageUrl: String? = null, + val prompt: String? = null, + val isGenerating: Boolean = false, +) + +/** Renders a DALL-E / image generation result card. [ImageGenResult.isGenerating] drives spinner vs image display. */ +@Composable +fun ImageGenCard( + result: ImageGenResult, + showDescription: Boolean = true, + modifier: Modifier = Modifier, +) { + var showFullscreen by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Image, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(if (result.isGenerating) R.string.generating_image else R.string.image_generated), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + if (result.isGenerating) { + Spacer(modifier = Modifier.width(8.dp)) + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + val imageUrl = result.imageUrl + if (imageUrl != null) { + SubcomposeAsyncImage( + model = imageUrl, + contentDescription = result.prompt ?: stringResource(R.string.cd_generated_image), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { showFullscreen = true } + .semantics { role = Role.Image }, + loading = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerHighest, + RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + }, + error = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerHighest, + RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = stringResource(R.string.cd_failed_to_load_generated_image), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } else if (result.isGenerating) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerHighest, + RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } + + val prompt = result.prompt + if (showDescription && !prompt.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = prompt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (showFullscreen && result.imageUrl != null) { + FullscreenImageViewer( + imageUrl = result.imageUrl, + onDismiss = { showFullscreen = false }, + ) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InConvoSearchBar.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InConvoSearchBar.kt new file mode 100644 index 0000000..301b267 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InConvoSearchBar.kt @@ -0,0 +1,163 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Search overlay bar for finding text within the current conversation. + * Shows a text field with match counter, up/down navigation arrows, and close button. + */ +@OptIn(FlowPreview::class) +@Composable +fun InConvoSearchBar( + query: String, + onQueryChanged: (String) -> Unit, + currentMatchIndex: Int, + totalMatches: Int, + onPreviousMatch: () -> Unit, + onNextMatch: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + // Auto-dismiss keyboard after user stops typing for 400ms (if query is non-blank). + // This lets the user see highlighted search results without the keyboard blocking the view. + LaunchedEffect(Unit) { + snapshotFlow { query } + .debounce(400L) + .filter { it.isNotBlank() } + .collect { keyboardController?.hide() } + } + + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val searchCd = stringResource(R.string.cd_search_in_conversation) + OutlinedTextField( + value = query, + onValueChange = onQueryChanged, + modifier = Modifier + .weight(1f) + .height(48.dp) +.semantics { + contentDescription = searchCd + }, + placeholder = { + Text( + text = stringResource(R.string.hint_find_in_conversation), + style = MaterialTheme.typography.bodyMedium, + ) + }, + textStyle = MaterialTheme.typography.bodyMedium, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + if (totalMatches > 0) onNextMatch() + }, + ), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Match counter + if (query.isNotBlank()) { + Text( + text = if (totalMatches > 0) { + "${currentMatchIndex + 1}/$totalMatches" + } else { + "0/0" + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Previous match + IconButton( + onClick = onPreviousMatch, + enabled = totalMatches > 0, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.cd_previous_match), + tint = if (totalMatches > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Next match + IconButton( + onClick = onNextMatch, + enabled = totalMatches > 0, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.cd_next_match), + tint = if (totalMatches > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Close search + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close_search), + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InlineEditInput.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InlineEditInput.kt new file mode 100644 index 0000000..84be46e --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/InlineEditInput.kt @@ -0,0 +1,92 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun InlineEditInput( + text: String, + onTextChanged: (String) -> Unit, + onSaveAndSubmit: () -> Unit, + onSaveOnly: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + OutlinedTextField( + value = text, + onValueChange = onTextChanged, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp, max = 200.dp), + textStyle = MaterialTheme.typography.bodyLarge, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + maxLines = 10, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) { + Text(stringResource(R.string.cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + FilledTonalButton( + onClick = onSaveOnly, + modifier = Modifier.weight(1f).fillMaxHeight(), + enabled = text.isNotBlank(), + ) { + Text(stringResource(R.string.save), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + Button( + onClick = onSaveAndSubmit, + modifier = Modifier.weight(1f).fillMaxHeight(), + enabled = text.isNotBlank(), + ) { + Text(stringResource(R.string.save_and_submit), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LandingContent.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LandingContent.kt new file mode 100644 index 0000000..a72e9bc --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LandingContent.kt @@ -0,0 +1,133 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChatBubbleOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.util.Calendar +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun LandingContent( + selectedModel: String?, + modifier: Modifier = Modifier, + selectedAgentName: String? = null, +) { + val greeting = remember { getTimeBasedGreeting() } + + Column( + modifier = modifier + .fillMaxSize() + .padding(start = 24.dp, end = 24.dp, bottom = 120.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // LibreChat conversation icon + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.ChatBubbleOutline, + contentDescription = stringResource(R.string.cd_librechat), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Greeting text + Text( + text = greeting, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { heading() }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.how_can_i_help), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + // Agent name display (prominent when an agent is selected) + if (!selectedAgentName.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = selectedAgentName, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + } else if (selectedModel != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = selectedModel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.Center, + ) + } + } +} + +/** + * Returns a time-based greeting with 6 periods matching the web frontend: + * early morning, morning, afternoon, evening, late night, and weekend variants. + */ +private fun getTimeBasedGreeting(): String { + val calendar = Calendar.getInstance() + val hour = calendar.get(Calendar.HOUR_OF_DAY) + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + val isWeekend = dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY + + if (isWeekend) { + return when { + hour < 5 -> "Happy late night" + hour < 12 -> "Happy weekend morning" + hour < 17 -> "Happy weekend afternoon" + else -> "Happy weekend evening" + } + } + + return when { + hour < 5 -> "Happy late night" + hour < 9 -> "Good early morning" + hour < 12 -> "Good morning" + hour < 17 -> "Good afternoon" + hour < 21 -> "Good evening" + else -> "Good evening" + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt new file mode 100644 index 0000000..8530df7 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt @@ -0,0 +1,341 @@ +package com.librechat.android.feature.chat.components + +import android.annotation.SuppressLint +import android.net.http.SslError +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.webkit.WebView +import android.webkit.WebViewClient +import timber.log.Timber +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView + +/** + * Escapes a LaTeX string for safe embedding inside a JavaScript string literal. + */ +private fun escapeForJs(latex: String): String { + return latex + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("<", "\\u003c") + .replace(">", "\\u003e") +} + +/** + * Converts an ARGB int color to a CSS rgba() string. + */ +private fun argbToCss(argb: Int): String { + val a = ((argb shr 24) and 0xFF) / 255.0 + val r = (argb shr 16) and 0xFF + val g = (argb shr 8) and 0xFF + val b = argb and 0xFF + return "rgba($r, $g, $b, $a)" +} + +/** + * Builds the minimal HTML page that loads KaTeX from CDN and renders a LaTeX expression. + * + * @param escapedLatex The LaTeX string already escaped via [escapeForJs]. + * @param displayMode true for block/display mode (centered, large), false for inline mode. + * @param textColorCss CSS color string for the rendered math text. + */ +private fun buildKatexHtml(escapedLatex: String, displayMode: Boolean, textColorCss: String): String { + return """ + + + + + + + + + +

+ + + + + """.trimIndent() +} + +// ── Plain-text renderers (lightweight, no WebView) ──────────────── + +@Composable +private fun NativeLatexBlock( + latex: String, + modifier: Modifier = Modifier, +) { + Text( + text = latex, + style = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + fontSize = 16.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(6.dp), + ) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) +} + +@Composable +private fun NativeLatexInline( + latex: String, + modifier: Modifier = Modifier, +) { + Text( + text = latex, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) +} + +// ── KaTeX (WebView) renderers ────────────────────────────────────── + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun KatexLatexBlock( + latex: String, + modifier: Modifier = Modifier, +) { + val textColorArgb = MaterialTheme.colorScheme.onSurface.toArgb() + val textColorCss = remember(textColorArgb) { argbToCss(textColorArgb) } + val escapedLatex = remember(latex) { escapeForJs(latex) } + val html = remember(escapedLatex, textColorCss) { + buildKatexHtml(escapedLatex, displayMode = true, textColorCss = textColorCss) + } + + AndroidView( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + setBackgroundColor(android.graphics.Color.TRANSPARENT) + settings.javaScriptEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + // Read the content height from document.title (set by JS) + view.evaluateJavascript("document.body.scrollHeight") { heightStr -> + val h = heightStr?.toIntOrNull() + if (h != null && h > 0) { + val density = view.resources.displayMetrics.density + val layoutParams = view.layoutParams + layoutParams.height = (h * density).toInt() + view.layoutParams = layoutParams + } + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + handler?.cancel() + Timber.w("SSL error in LaTeX block WebView: ${error?.primaryError}") + } + } + loadDataWithBaseURL( + "https://cdn.jsdelivr.net", + html, + "text/html", + "UTF-8", + null, + ) + } + }, + update = { webView -> + webView.loadDataWithBaseURL( + "https://cdn.jsdelivr.net", + html, + "text/html", + "UTF-8", + null, + ) + }, + onRelease = { webView -> + webView.stopLoading() + webView.destroy() + }, + ) +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun KatexLatexInline( + latex: String, + modifier: Modifier = Modifier, +) { + val textColorArgb = MaterialTheme.colorScheme.onSurface.toArgb() + val textColorCss = remember(textColorArgb) { argbToCss(textColorArgb) } + val escapedLatex = remember(latex) { escapeForJs(latex) } + val html = remember(escapedLatex, textColorCss) { + buildKatexHtml(escapedLatex, displayMode = false, textColorCss = textColorCss) + } + + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + setBackgroundColor(android.graphics.Color.TRANSPARENT) + settings.javaScriptEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + view.evaluateJavascript("document.body.scrollHeight") { heightStr -> + val h = heightStr?.toIntOrNull() + if (h != null && h > 0) { + val density = view.resources.displayMetrics.density + val layoutParams = view.layoutParams + layoutParams.height = (h * density).toInt() + view.layoutParams = layoutParams + } + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + handler?.cancel() + Timber.w("SSL error in LaTeX inline WebView: ${error?.primaryError}") + } + } + loadDataWithBaseURL( + "https://cdn.jsdelivr.net", + html, + "text/html", + "UTF-8", + null, + ) + } + }, + update = { webView -> + webView.loadDataWithBaseURL( + "https://cdn.jsdelivr.net", + html, + "text/html", + "UTF-8", + null, + ) + }, + onRelease = { webView -> + webView.stopLoading() + webView.destroy() + }, + ) +} + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Renders a LaTeX math expression as a display/block element (for `$$...$$` or `\[...\]`). + * When [useKatex] is true, renders via KaTeX WebView (full rendering). + * When false, shows the raw LaTeX source as styled monospace text (fast, no WebView). + */ +@Composable +fun LatexBlock( + latex: String, + modifier: Modifier = Modifier, + useKatex: Boolean = false, +) { + if (useKatex) { + KatexLatexBlock(latex = latex, modifier = modifier) + } else { + NativeLatexBlock(latex = latex, modifier = modifier) + } +} + +/** + * Renders a LaTeX math expression inline (for `$...$` or `\(...\)`). + * When [useKatex] is true, renders via KaTeX WebView (full rendering). + * When false, shows the raw LaTeX source as styled monospace text (fast, no WebView). + */ +@Composable +fun LatexInline( + latex: String, + modifier: Modifier = Modifier, + useKatex: Boolean = false, +) { + if (useKatex) { + KatexLatexInline(latex = latex, modifier = modifier) + } else { + NativeLatexInline(latex = latex, modifier = modifier) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LogContentCard.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LogContentCard.kt new file mode 100644 index 0000000..18fd4c8 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LogContentCard.kt @@ -0,0 +1,122 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +data class LogContent( + val title: String = "Log Output", + val content: String = "", +) + +/** Renders an expandable log output card with monospace text. Collapsed by default. */ +@Composable +fun LogContentCard( + log: LogContent, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + + val collapseCd = stringResource(R.string.cd_collapse_log, log.title) + val expandCd = stringResource(R.string.cd_expand_log, log.title) + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .semantics { + role = Role.Button + contentDescription = if (isExpanded) { + collapseCd + } else { + expandCd + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = log.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = stringResource(if (isExpanded) R.string.cd_collapse else R.string.cd_expand), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = log.content, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt new file mode 100644 index 0000000..c62df87 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt @@ -0,0 +1,1038 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Markdown content rendering with full CommonMark support. Uses a hybrid approach: + * - 3-phase extraction preserves code blocks, LaTeX blocks, and inline LaTeX + * - Custom CodeBlock.kt syntax highlighting and MermaidDiagram WebView for fenced code + * - KaTeX (WebView) for LaTeX rendering (LatexBlock / LatexInline) + * - multiplatform-markdown-renderer-m3 for rich text segments (headings, tables, + * lists, blockquotes, links, strikethrough, horizontal rules, etc.) + * - Citation detection preserved for annotated source references + * + * @param searchQuery When non-null and non-blank, all occurrences of this string + * are highlighted in yellow within text segments. During active search, text + * segments use a plain [Text] composable instead of the full markdown renderer + * to ensure accurate highlighting. + * @param searchFocusedOccurrence When >= 0, the occurrence at this index (counted + * across all text segments in this message, 0-based) is highlighted in orange + * instead of yellow to indicate the currently focused search result. + * @param onFocusedOccurrencePositioned Callback invoked with the [LayoutCoordinates] + * of the text segment containing the focused search occurrence, after it has been + * laid out. Used by the parent to fine-tune scroll position within a long message. + */ +@Composable +fun MarkdownContent( + text: String, + modifier: Modifier = Modifier, + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + searchQuery: String? = null, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, +) { + val segments = remember(text) { parseMarkdownSegments(text) } + val isSearchActive = !searchQuery.isNullOrBlank() + + Column( + modifier = modifier + .fillMaxWidth() + .semantics { + contentDescription = text + }, + ) { + // Track occurrence offset across segments for focused highlighting + var occurrenceOffset = 0 + + segments.forEachIndexed { index, segment -> + when (segment) { + is MarkdownSegment.CodeBlock -> { + if (index > 0) Spacer(modifier = Modifier.height(8.dp)) + if (segment.language?.lowercase() == "mermaid") { + MermaidDiagram( + code = segment.code, + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + CodeBlock( + code = segment.code, + language = segment.language, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + // Code blocks can also contain matches; count them for offset tracking + if (isSearchActive) { + occurrenceOffset += countOccurrences(segment.code, searchQuery!!) + } + if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp)) + } + is MarkdownSegment.LatexBlock -> { + if (index > 0) Spacer(modifier = Modifier.height(8.dp)) + LatexBlock( + latex = segment.latex, + modifier = Modifier.padding(vertical = 4.dp), + useKatex = useKatex, + ) + if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp)) + } + is MarkdownSegment.InlineLatexText -> { + Column { + segment.segments.forEach { inlineSegment -> + when (inlineSegment) { + is InlineSegment.Text -> { + if (inlineSegment.text.isNotBlank()) { + if (isSearchActive) { + val segmentOccurrences = countOccurrences(inlineSegment.text, searchQuery!!) + val focusedInSegment = searchFocusedOccurrence - occurrenceOffset + val hasFocus = focusedInSegment in 0 until segmentOccurrences + HighlightedTextSegment( + content = inlineSegment.text, + searchQuery = searchQuery, + focusedOccurrence = focusedInSegment, + fontSizeMultiplier = fontSizeMultiplier, + onPositioned = if (hasFocus) onFocusedOccurrencePositioned else null, + ) + occurrenceOffset += segmentOccurrences + } else { + MarkdownTextSegment( + content = inlineSegment.text, + fontSizeMultiplier = fontSizeMultiplier, + ) + } + } + } + is InlineSegment.Latex -> { + LatexInline( + latex = inlineSegment.latex, + useKatex = useKatex, + ) + } + } + } + } + } + is MarkdownSegment.Table -> { + if (index > 0) Spacer(modifier = Modifier.height(8.dp)) + MarkdownTableWithFullscreen( + headers = segment.headers, + alignments = segment.alignments, + rows = segment.rows, + fontSizeMultiplier = fontSizeMultiplier, + modifier = Modifier.padding(vertical = 4.dp), + ) + if (isSearchActive) { + val tableText = (segment.headers + segment.rows.flatten()).joinToString(" ") + occurrenceOffset += countOccurrences(tableText, searchQuery!!) + } + if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp)) + } + is MarkdownSegment.TextBlock -> { + if (isSearchActive) { + val segmentOccurrences = countOccurrences(segment.text, searchQuery!!) + val focusedInSegment = searchFocusedOccurrence - occurrenceOffset + val hasFocus = focusedInSegment in 0 until segmentOccurrences + HighlightedTextSegment( + content = segment.text, + searchQuery = searchQuery, + focusedOccurrence = focusedInSegment, + fontSizeMultiplier = fontSizeMultiplier, + onPositioned = if (hasFocus) onFocusedOccurrencePositioned else null, + ) + occurrenceOffset += segmentOccurrences + } else { + val hasCitations = remember(segment.text) { + segment.text.contains(CITATION_REGEX) + } + if (hasCitations) { + CitationText( + text = segment.text, + fontSizeMultiplier = fontSizeMultiplier, + ) + } else { + MarkdownTextSegment( + content = segment.text, + fontSizeMultiplier = fontSizeMultiplier, + ) + } + } + } + } + } + } +} + +/** + * Renders a text segment with search highlight spans. When [onPositioned] is non-null + * (meaning this segment contains the focused search occurrence), attaches an + * [onGloballyPositioned] modifier so the parent can fine-tune scroll position. + */ +@Composable +private fun HighlightedTextSegment( + content: String, + searchQuery: String, + focusedOccurrence: Int = -1, + modifier: Modifier = Modifier, + fontSizeMultiplier: Float = 1.0f, + onPositioned: ((LayoutCoordinates) -> Unit)? = null, +) { + val bodyStyle = MaterialTheme.typography.bodyLarge + val scaledStyle = bodyStyle.scaleFontSize(fontSizeMultiplier) + val isDarkTheme = isSystemInDarkTheme() + val highlighted = remember(content, searchQuery, focusedOccurrence, isDarkTheme) { + buildHighlightedString(content, searchQuery, focusedOccurrence, isDarkTheme) + } + + val positionedModifier = if (onPositioned != null) { + Modifier.onGloballyPositioned { coordinates -> + onPositioned(coordinates) + } + } else { + Modifier + } + + Text( + text = highlighted, + style = scaledStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier + .fillMaxWidth() + .then(positionedModifier), + ) +} + +/** + * Scales both fontSize and lineHeight of a TextStyle by the given multiplier. + * Uses explicit .sp conversion to avoid TextUnit.Unspecified edge cases. + */ +internal fun TextStyle.scaleFontSize(multiplier: Float): TextStyle { + if (multiplier == 1.0f) return this + val scaledFontSize = if (fontSize.isSpecified) (fontSize.value * multiplier).sp else fontSize + val scaledLineHeight = if (lineHeight.isSpecified) (lineHeight.value * multiplier).sp else lineHeight + return copy(fontSize = scaledFontSize, lineHeight = scaledLineHeight) +} + +/** + * Renders a text segment using the multiplatform-markdown-renderer library for + * full CommonMark support (headings, tables, lists, blockquotes, links, etc.). + * Links are clickable via the ambient [LocalUriHandler]. + */ +@Composable +private fun MarkdownTextSegment( + content: String, + modifier: Modifier = Modifier, + fontSizeMultiplier: Float = 1.0f, +) { + val colors = markdownColor( + text = MaterialTheme.colorScheme.onSurface, + codeText = MaterialTheme.colorScheme.onSurface, + linkText = MaterialTheme.colorScheme.primary, + codeBackground = MaterialTheme.colorScheme.surfaceContainerHigh, + inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHigh, + dividerColor = MaterialTheme.colorScheme.outlineVariant, + ) + val bodyLarge = MaterialTheme.typography.bodyLarge + val bodyMedium = MaterialTheme.typography.bodyMedium + val typography = markdownTypography( + h1 = MaterialTheme.typography.headlineLarge.scaleFontSize(fontSizeMultiplier), + h2 = MaterialTheme.typography.headlineMedium.scaleFontSize(fontSizeMultiplier), + h3 = MaterialTheme.typography.headlineSmall.scaleFontSize(fontSizeMultiplier), + h4 = MaterialTheme.typography.titleLarge.scaleFontSize(fontSizeMultiplier), + h5 = MaterialTheme.typography.titleMedium.scaleFontSize(fontSizeMultiplier), + h6 = MaterialTheme.typography.titleSmall.scaleFontSize(fontSizeMultiplier), + text = bodyLarge.scaleFontSize(fontSizeMultiplier), + paragraph = bodyLarge.scaleFontSize(fontSizeMultiplier), + quote = bodyLarge.copy(fontStyle = FontStyle.Italic).scaleFontSize(fontSizeMultiplier), + code = bodyMedium.copy(fontFamily = FontFamily.Monospace).scaleFontSize(fontSizeMultiplier), + inlineCode = bodyLarge.copy(fontFamily = FontFamily.Monospace).scaleFontSize(fontSizeMultiplier), + ordered = bodyLarge.scaleFontSize(fontSizeMultiplier), + bullet = bodyLarge.scaleFontSize(fontSizeMultiplier), + list = bodyLarge.scaleFontSize(fontSizeMultiplier), + ) + + // The Markdown composable caches internally keyed on content. Wrapping + // in key(fontSizeMultiplier) forces disposal and recreation when the + // user changes the font size setting, so the new typography takes effect. + key(fontSizeMultiplier) { + Markdown( + content = content, + colors = colors, + typography = typography, + modifier = modifier + .fillMaxWidth(), + ) + } +} + +internal sealed interface MarkdownSegment { + data class TextBlock(val text: String) : MarkdownSegment + data class CodeBlock(val code: String, val language: String?) : MarkdownSegment + data class LatexBlock(val latex: String) : MarkdownSegment + data class InlineLatexText(val segments: List) : MarkdownSegment + data class Table( + val headers: List, + val alignments: List, + val rows: List>, + ) : MarkdownSegment +} + +/** Column alignment parsed from the separator row of a markdown table. */ +internal enum class TableCellAlignment { + LEFT, CENTER, RIGHT, +} + +internal sealed interface InlineSegment { + data class Text(val text: String) : InlineSegment + data class Latex(val latex: String) : InlineSegment +} + +internal sealed interface InlineSpan { + val text: String + + data class Plain(override val text: String) : InlineSpan + data class Bold(override val text: String) : InlineSpan + data class Italic(override val text: String) : InlineSpan + data class InlineCode(override val text: String) : InlineSpan +} + +// Pre-compiled regex patterns for markdown/LaTeX parsing (avoid recreating on every call) +private val CODE_BLOCK_REGEX = Regex("```(\\w*)[^\\S\\n]*\\n([\\s\\S]*?)```") +private val BLOCK_LATEX_REGEX = Regex("\\$\\$([\\s\\S]+?)\\$\\$|\\\\\\[([\\s\\S]+?)\\\\\\]") +private val INLINE_LATEX_REGEX = Regex("(? content.contains(keyword) } +} + +/** + * Returns true if the text appears to be a raw HTML block that should be rendered + * as a code block rather than passed to the markdown renderer (which would garble it). + * + * Heuristic: starts with an HTML tag whose name is in [HTML_BLOCK_TAG_NAMES] or is + * a DOCTYPE declaration, AND contains at least one closing tag or is self-contained. + */ +private fun looksLikeHtmlBlock(text: String): Boolean { + val match = HTML_OPENING_TAG_REGEX.find(text) ?: return false + val tagName = match.groupValues[1].lowercase().let { + // Normalize "!doctype html" to "html" + if (it.startsWith("!doctype")) "html" else it + } + if (tagName !in HTML_BLOCK_TAG_NAMES) return false + // Require a closing tag or self-closing indicator to avoid false positives + // on lines that just happen to start with "") + return hasClosingTag +} + +/** + * Scans TextBlock segments for raw HTML blocks and converts them to CodeBlock + * segments with language="html". This prevents the mikepenz markdown renderer + * from silently dropping HTML tags. + * + * A TextBlock is treated as a raw HTML block if [looksLikeHtmlBlock] returns true. + * Mixed content (text + HTML) is split: lines before the HTML become a TextBlock, + * the HTML block becomes a CodeBlock, and lines after become a TextBlock. + */ +private fun extractHtmlBlocks(segments: List): List { + val result = mutableListOf() + + for (segment in segments) { + if (segment !is MarkdownSegment.TextBlock) { + result.add(segment) + continue + } + + val text = segment.text + if (looksLikeHtmlBlock(text)) { + // Entire block is HTML -- render as a code block + result.add(MarkdownSegment.CodeBlock(text.trim(), "html")) + continue + } + + // Check if the block contains an embedded HTML section starting on its own line. + // Split into lines and find the first line that opens an HTML block. + val lines = text.split('\n') + var htmlStart = -1 + for (i in lines.indices) { + val lineText = lines.subList(i, lines.size).joinToString("\n") + if (looksLikeHtmlBlock(lineText)) { + htmlStart = i + break + } + } + + if (htmlStart < 0) { + // No HTML found; keep as-is + result.add(segment) + continue + } + + // Flush preceding text lines + if (htmlStart > 0) { + val preceding = lines.subList(0, htmlStart).joinToString("\n").trim() + if (preceding.isNotEmpty()) { + result.add(MarkdownSegment.TextBlock(preceding)) + } + } + + // The rest from htmlStart onward is the HTML block + val htmlContent = lines.subList(htmlStart, lines.size).joinToString("\n").trim() + result.add(MarkdownSegment.CodeBlock(htmlContent, "html")) + } + + return result +} + +/** + * Splits raw text into code-block, LaTeX-block, table, inline-LaTeX, and text-block segments. + * + * Parsing order: + * 1. Extract fenced code blocks (``` ... ```) + * 2. Detect raw HTML blocks in remaining TextBlocks and convert to CodeBlocks + * 3. Within non-code segments, extract block LaTeX (`$$...$$`) + * 4. Within remaining text blocks, extract GFM-style tables + * 5. Within remaining text, detect inline LaTeX (`$...$`) and split accordingly + */ +internal fun parseMarkdownSegments(text: String): List { + // --- Pass 1: split on fenced code blocks --- + val afterCodeBlocks = mutableListOf() + var lastIndex = 0 + + CODE_BLOCK_REGEX.findAll(text).forEach { match -> + if (match.range.first > lastIndex) { + val textBefore = text.substring(lastIndex, match.range.first).trim() + if (textBefore.isNotEmpty()) { + afterCodeBlocks.add(MarkdownSegment.TextBlock(textBefore)) + } + } + val language = match.groupValues[1].ifEmpty { null } + val code = match.groupValues[2].trimEnd() + val langLower = language?.lowercase() + if (langLower == "latex" || langLower == "tex" || langLower == "math") { + // Treat latex/tex/math code blocks as text so LaTeX extraction + // passes handle $...$ and $$...$$ delimiters properly + afterCodeBlocks.add(MarkdownSegment.TextBlock(code)) + } else { + afterCodeBlocks.add(MarkdownSegment.CodeBlock(code, language)) + } + lastIndex = match.range.last + 1 + } + + if (lastIndex < text.length) { + val remaining = text.substring(lastIndex).trim() + if (remaining.isNotEmpty()) { + afterCodeBlocks.add(MarkdownSegment.TextBlock(remaining)) + } + } + + if (afterCodeBlocks.isEmpty() && text.isNotBlank()) { + afterCodeBlocks.add(MarkdownSegment.TextBlock(text)) + } + + // --- Pass 2: detect raw HTML blocks and convert to CodeBlocks --- + val afterHtmlBlocks = extractHtmlBlocks(afterCodeBlocks) + + // --- Pass 3: split TextBlocks on block LaTeX ($$...$$ or \[...\]) --- + val afterBlockLatex = mutableListOf() + + for (segment in afterHtmlBlocks) { + if (segment !is MarkdownSegment.TextBlock) { + afterBlockLatex.add(segment) + continue + } + var segLastIndex = 0 + val segText = segment.text + + BLOCK_LATEX_REGEX.findAll(segText).forEach { match -> + if (match.range.first > segLastIndex) { + val before = segText.substring(segLastIndex, match.range.first).trim() + if (before.isNotEmpty()) { + afterBlockLatex.add(MarkdownSegment.TextBlock(before)) + } + } + val latexContent = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim() + if (latexContent.isNotEmpty()) { + afterBlockLatex.add(MarkdownSegment.LatexBlock(latexContent)) + } + segLastIndex = match.range.last + 1 + } + + if (segLastIndex < segText.length) { + val remaining = segText.substring(segLastIndex).trim() + if (remaining.isNotEmpty()) { + afterBlockLatex.add(MarkdownSegment.TextBlock(remaining)) + } + } else if (segLastIndex == 0) { + // No block LaTeX found; keep original segment + afterBlockLatex.add(segment) + } + } + + // --- Pass 4: extract markdown tables from TextBlocks --- + val afterTables = mutableListOf() + + for (segment in afterBlockLatex) { + if (segment !is MarkdownSegment.TextBlock) { + afterTables.add(segment) + continue + } + afterTables.addAll(extractTables(segment.text)) + } + + // --- Pass 5: detect inline LaTeX ($...$ or \(...\)) within remaining TextBlocks --- + val finalSegments = mutableListOf() + + for (segment in afterTables) { + if (segment !is MarkdownSegment.TextBlock) { + finalSegments.add(segment) + continue + } + + val matches = INLINE_LATEX_REGEX.findAll(segment.text) + .filter { match -> + // \(...\) is always LaTeX; $...$ needs heuristic check + val dollarContent = match.groupValues[1] + val parenContent = match.groupValues[2] + if (parenContent.isNotBlank()) true + else dollarContent.isNotBlank() && looksLikeLatex(dollarContent) + } + .toList() + + if (matches.isEmpty()) { + finalSegments.add(segment) + continue + } + + // Build mixed inline segments + val inlineSegments = mutableListOf() + var segLastIndex = 0 + val segText = segment.text + + for (match in matches) { + if (match.range.first > segLastIndex) { + val before = segText.substring(segLastIndex, match.range.first) + if (before.isNotEmpty()) { + inlineSegments.add(InlineSegment.Text(before)) + } + } + val content = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim() + inlineSegments.add(InlineSegment.Latex(content)) + segLastIndex = match.range.last + 1 + } + + if (segLastIndex < segText.length) { + val remaining = segText.substring(segLastIndex) + if (remaining.isNotEmpty()) { + inlineSegments.add(InlineSegment.Text(remaining)) + } + } + + finalSegments.add(MarkdownSegment.InlineLatexText(inlineSegments)) + } + + return finalSegments +} + +/** + * Extracts markdown tables from a text block, splitting it into alternating + * TextBlock and Table segments. A valid table requires: + * - A header row with pipe-delimited cells + * - A separator row matching [TABLE_SEPARATOR_REGEX] + * - At least one data row + */ +private fun extractTables(text: String): List { + val lines = text.split('\n') + val result = mutableListOf() + val buffer = mutableListOf() + var i = 0 + + while (i < lines.size) { + // Check if lines[i] could be a table header row and lines[i+1] a separator + if (i + 2 < lines.size && isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) { + // Flush any buffered text before the table + if (buffer.isNotEmpty()) { + val preceding = buffer.joinToString("\n").trim() + if (preceding.isNotEmpty()) { + result.add(MarkdownSegment.TextBlock(preceding)) + } + buffer.clear() + } + + val headerCells = parseTableRow(lines[i]) + val alignments = parseAlignments(lines[i + 1], headerCells.size) + val dataRows = mutableListOf>() + var j = i + 2 + + while (j < lines.size && isTableRow(lines[j])) { + val rowCells = parseTableRow(lines[j]) + // Pad or truncate to match header column count + val normalized = List(headerCells.size) { col -> + rowCells.getOrElse(col) { "" } + } + dataRows.add(normalized) + j++ + } + + if (dataRows.isNotEmpty()) { + result.add(MarkdownSegment.Table(headerCells, alignments, dataRows)) + i = j + } else { + // Not a valid table (no data rows); treat header+separator as text + buffer.add(lines[i]) + buffer.add(lines[i + 1]) + i += 2 + } + } else { + buffer.add(lines[i]) + i++ + } + } + + // Flush remaining buffered text + if (buffer.isNotEmpty()) { + val remaining = buffer.joinToString("\n").trim() + if (remaining.isNotEmpty()) { + result.add(MarkdownSegment.TextBlock(remaining)) + } + } + + return result +} + +/** + * Returns true if the line looks like a pipe-delimited table row. + * Requires either leading/trailing pipes or at least two pipe characters, + * to avoid false positives on prose that contains a single `|`. + */ +private fun isTableRow(line: String): Boolean { + val trimmed = line.trim() + if (trimmed.isEmpty()) return false + return trimmed.startsWith('|') || trimmed.endsWith('|') || trimmed.count { it == '|' } >= 2 +} + +/** Returns true if the line matches a GFM table separator pattern. */ +private fun isTableSeparator(line: String): Boolean { + return TABLE_SEPARATOR_REGEX.matches(line.trim()) +} + +/** Splits a pipe-delimited table row into trimmed cell values. */ +private fun parseTableRow(line: String): List { + val trimmed = line.trim() + // Remove leading and trailing pipes if present + val inner = trimmed.removePrefix("|").removeSuffix("|") + return inner.split('|').map { it.trim() } +} + +/** + * Parses alignment indicators from a separator row. + * `:---` = LEFT, `:---:` = CENTER, `---:` = RIGHT, `---` = LEFT (default) + */ +private fun parseAlignments(separatorLine: String, columnCount: Int): List { + val cells = parseTableRow(separatorLine) + return List(columnCount) { col -> + val cell = cells.getOrElse(col) { "---" }.trim() + val startsColon = cell.startsWith(':') + val endsColon = cell.endsWith(':') + when { + startsColon && endsColon -> TableCellAlignment.CENTER + endsColon -> TableCellAlignment.RIGHT + else -> TableCellAlignment.LEFT + } + } +} + +/** + * Wraps a [MarkdownTable] in a [Box] with a fullscreen expand button overlaid on + * the top-right corner. Tapping the button opens a [FullscreenTableDialog]. + */ +@Composable +private fun MarkdownTableWithFullscreen( + headers: List, + alignments: List, + rows: List>, + modifier: Modifier = Modifier, + fontSizeMultiplier: Float = 1.0f, +) { + var showFullscreen by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + MarkdownTable( + headers = headers, + alignments = alignments, + rows = rows, + fontSizeMultiplier = fontSizeMultiplier, + ) + + IconButton( + onClick = { showFullscreen = true }, + modifier = Modifier + .align(Alignment.TopEnd) + .size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.85f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Icon( + imageVector = Icons.Default.Fullscreen, + contentDescription = stringResource(R.string.cd_expand_table), + modifier = Modifier.size(18.dp), + ) + } + } + + if (showFullscreen) { + FullscreenTableDialog( + headers = headers, + alignments = alignments, + rows = rows, + fontSizeMultiplier = fontSizeMultiplier, + onDismiss = { showFullscreen = false }, + ) + } +} + +/** + * Fullscreen dialog that renders a markdown table inside a scrollable container + * (both horizontal and vertical) with a [TopAppBar] for the title and close button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FullscreenTableDialog( + headers: List, + alignments: List, + rows: List>, + fontSizeMultiplier: Float, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + TopAppBar( + title = { Text(stringResource(R.string.dialog_table)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + + val verticalScrollState = rememberScrollState() + + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(verticalScrollState) + .padding(16.dp), + ) { + MarkdownTable( + headers = headers, + alignments = alignments, + rows = rows, + fontSizeMultiplier = fontSizeMultiplier, + ) + } + } + } +} + +/** + * Renders a markdown table with a header row and data rows. The table supports + * horizontal scrolling for wide tables with many columns. Header cells are bold + * with a bottom divider. Subtle row dividers and column dividers provide structure. + * + * Column widths are computed from the text content so that all cells in the same + * column share a uniform width, giving a proper grid appearance. + */ +@Composable +private fun MarkdownTable( + headers: List, + alignments: List, + rows: List>, + modifier: Modifier = Modifier, + fontSizeMultiplier: Float = 1.0f, +) { + val textColor = MaterialTheme.colorScheme.onSurface + val headerBackground = MaterialTheme.colorScheme.surfaceContainerHigh + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val bodyStyle = MaterialTheme.typography.bodyMedium.scaleFontSize(fontSizeMultiplier) + val headerStyle = bodyStyle.copy(fontWeight = FontWeight.Bold) + val cellPaddingH = 12.dp + val cellPaddingV = 8.dp + + // Measure column widths from text content using TextMeasurer + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val columnCount = headers.size + + val columnWidths = remember(headers, rows, fontSizeMultiplier) { + val cellPaddingPx = with(density) { (cellPaddingH * 2).roundToPx() } + List(columnCount) { col -> + var maxWidth = textMeasurer.measure( + text = headers[col], + style = headerStyle, + ).size.width + for (row in rows) { + val cellText = row.getOrElse(col) { "" } + val measured = textMeasurer.measure( + text = cellText, + style = bodyStyle, + ).size.width + if (measured > maxWidth) maxWidth = measured + } + with(density) { (maxWidth + cellPaddingPx).toDp() } + } + } + + val scrollState = rememberScrollState() + + Box( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + .clip(MaterialTheme.shapes.small), + ) { + Column { + // Header row + Row( + modifier = Modifier + .height(IntrinsicSize.Min) + .background(headerBackground), + ) { + headers.forEachIndexed { colIndex, header -> + if (colIndex > 0) { + VerticalDivider( + color = dividerColor, + modifier = Modifier.fillMaxHeight(), + ) + } + TableCell( + text = header, + style = headerStyle, + color = textColor, + alignment = alignments.getOrElse(colIndex) { TableCellAlignment.LEFT }, + cellWidth = columnWidths[colIndex], + paddingH = cellPaddingH, + paddingV = cellPaddingV, + ) + } + } + + HorizontalDivider(color = dividerColor, thickness = 1.dp) + + // Data rows + rows.forEachIndexed { rowIndex, row -> + Row( + modifier = Modifier.height(IntrinsicSize.Min), + ) { + row.forEachIndexed { colIndex, cell -> + if (colIndex > 0) { + VerticalDivider( + color = dividerColor, + modifier = Modifier.fillMaxHeight(), + ) + } + TableCell( + text = cell, + style = bodyStyle, + color = textColor, + alignment = alignments.getOrElse(colIndex) { TableCellAlignment.LEFT }, + cellWidth = columnWidths.getOrElse(colIndex) { columnWidths.last() }, + paddingH = cellPaddingH, + paddingV = cellPaddingV, + ) + } + } + if (rowIndex < rows.lastIndex) { + HorizontalDivider(color = dividerColor, thickness = Dp.Hairline) + } + } + } + } +} + +/** + * A single table cell with fixed [cellWidth] for uniform column alignment. + * Text is aligned according to the column's [alignment] setting. + */ +@Composable +private fun TableCell( + text: String, + style: TextStyle, + color: Color, + alignment: TableCellAlignment, + cellWidth: Dp, + paddingH: Dp, + paddingV: Dp, + modifier: Modifier = Modifier, +) { + val textAlign = when (alignment) { + TableCellAlignment.LEFT -> TextAlign.Start + TableCellAlignment.CENTER -> TextAlign.Center + TableCellAlignment.RIGHT -> TextAlign.End + } + + Box( + modifier = modifier + .width(cellWidth) + .padding(horizontal = paddingH, vertical = paddingV), + contentAlignment = when (alignment) { + TableCellAlignment.LEFT -> Alignment.CenterStart + TableCellAlignment.CENTER -> Alignment.Center + TableCellAlignment.RIGHT -> Alignment.CenterEnd + }, + ) { + Text( + text = text, + style = style, + color = color, + textAlign = textAlign, + maxLines = 10, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * Parses inline markdown (bold, italic, inline code) within a text block. + * Calls [onSpan] for each discovered span. + */ +internal fun parseInlineMarkdown(text: String, onSpan: (InlineSpan) -> Unit) { + var lastIndex = 0 + + INLINE_MARKDOWN_REGEX.findAll(text).forEach { match -> + if (match.range.first > lastIndex) { + onSpan(InlineSpan.Plain(text.substring(lastIndex, match.range.first))) + } + when { + match.groupValues[1].isNotEmpty() -> { + onSpan(InlineSpan.InlineCode(match.groupValues[1])) + } + match.groupValues[2].isNotEmpty() -> { + onSpan(InlineSpan.Bold(match.groupValues[2])) + } + match.groupValues[3].isNotEmpty() -> { + onSpan(InlineSpan.Italic(match.groupValues[3])) + } + } + lastIndex = match.range.last + 1 + } + + if (lastIndex < text.length) { + onSpan(InlineSpan.Plain(text.substring(lastIndex))) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpResourceCarousel.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpResourceCarousel.kt new file mode 100644 index 0000000..b54009b --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpResourceCarousel.kt @@ -0,0 +1,133 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Data class representing an MCP resource parsed from tool call output. + */ +data class McpResource( + val title: String, + val uri: String, + val preview: String? = null, +) + +/** + * Horizontal carousel of MCP resource cards displayed in a LazyRow. + * Each card is 200dp wide and shows the resource title, URI, and optional preview. + */ +@Composable +fun McpResourceCarousel( + resources: List, + modifier: Modifier = Modifier, +) { + val resourcesCd = stringResource(R.string.cd_mcp_resources, resources.size) + LazyRow( + modifier = modifier.semantics { + contentDescription = resourcesCd + }, + contentPadding = PaddingValues(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = resources, + key = { it.uri }, + contentType = { "mcp_resource" }, + ) { resource -> + McpResourceCard(resource = resource) + } + } +} + +@Composable +private fun McpResourceCard( + resource: McpResource, + modifier: Modifier = Modifier, +) { + val mcpResourceCd = stringResource(R.string.cd_mcp_resource, resource.title) + Card( + modifier = modifier + .width(200.dp) + .semantics { + contentDescription = mcpResourceCd + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Header with icon and title + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Extension, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = resource.title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // URI + Text( + text = resource.uri, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Optional preview + if (!resource.preview.isNullOrBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = resource.preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpServerSelector.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpServerSelector.kt new file mode 100644 index 0000000..255599c --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/McpServerSelector.kt @@ -0,0 +1,172 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.chat.McpServerDisplayData +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun McpServerSelector( + servers: List, + selectedServerNames: Set, + onToggleServer: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val selectMcpCd = stringResource(R.string.cd_select_mcp_servers) + if (servers.isEmpty()) return + + var expanded by remember { mutableStateOf(false) } + val selectedCount = selectedServerNames.size + + Box(modifier = modifier) { + FilterChip( + selected = selectedCount > 0, + onClick = { expanded = !expanded }, + label = { + Text( + text = if (selectedCount == 0) { + "MCP" + } else if (selectedCount == 1) { + val serverName = selectedServerNames.first() + servers.find { it.name == serverName }?.let { it.title ?: it.name } + ?: serverName + } else { + "MCP ($selectedCount)" + }, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Hub, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedTrailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.semantics { + contentDescription = if (selectedCount > 0) { + "$selectedCount MCP servers selected" + } else { + selectMcpCd + } + }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + servers.forEach { server -> + val isSelected = server.name in selectedServerNames + McpServerDropdownItem( + server = server, + isSelected = isSelected, + onToggle = { onToggleServer(server.name) }, + ) + } + } + } +} + +@Composable +private fun McpServerDropdownItem( + server: McpServerDisplayData, + isSelected: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status indicator + Box( + modifier = Modifier + .size(8.dp) + .background( + color = if (server.isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + shape = CircleShape, + ), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = server.title ?: server.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val description = server.description + if (!description.isNullOrBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Checkbox( + checked = isSelected, + onCheckedChange = { onToggle() }, + ) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MemoryArtifactCard.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MemoryArtifactCard.kt new file mode 100644 index 0000000..f88bfd4 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MemoryArtifactCard.kt @@ -0,0 +1,116 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Data class representing a memory artifact parsed from tool call output. + */ +data class MemoryArtifact( + val title: String?, + val content: String?, + val key: String? = null, +) + +/** + * Card displaying a memory artifact with "Memory" badge, title, and expandable content preview. + * Uses tertiary container color for distinction. + */ +@Composable +fun MemoryArtifactCard( + artifact: MemoryArtifact, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + + val memoryCd = stringResource(R.string.cd_memory_artifact, artifact.title ?: stringResource(R.string.memory_artifact_untitled)) + Card( + modifier = modifier + .fillMaxWidth() + .semantics { + contentDescription = memoryCd + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + // Memory badge + SuggestionChip( + onClick = {}, + label = { + Text( + text = stringResource(R.string.label_memory), + style = MaterialTheme.typography.labelSmall, + ) + }, + modifier = Modifier.height(24.dp), + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f), + labelColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) + + if (!artifact.title.isNullOrBlank()) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = artifact.title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + + // Content preview + if (!artifact.content.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = artifact.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = if (isExpanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { + isExpanded = !isExpanded + }, + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt new file mode 100644 index 0000000..a3eceb2 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt @@ -0,0 +1,304 @@ +package com.librechat.android.feature.chat.components + +import android.annotation.SuppressLint +import android.graphics.Color as AndroidColor +import android.net.http.SslError +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import timber.log.Timber +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun MermaidDiagram( + code: String, + modifier: Modifier = Modifier, +) { + var showCode by remember { mutableStateOf(false) } + var showFullscreen by remember { mutableStateOf(false) } + + val isDarkTheme = MaterialTheme.colorScheme.surface.toArgb().let { argb -> + val r = (argb shr 16) and 0xFF + val g = (argb shr 8) and 0xFF + val b = argb and 0xFF + (r * 0.299 + g * 0.587 + b * 0.114) < 128 + } + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.label_mermaid), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = { showCode = !showCode }, + ) { + Icon( + imageVector = if (showCode) Icons.Default.Image else Icons.Default.Code, + contentDescription = stringResource(if (showCode) R.string.cd_show_diagram else R.string.cd_view_code), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(if (showCode) R.string.label_diagram else R.string.code), + style = MaterialTheme.typography.labelSmall, + ) + } + IconButton(onClick = { showFullscreen = true }) { + Icon( + imageVector = Icons.Default.Fullscreen, + contentDescription = stringResource(R.string.cd_fullscreen), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Content + AnimatedContent( + targetState = showCode, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "mermaid_content", + ) { isCode -> + if (isCode) { + CodeBlock( + code = code, + language = "mermaid", + ) + } else { + MermaidWebView( + code = code, + isDarkTheme = isDarkTheme, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(8.dp), + ) + } + } + } + + if (showFullscreen) { + Dialog( + onDismissRequest = { showFullscreen = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + MermaidWebView( + code = code, + isDarkTheme = isDarkTheme, + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) + IconButton( + onClick = { showFullscreen = false }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close_fullscreen), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun MermaidWebView( + code: String, + isDarkTheme: Boolean, + modifier: Modifier = Modifier, +) { + val bgColor = MaterialTheme.colorScheme.surfaceContainerHighest.toArgb() + val escapedCode = remember(code) { + code.replace("\\", "\\\\") + .replace("`", "\\`") + .replace("$", "\\$") + .replace("\"", "\\\"") + .replace("<", "\\u003c") + .replace("\n", "\\n") + } + val theme = if (isDarkTheme) "dark" else "default" + + val html = remember(escapedCode, theme) { + buildMermaidHtml(escapedCode, theme) + } + + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setBackgroundColor(bgColor) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.allowFileAccess = false + settings.allowContentAccess = false + webChromeClient = WebChromeClient() + webViewClient = object : WebViewClient() { + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + handler?.cancel() + Timber.w("SSL error in Mermaid WebView: ${error?.primaryError}") + } + } + loadDataWithBaseURL( + "https://cdn.jsdelivr.net", + html, + "text/html", + "UTF-8", + null, + ) + } + }, + update = { webView -> + webView.setBackgroundColor(bgColor) + webView.loadDataWithBaseURL( + "https://cdn.jsdelivr.net", + html, + "text/html", + "UTF-8", + null, + ) + }, + onRelease = { webView -> + webView.stopLoading() + webView.destroy() + }, + ) +} + +private fun buildMermaidHtml(escapedCode: String, theme: String): String { + return """ + + + + + + + + +
+
+ + + + + """.trimIndent() +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageBubble.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageBubble.kt new file mode 100644 index 0000000..cad19a9 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageBubble.kt @@ -0,0 +1,851 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.ThumbDown +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.outlined.ThumbDown +import androidx.compose.material.icons.outlined.ThumbUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.annotation.DrawableRes +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.core.model.Message +import com.librechat.android.core.ui.components.AvatarImage +import kotlinx.coroutines.delay +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +internal val BubbleShape = RoundedCornerShape(16.dp) + +private const val ACTION_AUTO_HIDE_MILLIS = 30_000L + +@Composable +fun MessageBubble( + message: Message, + modifier: Modifier = Modifier, + siblingIndex: Int = 0, + siblingCount: Int = 1, + onSiblingNavigation: ((Int) -> Unit)? = null, + onEdit: (() -> Unit)? = null, + onRegenerate: (() -> Unit)? = null, + onCopy: (() -> Unit)? = null, + onFeedback: ((String?) -> Unit)? = null, + onContinue: (() -> Unit)? = null, + onReadAloud: (() -> Unit)? = null, + onFork: (() -> Unit)? = null, + baseUrl: String = "", + fontSizeMultiplier: Float = 1.0f, + isReading: Boolean = false, + currentFeedback: String? = null, + isEditing: Boolean = false, + editText: String = "", + onEditTextChanged: ((String) -> Unit)? = null, + onEditSaveAndSubmit: (() -> Unit)? = null, + onEditSaveOnly: (() -> Unit)? = null, + onEditCancel: (() -> Unit)? = null, + userAvatarUrl: String? = null, + userName: String? = null, + @DrawableRes endpointIconRes: Int? = null, + tintEndpointIcon: Boolean = false, + showImageDescriptions: Boolean = true, + showActionsInitially: Boolean = false, + searchQuery: String? = null, + isSearchMatch: Boolean = false, + isCurrentSearchMatch: Boolean = false, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + useKatex: Boolean = false, + chatLayoutStyle: String = ChatLayoutConstants.THREAD, + showAvatars: Boolean = true, + showBubbles: Boolean = false, +) { + if (chatLayoutStyle == ChatLayoutConstants.TWO_SIDED) { + TwoSidedMessageBubble( + message = message, + modifier = modifier, + siblingIndex = siblingIndex, + siblingCount = siblingCount, + onSiblingNavigation = onSiblingNavigation, + onEdit = onEdit, + onRegenerate = onRegenerate, + onCopy = onCopy, + onFeedback = onFeedback, + onContinue = onContinue, + onReadAloud = onReadAloud, + onFork = onFork, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + isReading = isReading, + currentFeedback = currentFeedback, + isEditing = isEditing, + editText = editText, + onEditTextChanged = onEditTextChanged, + onEditSaveAndSubmit = onEditSaveAndSubmit, + onEditSaveOnly = onEditSaveOnly, + onEditCancel = onEditCancel, + userAvatarUrl = userAvatarUrl, + userName = userName, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + showImageDescriptions = showImageDescriptions, + showActionsInitially = showActionsInitially, + searchQuery = searchQuery, + isSearchMatch = isSearchMatch, + isCurrentSearchMatch = isCurrentSearchMatch, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + showAvatars = showAvatars, + showBubbles = showBubbles, + ) + } else { + ThreadMessageBubble( + message = message, + modifier = modifier, + siblingIndex = siblingIndex, + siblingCount = siblingCount, + onSiblingNavigation = onSiblingNavigation, + onEdit = onEdit, + onRegenerate = onRegenerate, + onCopy = onCopy, + onFeedback = onFeedback, + onContinue = onContinue, + onReadAloud = onReadAloud, + onFork = onFork, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + isReading = isReading, + currentFeedback = currentFeedback, + isEditing = isEditing, + editText = editText, + onEditTextChanged = onEditTextChanged, + onEditSaveAndSubmit = onEditSaveAndSubmit, + onEditSaveOnly = onEditSaveOnly, + onEditCancel = onEditCancel, + userAvatarUrl = userAvatarUrl, + userName = userName, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + showImageDescriptions = showImageDescriptions, + showActionsInitially = showActionsInitially, + searchQuery = searchQuery, + isSearchMatch = isSearchMatch, + isCurrentSearchMatch = isCurrentSearchMatch, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + showAvatars = showAvatars, + showBubbles = showBubbles, + ) + } +} + +/** + * Original thread-style layout: avatar on left, name + time, content below. + */ +@Composable +private fun ThreadMessageBubble( + message: Message, + modifier: Modifier = Modifier, + siblingIndex: Int = 0, + siblingCount: Int = 1, + onSiblingNavigation: ((Int) -> Unit)? = null, + onEdit: (() -> Unit)? = null, + onRegenerate: (() -> Unit)? = null, + onCopy: (() -> Unit)? = null, + onFeedback: ((String?) -> Unit)? = null, + onContinue: (() -> Unit)? = null, + onReadAloud: (() -> Unit)? = null, + onFork: (() -> Unit)? = null, + baseUrl: String = "", + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + isReading: Boolean = false, + currentFeedback: String? = null, + isEditing: Boolean = false, + editText: String = "", + onEditTextChanged: ((String) -> Unit)? = null, + onEditSaveAndSubmit: (() -> Unit)? = null, + onEditSaveOnly: (() -> Unit)? = null, + onEditCancel: (() -> Unit)? = null, + userAvatarUrl: String? = null, + userName: String? = null, + @DrawableRes endpointIconRes: Int? = null, + tintEndpointIcon: Boolean = false, + showImageDescriptions: Boolean = true, + showActionsInitially: Boolean = false, + searchQuery: String? = null, + isSearchMatch: Boolean = false, + isCurrentSearchMatch: Boolean = false, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + showAvatars: Boolean = true, + showBubbles: Boolean = false, +) { + val isUser = message.isCreatedByUser + var showActions by remember(message.messageId) { mutableStateOf(showActionsInitially) } + var showFeedbackDialog by remember { mutableStateOf(false) } + + if (showFeedbackDialog && onFeedback != null) { + FeedbackCommentDialog( + onSubmit = { comment -> + showFeedbackDialog = false + onFeedback("thumbsDown") + }, + onDismiss = { showFeedbackDialog = false }, + ) + } + + // Auto-hide actions after timeout + LaunchedEffect(showActions) { + if (showActions) { + delay(ACTION_AUTO_HIDE_MILLIS) + showActions = false + } + } + + // Determine background color for search state + val searchBackground = when { + isCurrentSearchMatch -> SearchHighlightOrange.copy(alpha = 0.18f) + isSearchMatch -> SearchHighlightYellow.copy(alpha = 0.12f) + else -> null + } + + Column( + modifier = modifier + .fillMaxWidth() + .then( + if (searchBackground != null) { + Modifier.background( + color = searchBackground, + shape = RoundedCornerShape(8.dp), + ) + } else { + Modifier + }, + ) + .clickable( + interactionSource = null, + indication = null, + ) { + showActions = !showActions + } + .padding( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + // Sender row with avatar + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (showAvatars) { + AvatarImage( + imageUrl = if (isUser) userAvatarUrl else message.iconURL, + fallbackText = if (isUser) (userName ?: stringResource(R.string.sender_you)) else (message.sender ?: stringResource(R.string.sender_assistant)), + fallbackIconRes = if (!isUser && message.iconURL == null) endpointIconRes else null, + showPersonIcon = isUser && userAvatarUrl == null, + tintIcon = if (!isUser && message.iconURL == null) tintEndpointIcon else false, + size = 28.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = if (isUser) (userName ?: stringResource(R.string.sender_you)) else (message.sender ?: stringResource(R.string.sender_assistant)), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + + // Timestamp + val timestamp = message.createdAt + if (timestamp != null) { + Spacer(modifier = Modifier.width(8.dp)) + MessageTimestamp(isoTimestamp = timestamp) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Message content -- indented to align with the text after avatar + val contentStartPadding = if (showAvatars) 36.dp else 0.dp + + // When bubbles are ON in thread mode, wrap content in a subtle rounded background + val threadBubbleBackground = if (showBubbles) { + if (isUser) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + } else { + null + } + + Column( + modifier = Modifier + .padding(start = contentStartPadding) + .then( + if (threadBubbleBackground != null) { + Modifier + .background( + color = threadBubbleBackground, + shape = BubbleShape, + ) + .padding(12.dp) + } else { + Modifier + }, + ), + ) { + MessageContentAndActions( + message = message, + isUser = isUser, + isEditing = isEditing, + editText = editText, + onEditTextChanged = onEditTextChanged, + onEditSaveAndSubmit = onEditSaveAndSubmit, + onEditSaveOnly = onEditSaveOnly, + onEditCancel = onEditCancel, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + showImageDescriptions = showImageDescriptions, + searchQuery = searchQuery, + isSearchMatch = isSearchMatch, + isCurrentSearchMatch = isCurrentSearchMatch, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + showActions = showActions, + siblingIndex = siblingIndex, + siblingCount = siblingCount, + onSiblingNavigation = onSiblingNavigation, + onEdit = onEdit, + onRegenerate = onRegenerate, + onCopy = onCopy, + onFeedback = onFeedback, + onContinue = onContinue, + onReadAloud = onReadAloud, + onFork = onFork, + isReading = isReading, + currentFeedback = currentFeedback, + onShowFeedbackDialog = { showFeedbackDialog = true }, + ) + } + } +} + +/** + * Two-sided chat layout: user messages on right, agent on left, with bubble backgrounds. + */ +@Composable +private fun TwoSidedMessageBubble( + message: Message, + modifier: Modifier = Modifier, + siblingIndex: Int = 0, + siblingCount: Int = 1, + onSiblingNavigation: ((Int) -> Unit)? = null, + onEdit: (() -> Unit)? = null, + onRegenerate: (() -> Unit)? = null, + onCopy: (() -> Unit)? = null, + onFeedback: ((String?) -> Unit)? = null, + onContinue: (() -> Unit)? = null, + onReadAloud: (() -> Unit)? = null, + onFork: (() -> Unit)? = null, + baseUrl: String = "", + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + isReading: Boolean = false, + currentFeedback: String? = null, + isEditing: Boolean = false, + editText: String = "", + onEditTextChanged: ((String) -> Unit)? = null, + onEditSaveAndSubmit: (() -> Unit)? = null, + onEditSaveOnly: (() -> Unit)? = null, + onEditCancel: (() -> Unit)? = null, + userAvatarUrl: String? = null, + userName: String? = null, + @DrawableRes endpointIconRes: Int? = null, + tintEndpointIcon: Boolean = false, + showImageDescriptions: Boolean = true, + showActionsInitially: Boolean = false, + searchQuery: String? = null, + isSearchMatch: Boolean = false, + isCurrentSearchMatch: Boolean = false, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + showAvatars: Boolean = true, + showBubbles: Boolean = false, +) { + val isUser = message.isCreatedByUser + var showActions by remember(message.messageId) { mutableStateOf(showActionsInitially) } + var showFeedbackDialog by remember { mutableStateOf(false) } + + if (showFeedbackDialog && onFeedback != null) { + FeedbackCommentDialog( + onSubmit = { comment -> + showFeedbackDialog = false + onFeedback("thumbsDown") + }, + onDismiss = { showFeedbackDialog = false }, + ) + } + + // Auto-hide actions after timeout + LaunchedEffect(showActions) { + if (showActions) { + delay(ACTION_AUTO_HIDE_MILLIS) + showActions = false + } + } + + // Determine background color for search state + val searchBackground = when { + isCurrentSearchMatch -> SearchHighlightOrange.copy(alpha = 0.18f) + isSearchMatch -> SearchHighlightYellow.copy(alpha = 0.12f) + else -> null + } + + // Use secondaryContainer for user bubbles (better dark mode contrast than primaryContainer) + // and surfaceVariant for agent bubbles. Text uses onSecondaryContainer/onSurfaceVariant. + val bubbleBackground = if (showBubbles) { + if (isUser) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + } else { + null + } + + // Composite search highlight over bubble background so the highlight is visible + // even when an opaque bubble background is present. + val effectiveBubbleBackground = if (searchBackground != null && bubbleBackground != null) { + searchBackground.compositeOver(bubbleBackground) + } else { + searchBackground ?: bubbleBackground + } + + val textColor = if (showBubbles) { + if (isUser) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + } else { + MaterialTheme.colorScheme.onSurface + } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + interactionSource = null, + indication = null, + ) { + showActions = !showActions + } + .padding( + horizontal = 8.dp, + vertical = 4.dp, + ), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + verticalAlignment = Alignment.Top, + ) { + // Agent avatar on left + if (!isUser && showAvatars) { + AvatarImage( + imageUrl = message.iconURL, + fallbackText = message.sender ?: stringResource(R.string.sender_assistant), + fallbackIconRes = if (message.iconURL == null) endpointIconRes else null, + tintIcon = if (message.iconURL == null) tintEndpointIcon else false, + size = 28.dp, + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + // Bubble content + Column( + modifier = Modifier + .weight(1f) + .then( + if (effectiveBubbleBackground != null) { + Modifier + .background( + color = effectiveBubbleBackground, + shape = BubbleShape, + ) + .padding(12.dp) + } else { + Modifier.padding( + horizontal = 4.dp, + vertical = 8.dp, + ) + }, + ), + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, + ) { + // Name + timestamp inside bubble + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isUser) (userName ?: stringResource(R.string.sender_you)) else (message.sender ?: stringResource(R.string.sender_assistant)), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = textColor, + ) + val timestamp = message.createdAt + if (timestamp != null) { + Spacer(modifier = Modifier.width(6.dp)) + MessageTimestamp(isoTimestamp = timestamp) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Message content + MessageContentAndActions( + message = message, + isUser = isUser, + isEditing = isEditing, + editText = editText, + onEditTextChanged = onEditTextChanged, + onEditSaveAndSubmit = onEditSaveAndSubmit, + onEditSaveOnly = onEditSaveOnly, + onEditCancel = onEditCancel, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + showImageDescriptions = showImageDescriptions, + searchQuery = searchQuery, + isSearchMatch = isSearchMatch, + isCurrentSearchMatch = isCurrentSearchMatch, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + showActions = showActions, + siblingIndex = siblingIndex, + siblingCount = siblingCount, + onSiblingNavigation = onSiblingNavigation, + onEdit = onEdit, + onRegenerate = onRegenerate, + onCopy = onCopy, + onFeedback = onFeedback, + onContinue = onContinue, + onReadAloud = onReadAloud, + onFork = onFork, + isReading = isReading, + currentFeedback = currentFeedback, + onShowFeedbackDialog = { showFeedbackDialog = true }, + ) + } + + // User avatar on right + if (isUser && showAvatars) { + Spacer(modifier = Modifier.width(6.dp)) + AvatarImage( + imageUrl = userAvatarUrl, + fallbackText = userName ?: stringResource(R.string.sender_you), + showPersonIcon = userAvatarUrl == null, + size = 28.dp, + ) + } + } +} + +/** + * Shared message content rendering and action buttons, used by both layout styles. + */ +@Composable +private fun MessageContentAndActions( + message: Message, + isUser: Boolean, + isEditing: Boolean, + editText: String, + onEditTextChanged: ((String) -> Unit)?, + onEditSaveAndSubmit: (() -> Unit)?, + onEditSaveOnly: (() -> Unit)?, + onEditCancel: (() -> Unit)?, + baseUrl: String, + fontSizeMultiplier: Float, + useKatex: Boolean, + showImageDescriptions: Boolean, + searchQuery: String?, + isSearchMatch: Boolean, + isCurrentSearchMatch: Boolean, + searchFocusedOccurrence: Int, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?, + showActions: Boolean, + siblingIndex: Int, + siblingCount: Int, + onSiblingNavigation: ((Int) -> Unit)?, + onEdit: (() -> Unit)?, + onRegenerate: (() -> Unit)?, + onCopy: (() -> Unit)?, + onFeedback: ((String?) -> Unit)?, + onContinue: (() -> Unit)?, + onReadAloud: (() -> Unit)?, + onFork: (() -> Unit)?, + isReading: Boolean, + currentFeedback: String?, + onShowFeedbackDialog: () -> Unit, +) { + if (isEditing && onEditTextChanged != null && onEditSaveAndSubmit != null && onEditSaveOnly != null && onEditCancel != null) { + InlineEditInput( + text = editText, + onTextChanged = onEditTextChanged, + onSaveAndSubmit = onEditSaveAndSubmit, + onSaveOnly = onEditSaveOnly, + onCancel = onEditCancel, + ) + } else { + // Render attached files above message text (matches web app behavior) + val messageFiles = message.files + if (!messageFiles.isNullOrEmpty()) { + MessageFiles( + files = messageFiles, + baseUrl = baseUrl, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + + val contentParts = message.content + if (!contentParts.isNullOrEmpty()) { + contentParts.forEach { part -> + ContentPartRenderer( + part = part, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + attachments = message.attachments.orEmpty(), + showImageDescriptions = showImageDescriptions, + searchQuery = if (isSearchMatch) searchQuery else null, + searchFocusedOccurrence = if (isCurrentSearchMatch) searchFocusedOccurrence else -1, + onFocusedOccurrencePositioned = if (isCurrentSearchMatch) onFocusedOccurrencePositioned else null, + modifier = Modifier.padding(vertical = 2.dp), + ) + } + } else if (message.text.isNotBlank()) { + MarkdownContent( + text = message.text, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = if (isSearchMatch) searchQuery else null, + searchFocusedOccurrence = if (isCurrentSearchMatch) searchFocusedOccurrence else -1, + onFocusedOccurrencePositioned = if (isCurrentSearchMatch) onFocusedOccurrencePositioned else null, + ) + } + } + + // Action row: sibling nav + message actions (tap-to-reveal) + val hasActions = siblingCount > 1 || onEdit != null || onRegenerate != null || + onCopy != null || onFeedback != null || onContinue != null || + onReadAloud != null || onFork != null + if (hasActions) { + AnimatedVisibility( + visible = showActions, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Sibling navigator (left side) + if (siblingCount > 1 && onSiblingNavigation != null) { + SiblingNavigator( + siblingIndex = siblingIndex, + siblingCount = siblingCount, + onNavigate = onSiblingNavigation, + ) + } else { + Spacer(modifier = Modifier.width(0.dp)) + } + + // Action buttons (right side) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + // Feedback buttons (AI messages only) + if (!isUser && onFeedback != null) { + IconButton( + onClick = { + onFeedback( + if (currentFeedback == "thumbsUp") null else "thumbsUp", + ) + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = if (currentFeedback == "thumbsUp") { + Icons.Filled.ThumbUp + } else { + Icons.Outlined.ThumbUp + }, + contentDescription = stringResource(R.string.cd_thumbs_up), + modifier = Modifier.size(18.dp), + tint = if (currentFeedback == "thumbsUp") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + IconButton( + onClick = { + if (currentFeedback == "thumbsDown") { + onFeedback(null) + } else { + onShowFeedbackDialog() + } + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = if (currentFeedback == "thumbsDown") { + Icons.Filled.ThumbDown + } else { + Icons.Outlined.ThumbDown + }, + contentDescription = stringResource(R.string.cd_thumbs_down), + modifier = Modifier.size(18.dp), + tint = if (currentFeedback == "thumbsDown") { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + if (onCopy != null) { + IconButton( + onClick = onCopy, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.cd_copy_message), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (onEdit != null) { + IconButton( + onClick = onEdit, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit_message), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (!isUser && onRegenerate != null) { + IconButton( + onClick = onRegenerate, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.cd_regenerate_response), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Read aloud button + if (onReadAloud != null) { + IconButton( + onClick = onReadAloud, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = if (isReading) { + Icons.Default.Stop + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = if (isReading) { + stringResource(R.string.cd_stop_reading) + } else { + stringResource(R.string.cd_read_aloud) + }, + modifier = Modifier.size(18.dp), + tint = if (isReading) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + // Fork conversation from this message + if (onFork != null) { + IconButton( + onClick = onFork, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CallSplit, + contentDescription = stringResource(R.string.cd_fork_conversation), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } +} + diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageFiles.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageFiles.kt new file mode 100644 index 0000000..ec35491 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageFiles.kt @@ -0,0 +1,242 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material.icons.filled.TableChart +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import com.librechat.android.core.model.FileReference +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Renders files attached to a message. Splits files into images (shown as + * inline previews) and non-image files (shown as file chips with icon and name). + * + * This mirrors the official LibreChat web app's `Files` component which renders + * `message.files` above the message text for user messages. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MessageFiles( + files: List, + baseUrl: String = "", + modifier: Modifier = Modifier, +) { + if (files.isEmpty()) return + + val imageFiles = remember(files) { + files.filter { it.type?.startsWith("image/") == true } + } + val otherFiles = remember(files) { + files.filter { it.type?.startsWith("image/") != true } + } + + Column(modifier = modifier) { + // Non-image file chips + if (otherFiles.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + otherFiles.forEach { file -> + FileChip( + filename = file.filename ?: file.filepath?.substringAfterLast('/') ?: stringResource(R.string.file_fallback), + type = file.type, + ) + } + } + if (imageFiles.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // Image previews + imageFiles.forEach { file -> + val imageUrl = resolveFileUrl(file, baseUrl) + if (imageUrl != null) { + MessageImagePreview( + imageUrl = imageUrl, + altText = file.filename ?: "Attached image", + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +/** + * Resolves a full URL for a file reference, handling relative paths. + */ +private fun resolveFileUrl(file: FileReference, baseUrl: String): String? { + val filepath = file.filepath + if (filepath != null) { + return when { + filepath.startsWith("http") -> filepath + filepath.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + filepath.startsWith("/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + baseUrl.isNotBlank() -> "$baseUrl/api/files/$filepath" + else -> filepath + } + } + val fileId = file.fileId + if (fileId != null && baseUrl.isNotBlank()) { + return "$baseUrl/api/files/$fileId" + } + return null +} + +/** + * Inline image preview that can be tapped to open fullscreen. + */ +@Composable +private fun MessageImagePreview( + imageUrl: String, + altText: String, + modifier: Modifier = Modifier, +) { + var showFullscreen by remember { mutableStateOf(false) } + + SubcomposeAsyncImage( + model = imageUrl, + contentDescription = altText, + contentScale = ContentScale.FillWidth, + modifier = modifier + .widthIn(max = 300.dp) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { showFullscreen = true } + .semantics { + role = Role.Image + contentDescription = altText + }, + loading = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + }, + error = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.BrokenImage, + contentDescription = stringResource(R.string.cd_failed_to_load_image), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + + if (showFullscreen) { + FullscreenImageViewer( + imageUrl = imageUrl, + onDismiss = { showFullscreen = false }, + ) + } +} + +/** + * A chip that shows a non-image file with an icon and filename. + */ +@Composable +private fun FileChip( + filename: String, + type: String?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = fileTypeIcon(type), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = filename, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 200.dp), + ) + } +} + +/** + * Returns an appropriate icon for the given MIME type. + */ +private fun fileTypeIcon(type: String?): ImageVector { + if (type == null) return Icons.AutoMirrored.Filled.InsertDriveFile + return when { + type.startsWith("application/pdf") -> Icons.Default.PictureAsPdf + type.startsWith("text/") -> Icons.Default.Description + type.contains("spreadsheet") || type.contains("csv") || type.contains("excel") -> Icons.Default.TableChart + type.contains("document") || type.contains("word") -> Icons.Default.Description + else -> Icons.Default.AttachFile + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageList.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageList.kt new file mode 100644 index 0000000..bb0c38a --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageList.kt @@ -0,0 +1,478 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.annotation.DrawableRes +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.core.model.FeedbackRating +import com.librechat.android.feature.chat.util.MessageNode +import com.librechat.android.feature.chat.viewmodel.ActiveToolCall +import com.librechat.android.feature.chat.viewmodel.SearchMatch +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageList( + displayMessages: List, + isStreaming: Boolean, + streamingContent: String, + activeToolCalls: List = emptyList(), + onSiblingNavigation: (parentMessageId: String, siblingIndex: Int) -> Unit, + onEditMessage: (messageId: String) -> Unit, + onRegenerateMessage: (messageId: String) -> Unit, + onCopyMessage: (messageId: String) -> Unit, + onFeedback: (messageId: String, rating: String?) -> Unit = { _, _ -> }, + onContinue: (messageId: String) -> Unit = {}, + onReadAloud: (messageId: String) -> Unit = {}, + onFork: (messageId: String) -> Unit = {}, + currentlyReadingMessageId: String? = null, + editingMessageId: String? = null, + editingText: String = "", + onEditTextChanged: (String) -> Unit = {}, + onEditSaveAndSubmit: () -> Unit = {}, + onEditSaveOnly: () -> Unit = {}, + onEditCancel: () -> Unit = {}, + baseUrl: String = "", + fontSizeMultiplier: Float = 1.0f, + isRefreshing: Boolean = false, + onRefresh: () -> Unit = {}, + userAvatarUrl: String? = null, + userName: String? = null, + @DrawableRes endpointIconRes: Int? = null, + tintEndpointIcon: Boolean = false, + streamingSenderName: String = "Assistant", + showImageDescriptions: Boolean = true, + chatLayoutStyle: String = ChatLayoutConstants.THREAD, + showAvatars: Boolean = true, + showBubbles: Boolean = false, + useKatex: Boolean = false, + searchQuery: String? = null, + searchMatchIndices: List = emptyList(), + currentSearchMatchIndex: Int = 0, + searchScrollToIndex: Int? = null, + onSearchScrollHandled: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + // Track whether the user's finger is currently touching the list. + // This is more reliable than isScrollInProgress which only covers flings. + var isTouching by remember { mutableStateOf(false) } + val currentStreamingContent by rememberUpdatedState(streamingContent) + val nearBottomThresholdPx = with(LocalDensity.current) { 80.dp.toPx() } + var lastNavigatedParentKey by remember { mutableStateOf(null) } + + val streamingToolCallCount = if (isStreaming) activeToolCalls.size else 0 + val totalItemCount = displayMessages.size + streamingToolCallCount + if (isStreaming) 1 else 0 + + // Track whether user has deliberately scrolled away from the bottom. + // Reset when user scrolls back to bottom (via FAB or manual scroll). + var userScrolledUp by remember { mutableStateOf(false) } + + val isNearBottom by remember { + derivedStateOf { + val info = listState.layoutInfo + val lastItem = info.visibleItemsInfo.lastOrNull() + val itemCount = info.totalItemsCount + if (lastItem == null || itemCount == 0) true + else lastItem.index >= itemCount - 2 && + (lastItem.offset + lastItem.size - info.viewportEndOffset) < nearBottomThresholdPx + } + } + + // Detect user scroll-away: when user touches/flings away from bottom, set the flag. + // When they return to the bottom (manually or via FAB), clear it. + LaunchedEffect(Unit) { + snapshotFlow { (isTouching || listState.isScrollInProgress) to isNearBottom } + .collect { (userIsScrolling, nearBottom) -> + if (userIsScrolling && !nearBottom) { + userScrolledUp = true + } else if (!userIsScrolling && nearBottom) { + userScrolledUp = false + } + } + } + + val showScrollToBottom by remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + totalItemCount > 0 && lastVisibleItem < totalItemCount - 1 + } + } + + // ── Auto-scroll behavior ────────────────────────────────────────── + // + // 1. INITIAL LOAD — Scroll to the most recent message once on the + // first non-empty emission. Never fires again for this instance. + // + // 2. STREAMING START — Jump to bottom when isStreaming flips true. + // + // 3. STREAMING FOLLOW — A snapshotFlow observes streamingContent + // length (via rememberUpdatedState) so every token guarantees an + // emission. On each emission we check two conditions: + // a) User is NOT actively scrolling (isScrollInProgress) so + // programmatic scrolls never fight user gestures. + // b) Pixel-based "near bottom" — the bottom edge of the last + // visible item must be within 200dp of the viewport bottom. + // This avoids yanking the user when they're reading the top + // of a tall streaming bubble. + // Scrolls are launched in an isolated coroutine so that if a + // late user touch cancels the scroll via the scroll mutex, the + // CancellationException doesn't kill the snapshotFlow collector. + // + // 4. POST-STREAMING — When streaming ends, a 300ms delay lets the + // Room observer replace the streaming bubble with the real AI + // message, then we scroll to show the complete response. + // + // Auto-scroll does NOT fire for branch switching, message deletion, + // or any other display-list change — only streaming and initial load. + // ───────────────────────────────────────────────────────────────── + + // Track whether we need a fine-tune scroll after the item scroll completes. + // When the focused occurrence's HighlightedTextSegment reports its layout position, + // we compare it against the viewport and animateScrollBy the delta if needed. + var pendingFineTuneScroll by remember { mutableStateOf(false) } + + // Scroll to search match when navigating prev/next + LaunchedEffect(searchScrollToIndex) { + val index = searchScrollToIndex + if (index != null && index in displayMessages.indices) { + pendingFineTuneScroll = true + listState.animateScrollToItem(index) + // After scroll completes, the focused segment will report its position + // via onGloballyPositioned, and the fine-tune scroll will happen there. + onSearchScrollHandled() + } + } + + // Determine the currently focused SearchMatch + val currentSearchMatch: SearchMatch? = if (searchMatchIndices.isNotEmpty() && + currentSearchMatchIndex in searchMatchIndices.indices + ) { + searchMatchIndices[currentSearchMatchIndex] + } else { + null + } + + // Build a set of matching message indices for O(1) lookup + val searchMatchMessageIndexSet = remember(searchMatchIndices) { + searchMatchIndices.map { it.messageIndex }.toSet() + } + + var hasScrolledToBottom by remember { mutableStateOf(false) } + LaunchedEffect(displayMessages.size) { + if (!hasScrolledToBottom && displayMessages.isNotEmpty()) { + listState.scrollToItem(totalItemCount - 1, scrollOffset = Int.MAX_VALUE) + hasScrolledToBottom = true + } + } + + var wasStreaming by remember { mutableStateOf(false) } + LaunchedEffect(isStreaming) { + if (isStreaming) { + wasStreaming = true + // Reset scroll-away flag — user just sent a message, start at bottom + userScrolledUp = false + // Jump to bottom immediately (user just sent a message) + val total = listState.layoutInfo.totalItemsCount + if (total > 0) { + listState.scrollToItem(total - 1, scrollOffset = Int.MAX_VALUE) + } + // Rule 3: streaming follow (see block comment above) + snapshotFlow { + currentStreamingContent.length + }.collect { + // Skip auto-scroll if user is actively touching the screen, + // if a fling is in progress, or if user has scrolled away. + if (!isTouching && !listState.isScrollInProgress && !userScrolledUp) { + val info = listState.layoutInfo + val lastItem = info.visibleItemsInfo.lastOrNull() + val itemCount = info.totalItemsCount + if (lastItem != null && itemCount > 0 && lastItem.index >= itemCount - 2) { + val contentBottom = lastItem.offset + lastItem.size + val distanceFromBottom = contentBottom - info.viewportEndOffset + if (distanceFromBottom < nearBottomThresholdPx) { + coroutineScope.launch { + listState.scrollToItem(itemCount - 1, scrollOffset = Int.MAX_VALUE) + } + } + } + } + } + } else if (wasStreaming) { + // Streaming just ended — wait for Room observer to emit the + // final messages (replacing the streaming bubble with the real + // AI message), then scroll to show the complete response. + // Only auto-scroll if user hasn't scrolled away. + wasStreaming = false + if (!userScrolledUp) { + delay(300) + val total = listState.layoutInfo.totalItemsCount + if (total > 0) { + listState.scrollToItem(total - 1, scrollOffset = Int.MAX_VALUE) + } + } + } + } + + // ── Keyboard scroll ──────────────────────────────────────────── + // When the soft keyboard (IME) opens, the Scaffold's imePadding() + // shrinks the viewport. We detect this by observing the + // LazyColumn's viewportEndOffset via snapshotFlow. When it + // shrinks and the user was near the bottom, we scroll down to + // keep the latest messages visible above the input box. + // + // This approach is more reliable than observing WindowInsets.ime + // directly because: + // - It fires AFTER the layout has actually resized (no timing + // issues with keyboard animation). + // - It avoids known issues with WindowInsets.isImeVisible on + // targetSdk 35. + // - It naturally handles any cause of viewport shrinkage, not + // just keyboard appearance. + // ─────────────────────────────────────────────────────────────── + LaunchedEffect(Unit) { + var previousViewportEnd = 0 + snapshotFlow { + listState.layoutInfo.viewportEndOffset + }.collect { viewportEnd -> + if (previousViewportEnd > 0 && viewportEnd < previousViewportEnd) { + // Viewport shrank (e.g., keyboard opened). If user was + // near the bottom, scroll to keep them there. + val info = listState.layoutInfo + val lastVisible = info.visibleItemsInfo.lastOrNull() + val itemCount = info.totalItemsCount + if (lastVisible != null && itemCount > 0 && lastVisible.index >= itemCount - 2) { + coroutineScope.launch { + listState.scrollToItem(itemCount - 1, scrollOffset = Int.MAX_VALUE) + } + } + } + previousViewportEnd = viewportEnd + } + } + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize(), + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + // Observe on Initial pass so we don't interfere with scrolling + val event = awaitPointerEvent(PointerEventPass.Initial) + isTouching = event.changes.any { it.pressed } + } + } + }, + state = listState, + contentPadding = PaddingValues(top = 8.dp, bottom = 160.dp), + ) { + itemsIndexed( + items = displayMessages, + key = { _, node -> node.message.messageId }, + contentType = { _, node -> + val role = if (node.message.isCreatedByUser) "user" else "assistant" + "${chatLayoutStyle}_${role}" + }, + ) { index, node -> + val feedbackRating = node.message.feedback?.rating + val currentFeedbackStr = when (feedbackRating) { + FeedbackRating.THUMBS_UP -> "thumbsUp" + FeedbackRating.THUMBS_DOWN -> "thumbsDown" + null -> null + } + + val isMatch = index in searchMatchMessageIndexSet + val isCurrent = currentSearchMatch != null && index == currentSearchMatch.messageIndex + // Determine which occurrence in this message is focused + val focusedOccurrenceInMessage = if (isCurrent) { + currentSearchMatch?.occurrenceInMessage ?: -1 + } else { + -1 + } + + MessageBubble( + message = node.message, + siblingIndex = node.siblingIndex, + siblingCount = node.siblingCount, + onSiblingNavigation = { newIndex -> + lastNavigatedParentKey = node.treeParentKey + onSiblingNavigation(node.treeParentKey, newIndex) + }, + showActionsInitially = lastNavigatedParentKey == node.treeParentKey, + onEdit = { onEditMessage(node.message.messageId) }, + onRegenerate = if (!node.message.isCreatedByUser) { + { onRegenerateMessage(node.message.messageId) } + } else { + null + }, + onCopy = { onCopyMessage(node.message.messageId) }, + onFeedback = if (!node.message.isCreatedByUser) { + { rating -> onFeedback(node.message.messageId, rating) } + } else { + null + }, + onContinue = if (!node.message.isCreatedByUser) { + { onContinue(node.message.messageId) } + } else { + null + }, + onReadAloud = { onReadAloud(node.message.messageId) }, + onFork = { onFork(node.message.messageId) }, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + isReading = currentlyReadingMessageId == node.message.messageId, + currentFeedback = currentFeedbackStr, + isEditing = editingMessageId == node.message.messageId, + editText = if (editingMessageId == node.message.messageId) editingText else "", + onEditTextChanged = onEditTextChanged, + onEditSaveAndSubmit = onEditSaveAndSubmit, + onEditSaveOnly = onEditSaveOnly, + onEditCancel = onEditCancel, + userAvatarUrl = userAvatarUrl, + userName = userName, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + showImageDescriptions = showImageDescriptions, + chatLayoutStyle = chatLayoutStyle, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + searchQuery = searchQuery, + isSearchMatch = isMatch, + isCurrentSearchMatch = isCurrent, + searchFocusedOccurrence = focusedOccurrenceInMessage, + onFocusedOccurrencePositioned = if (isCurrent) { coordinates -> + if (!pendingFineTuneScroll) return@MessageBubble + pendingFineTuneScroll = false + + val layoutInfo = listState.layoutInfo + val viewportTop = layoutInfo.viewportStartOffset.toFloat() + val viewportBottom = layoutInfo.viewportEndOffset.toFloat() + + val segmentBounds = coordinates.boundsInRoot() + val padding = 80f // Extra padding so the highlight isn't flush against edges + + coroutineScope.launch { + if (segmentBounds.top < viewportTop + padding) { + // Segment is above or too close to the top of the viewport + listState.animateScrollBy(segmentBounds.top - viewportTop - padding) + } else if (segmentBounds.bottom > viewportBottom - padding) { + // Segment is below or too close to the bottom of the viewport + listState.animateScrollBy(segmentBounds.bottom - viewportBottom + padding) + } + } + } else { + null + }, + ) + } + + if (isStreaming) { + item(key = "streaming_message") { + StreamingMessageBubble( + streamingContent = streamingContent, + senderName = streamingSenderName, + senderIconUrl = null, + fontSizeMultiplier = fontSizeMultiplier, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + chatLayoutStyle = chatLayoutStyle, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + ) + } + + if (activeToolCalls.isNotEmpty()) { + items( + items = activeToolCalls, + key = { "tool_call_${it.id}" }, + contentType = { "tool_call" }, + ) { toolCall -> + StreamingToolCallCard( + toolCall = toolCall, + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 4.dp, + ), + ) + } + } + } + } + + // Scroll-to-bottom FAB + AnimatedVisibility( + visible = showScrollToBottom, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 130.dp), + enter = fadeIn(), + exit = fadeOut(), + ) { + SmallFloatingActionButton( + onClick = { + userScrolledUp = false + coroutineScope.launch { + if (totalItemCount > 0) { + listState.animateScrollToItem(totalItemCount - 1, scrollOffset = Int.MAX_VALUE) + } + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 4.dp, + ), + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.cd_scroll_to_bottom), + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageTimestamp.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageTimestamp.kt new file mode 100644 index 0000000..6201a79 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageTimestamp.kt @@ -0,0 +1,94 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.TimeUnit + +/** Displays a message timestamp with relative time ("2m ago") that toggles to absolute on tap. Parses multiple ISO 8601 variants; returns empty on invalid input. */ +@Composable +fun MessageTimestamp( + isoTimestamp: String, + modifier: Modifier = Modifier, +) { + var showAbsolute by remember { mutableStateOf(false) } + + val timeText = remember(isoTimestamp, showAbsolute) { + if (showAbsolute) { + formatAbsoluteTimestamp(isoTimestamp) + } else { + formatRelativeTimestamp(isoTimestamp) + } + } + + if (timeText.isNotEmpty()) { + Text( + text = timeText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + showAbsolute = !showAbsolute + }, + ) + } +} + +internal fun formatRelativeTimestamp(isoTimestamp: String): String { + val date = parseTimestamp(isoTimestamp) ?: return "" + val now = System.currentTimeMillis() + val diff = now - date.time + if (diff < 0) return "now" + + val seconds = TimeUnit.MILLISECONDS.toSeconds(diff) + val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) + val hours = TimeUnit.MILLISECONDS.toHours(diff) + val days = TimeUnit.MILLISECONDS.toDays(diff) + + return when { + seconds < 60 -> "now" + minutes < 60 -> "${minutes}m ago" + hours < 24 -> "${hours}h ago" + days < 7 -> "${days}d ago" + days < 30 -> "${days / 7}w ago" + else -> formatAbsoluteTimestamp(isoTimestamp) + } +} + +internal fun formatAbsoluteTimestamp(isoTimestamp: String): String { + val date = parseTimestamp(isoTimestamp) ?: return "" + val format = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) + return format.format(date) +} + +private fun parseTimestamp(timestamp: String): Date? { + val formats = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + ) + for (pattern in formats) { + try { + val sdf = SimpleDateFormat(pattern, Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + return sdf.parse(timestamp) + } catch (_: Exception) { + continue + } + } + return null +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorButton.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorButton.kt new file mode 100644 index 0000000..bee5e9f --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorButton.kt @@ -0,0 +1,46 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun ModelSelectorButton( + modelName: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextButton( + onClick = onClick, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = modelName ?: stringResource(R.string.select_model), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.cd_select_model), + modifier = Modifier.size(20.dp), + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorSheet.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorSheet.kt new file mode 100644 index 0000000..10bcdc2 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ModelSelectorSheet.kt @@ -0,0 +1,400 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.SmartToy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.model.Agent +import com.librechat.android.core.model.EndpointConfig +import com.librechat.android.core.ui.components.endpointIconRes +import com.librechat.android.core.ui.components.isMonochromeEndpointIcon +import me.xdrop.fuzzywuzzy.FuzzySearch +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +private val IconSize = 20.dp +private const val FUZZY_MATCH_THRESHOLD = 55 + +private fun fuzzyMatches(candidate: String, query: String): Boolean { + // Short queries (1-2 chars) use substring matching for better UX + if (query.length <= 2) return candidate.contains(query, ignoreCase = true) + return FuzzySearch.weightedRatio(query.lowercase(), candidate.lowercase()) >= FUZZY_MATCH_THRESHOLD +} + +/** + * Returns the fuzzy match score (0-100) for [candidate] against [query]. + * For short queries (1-2 chars), returns 100 if substring matches, 0 otherwise. + */ +private fun fuzzyScore(candidate: String, query: String): Int { + if (query.length <= 2) { + return if (candidate.contains(query, ignoreCase = true)) 100 else 0 + } + return FuzzySearch.weightedRatio(query.lowercase(), candidate.lowercase()) +} + +/** + * Returns a Material icon fallback for endpoint names that don't have a bundled drawable. + */ +private fun endpointFallbackIcon(endpointName: String): ImageVector = when (endpointName) { + EndpointConstants.AGENTS -> Icons.Outlined.Create + "assistants", "azureAssistants" -> Icons.Outlined.AutoAwesome + else -> Icons.Outlined.SmartToy +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModelSelectorSheet( + endpointConfigs: Map, + availableModels: Map>, + agents: List, + selectedEndpoint: String?, + selectedModel: String?, + onModelSelected: (endpoint: String, model: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + serverUrl: String = "", +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + var searchQuery by remember { mutableStateOf("") } + val expandedGroups = remember { + mutableStateMapOf().apply { + availableModels.keys.forEach { put(it, false) } + if (agents.isNotEmpty()) put(EndpointConstants.AGENTS, false) + } + } + val isSearching = searchQuery.isNotBlank() + + // Filter to only show models for endpoints the user's server has enabled + val filteredByEndpoint = remember(availableModels, endpointConfigs) { + if (endpointConfigs.isEmpty()) { + availableModels + } else { + availableModels.filterKeys { it in endpointConfigs } + } + } + + // Filter agents by search query (fuzzy matching), sorted by score when searching + val filteredAgents = remember(agents, searchQuery) { + if (!isSearching) agents + else if (searchQuery.length <= 2) { + agents.filter { agent -> + val name = agent.name ?: agent.id + fuzzyMatches(name, searchQuery) + } + } else { + agents.map { agent -> + val name = agent.name ?: agent.id + agent to fuzzyScore(name, searchQuery) + } + .filter { (_, score) -> score >= FUZZY_MATCH_THRESHOLD } + .sortedByDescending { (_, score) -> score } + .map { (agent, _) -> agent } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.select_a_model), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp), + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search models...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.cd_clear_search), + ) + } + } + }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LazyColumn(modifier = Modifier.weight(1f)) { + // "My Agents" group (shown first, like the web frontend) + if (filteredAgents.isNotEmpty()) { + val agentsExpanded = expandedGroups[EndpointConstants.AGENTS] != false + item(key = "header_agents") { + EndpointGroupHeader( + endpointName = EndpointConstants.AGENTS, + displayLabel = "My Agents", + modelCount = filteredAgents.size, + isExpanded = agentsExpanded, + iconUrl = null, + onToggle = { expandedGroups[EndpointConstants.AGENTS] = !agentsExpanded }, + ) + } + if (agentsExpanded) { + items(filteredAgents, key = { "agents_${it.id}" }, contentType = { "agent" }) { agent -> + AgentListItem( + agent = agent, + isSelected = selectedEndpoint == EndpointConstants.AGENTS && agent.id == selectedModel, + serverUrl = serverUrl, + onClick = { onModelSelected(EndpointConstants.AGENTS, agent.id) }, + ) + } + } + } + + // Endpoint model groups + filteredByEndpoint.forEach { (endpointName, models) -> + val filteredModels = if (!isSearching) { + models + } else if (searchQuery.length <= 2) { + models.filter { fuzzyMatches(it, searchQuery) } + } else { + models.map { model -> model to fuzzyScore(model, searchQuery) } + .filter { (_, score) -> score >= FUZZY_MATCH_THRESHOLD } + .sortedByDescending { (_, score) -> score } + .map { (model, _) -> model } + } + if (filteredModels.isNotEmpty()) { + val config = endpointConfigs[endpointName] + val displayLabel = config?.modelDisplayLabel ?: endpointName + // Auto-expand groups when searching, otherwise use manual toggle state + val isExpanded = expandedGroups[endpointName] != false + item(key = "header_$endpointName") { + EndpointGroupHeader( + endpointName = endpointName, + displayLabel = displayLabel, + modelCount = filteredModels.size, + isExpanded = isExpanded, + iconUrl = config?.iconURL, + onToggle = { expandedGroups[endpointName] = !isExpanded }, + ) + } + if (isExpanded) { + items(filteredModels, key = { "${endpointName}_$it" }, contentType = { "model" }) { model -> + val isSelected = endpointName == selectedEndpoint && model == selectedModel + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onModelSelected(endpointName, model) } + .padding(vertical = 12.dp, horizontal = 8.dp) + .animateItem(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = model, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun EndpointGroupHeader( + endpointName: String, + displayLabel: String, + modelCount: Int, + isExpanded: Boolean, + iconUrl: String?, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + EndpointIcon( + endpointName = endpointName, + iconUrl = iconUrl, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$displayLabel ($modelCount)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = stringResource(if (isExpanded) R.string.cd_collapse_section else R.string.cd_expand_section, displayLabel), + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun EndpointIcon( + endpointName: String, + iconUrl: String?, +) { + // If the endpoint config provides a remote icon URL, use it + if (iconUrl != null) { + AsyncImage( + model = iconUrl, + contentDescription = "$endpointName icon", + modifier = Modifier + .size(IconSize) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + return + } + // Otherwise, fall back to bundled drawable or Material icon + val drawableRes = endpointIconRes(endpointName) + if (drawableRes != null) { + val tint = if (isMonochromeEndpointIcon(endpointName)) { + MaterialTheme.colorScheme.onSurface + } else { + Color.Unspecified + } + Icon( + painter = painterResource(id = drawableRes), + contentDescription = "$endpointName icon", + modifier = Modifier.size(IconSize), + tint = tint, + ) + } else { + Icon( + imageVector = endpointFallbackIcon(endpointName), + contentDescription = "$endpointName icon", + modifier = Modifier.size(IconSize), + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun LazyItemScope.AgentListItem( + agent: Agent, + isSelected: Boolean, + serverUrl: String, + onClick: () -> Unit, +) { + val agentName = agent.name ?: agent.id + val resolvedAvatarUrl = agent.avatarUrl?.let { url -> + if (url.startsWith("http")) url else "$serverUrl$url" + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 8.dp) + .animateItem(), + verticalAlignment = Alignment.CenterVertically, + ) { + // Agent avatar + if (resolvedAvatarUrl != null) { + AsyncImage( + model = resolvedAvatarUrl, + contentDescription = "$agentName avatar", + modifier = Modifier + .size(IconSize) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Outlined.Create, + contentDescription = "$agentName icon", + modifier = Modifier.size(IconSize), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = agentName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (agent.isPublic == true) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Outlined.Public, + contentDescription = stringResource(R.string.cd_public_agent), + modifier = Modifier.size(16.dp), + tint = Color(0xFF4CAF50), + ) + } + if (isSelected) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/PresetPicker.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/PresetPicker.kt new file mode 100644 index 0000000..293b159 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/PresetPicker.kt @@ -0,0 +1,161 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.chat.PresetDisplayData +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PresetPicker( + presets: List, + onPresetSelected: (PresetDisplayData) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onEditPreset: (PresetDisplayData) -> Unit = {}, + onDeletePreset: (PresetDisplayData) -> Unit = {}, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(R.string.select_preset), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + if (presets.isEmpty()) { + Text( + text = stringResource(R.string.no_presets_saved), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp), + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f, fill = false), + ) { + items(presets, key = { it.presetId ?: it.title }, contentType = { "preset" }) { preset -> + PresetItem( + preset = preset, + onClick = { onPresetSelected(preset) }, + onEdit = { onEditPreset(preset) }, + onDelete = { onDeletePreset(preset) }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun PresetItem( + preset: PresetDisplayData, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = preset.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row { + val endpointLabel = preset.endpointLabel + if (endpointLabel != null) { + Text( + text = endpointLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + val model = preset.model + if (model != null) { + if (endpointLabel != null) { + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = model, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + IconButton( + onClick = onEdit, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit_preset, preset.title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + + IconButton( + onClick = onDelete, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_preset, preset.title), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SavePresetDialog.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SavePresetDialog.kt new file mode 100644 index 0000000..d48e3ea --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SavePresetDialog.kt @@ -0,0 +1,74 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun SavePresetDialog( + currentEndpoint: String, + currentModel: String?, + onSave: (name: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var presetName by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.save_as_preset)) }, + text = { + Column { + OutlinedTextField( + value = presetName, + onValueChange = { presetName = it }, + label = { Text(stringResource(R.string.preset_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.preset_endpoint, currentEndpoint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (currentModel != null) { + Text( + text = stringResource(R.string.preset_model, currentModel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onSave(presetName) }, + enabled = presetName.isNotBlank(), + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SearchHighlight.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SearchHighlight.kt new file mode 100644 index 0000000..306f3eb --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SearchHighlight.kt @@ -0,0 +1,110 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString + +// --- Light mode highlight colors --- +private val SearchHighlightYellowLight = Color(0xFFFFEB3B) +private val SearchHighlightOrangeLight = Color(0xFFFF9800) + +// --- Dark mode highlight colors (muted, for readable contrast with light text) --- +private val SearchHighlightYellowDark = Color(0xFF5C4800) +private val SearchHighlightOrangeDark = Color(0xFF804D00) + +/** + * Background color for all search matches (non-focused), adapted to theme. + */ +val SearchHighlightYellow: Color + @Composable get() = if (isSystemInDarkTheme()) SearchHighlightYellowDark else SearchHighlightYellowLight + +/** + * Background color for the currently focused search match, adapted to theme. + */ +val SearchHighlightOrange: Color + @Composable get() = if (isSystemInDarkTheme()) SearchHighlightOrangeDark else SearchHighlightOrangeLight + +/** + * Returns the non-focused search highlight color for the given theme. + */ +fun searchHighlightYellow(isDarkTheme: Boolean): Color = + if (isDarkTheme) SearchHighlightYellowDark else SearchHighlightYellowLight + +/** + * Returns the focused search highlight color for the given theme. + */ +fun searchHighlightOrange(isDarkTheme: Boolean): Color = + if (isDarkTheme) SearchHighlightOrangeDark else SearchHighlightOrangeLight + +/** + * Builds an [AnnotatedString] from [text] with background spans applied to all + * case-insensitive occurrences of [query]. + * + * All matches get a yellow background. If [focusedOccurrence] >= 0, + * that specific occurrence (0-based within this text) gets an orange background + * instead to indicate the currently focused search result. + * + * @param isDarkTheme When true, uses muted dark-mode highlight colors that maintain + * contrast with light text. When false, uses bright highlight colors for light mode. + */ +fun buildHighlightedString( + text: String, + query: String, + focusedOccurrence: Int = -1, + isDarkTheme: Boolean = false, +): AnnotatedString { + if (query.isBlank()) return AnnotatedString(text) + + val highlightColor = searchHighlightYellow(isDarkTheme) + val focusedColor = searchHighlightOrange(isDarkTheme) + + return buildAnnotatedString { + append(text) + + val lowerText = text.lowercase() + val lowerQuery = query.lowercase() + var startIndex = 0 + var occurrence = 0 + + while (true) { + val foundIndex = lowerText.indexOf(lowerQuery, startIndex) + if (foundIndex < 0) break + + val bgColor = if (occurrence == focusedOccurrence) { + focusedColor + } else { + highlightColor + } + + addStyle( + SpanStyle(background = bgColor), + start = foundIndex, + end = foundIndex + query.length, + ) + + occurrence++ + startIndex = foundIndex + query.length + } + } +} + +/** + * Counts the number of case-insensitive occurrences of [query] in [text]. + */ +fun countOccurrences(text: String, query: String): Int { + if (query.isBlank() || text.isBlank()) return 0 + val lowerText = text.lowercase() + val lowerQuery = query.lowercase() + var count = 0 + var startIndex = 0 + while (true) { + val foundIndex = lowerText.indexOf(lowerQuery, startIndex) + if (foundIndex < 0) break + count++ + startIndex = foundIndex + query.length + } + return count +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SecondaryMessageList.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SecondaryMessageList.kt new file mode 100644 index 0000000..50c8cb4 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SecondaryMessageList.kt @@ -0,0 +1,144 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.annotation.DrawableRes +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.feature.chat.R +import com.librechat.android.feature.chat.util.MessageNode +import com.librechat.android.feature.chat.viewmodel.ActiveToolCall + +/** + * Simplified message list for the secondary comparison pane. + * Does not support editing, forking, sibling navigation, or search highlighting. + * Just displays messages and streaming content for the comparison model. + */ +@Composable +fun SecondaryMessageList( + displayMessages: List, + isStreaming: Boolean, + streamingContent: String, + activeToolCalls: List = emptyList(), + error: String? = null, + baseUrl: String = "", + fontSizeMultiplier: Float = 1.0f, + @DrawableRes endpointIconRes: Int? = null, + tintEndpointIcon: Boolean = false, + streamingSenderName: String = "Assistant", + showImageDescriptions: Boolean = true, + chatLayoutStyle: String = ChatLayoutConstants.THREAD, + showAvatars: Boolean = true, + showBubbles: Boolean = false, + useKatex: Boolean = false, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val totalItemCount = displayMessages.size + + (if (isStreaming) 1 else 0) + + (if (isStreaming) activeToolCalls.size else 0) + + // Auto-scroll to bottom during streaming + LaunchedEffect(streamingContent.length, totalItemCount) { + if (totalItemCount > 0) { + listState.scrollToItem(totalItemCount - 1, scrollOffset = Int.MAX_VALUE) + } + } + + Box(modifier = modifier.fillMaxSize()) { + if (error != null) { + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + ) + } else if (!isStreaming && displayMessages.isEmpty() && streamingContent.isBlank()) { + Text( + text = stringResource(R.string.waiting_for_response), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(top = 8.dp, bottom = 160.dp), + ) { + items( + items = displayMessages, + key = { node -> "secondary_${node.message.messageId}" }, + contentType = { "message" }, + ) { node -> + MessageBubble( + message = node.message, + siblingIndex = node.siblingIndex, + siblingCount = node.siblingCount, + onSiblingNavigation = { /* no-op for secondary */ }, + onEdit = {}, + onCopy = {}, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + showImageDescriptions = showImageDescriptions, + chatLayoutStyle = chatLayoutStyle, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + ) + } + + if (isStreaming) { + item(key = "secondary_streaming_message") { + StreamingMessageBubble( + streamingContent = streamingContent, + senderName = streamingSenderName, + senderIconUrl = null, + fontSizeMultiplier = fontSizeMultiplier, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + chatLayoutStyle = chatLayoutStyle, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + ) + } + + if (activeToolCalls.isNotEmpty()) { + items( + items = activeToolCalls, + key = { "secondary_tool_call_${it.id}" }, + contentType = { "tool_call" }, + ) { toolCall -> + StreamingToolCallCard( + toolCall = toolCall, + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 4.dp, + ), + ) + } + } + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SiblingNavigator.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SiblingNavigator.kt new file mode 100644 index 0000000..404e693 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SiblingNavigator.kt @@ -0,0 +1,80 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun SiblingNavigator( + siblingIndex: Int, + siblingCount: Int, + onNavigate: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + if (siblingCount <= 1) return + + Row( + modifier = modifier.semantics { + stateDescription = "Response ${siblingIndex + 1} of $siblingCount" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + IconButton( + onClick = { onNavigate(siblingIndex - 1) }, + enabled = siblingIndex > 0, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.cd_previous_response), + modifier = Modifier.size(20.dp), + tint = if (siblingIndex > 0) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) + } + + Text( + text = "${siblingIndex + 1} / $siblingCount", + style = MaterialTheme.typography.labelSmall.copy( + fontFeatureSettings = "tnum", + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + IconButton( + onClick = { onNavigate(siblingIndex + 1) }, + enabled = siblingIndex < siblingCount - 1, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.cd_next_response), + modifier = Modifier.size(20.dp), + tint = if (siblingIndex < siblingCount - 1) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SlashCommandMenu.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SlashCommandMenu.kt new file mode 100644 index 0000000..ef7699e --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/SlashCommandMenu.kt @@ -0,0 +1,55 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.chat.PromptMentionDisplayData + +/** + * Dropdown menu showing slash command suggestions. + * Appears when the user types "/" at position 0 in the input field. + * Filters by the [PromptMentionDisplayData.command] field. + */ +@Composable +fun SlashCommandMenu( + filteredCommands: List, + onCommandSelected: (PromptMentionDisplayData) -> Unit, + modifier: Modifier = Modifier, +) { + DropdownMenu( + expanded = filteredCommands.isNotEmpty(), + onDismissRequest = { /* Dismissed by typing or selecting */ }, + modifier = modifier.heightIn(max = 240.dp), + ) { + filteredCommands.forEach { group -> + DropdownMenuItem( + text = { + Column { + Text( + text = "/${group.command ?: group.name}", + style = MaterialTheme.typography.bodyMedium, + ) + val oneliner = group.oneliner + if (!oneliner.isNullOrBlank()) { + Text( + text = oneliner, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + onClick = { onCommandSelected(group) }, + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingIndicator.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingIndicator.kt new file mode 100644 index 0000000..84da0e0 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingIndicator.kt @@ -0,0 +1,57 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Blinking cursor indicator shown during streaming. Displays a pipe character (|) + * with a smooth alpha animation, matching the web frontend's insertion-point cursor. + */ +@Composable +fun StreamingIndicator( + modifier: Modifier = Modifier, +) { + val infiniteTransition = rememberInfiniteTransition(label = "streaming_cursor") + + val cursorAlpha by infiniteTransition.animateFloat( + initialValue = 1.0f, + targetValue = 0.0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "cursor_blink", + ) + + val generatingCd = stringResource(R.string.cd_generating_response) + Text( + text = "\u258C", // left half block character for a visible cursor + modifier = modifier + .padding(start = 1.dp) + .alpha(cursorAlpha) + .semantics { contentDescription = generatingCd }, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + ), + color = MaterialTheme.colorScheme.primary, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingMessageBubble.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingMessageBubble.kt new file mode 100644 index 0000000..05d694e --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingMessageBubble.kt @@ -0,0 +1,245 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.annotation.DrawableRes +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.core.ui.components.AvatarImage + +// Shared BubbleShape is imported from MessageBubble.kt + +/** + * Dedicated composable for rendering a message that is currently being streamed. + * Extracted from MessageBubble.kt to avoid merge conflicts with WS4 + * (which modifies the action row in MessageBubble). + * + * Shows a blinking cursor at the end of the streaming content, or a standalone + * blinking cursor when no content has arrived yet. + */ +@Composable +fun StreamingMessageBubble( + streamingContent: String, + senderName: String, + senderIconUrl: String?, + fontSizeMultiplier: Float = 1.0f, + @DrawableRes endpointIconRes: Int? = null, + tintEndpointIcon: Boolean = false, + chatLayoutStyle: String = ChatLayoutConstants.THREAD, + showAvatars: Boolean = true, + showBubbles: Boolean = false, + useKatex: Boolean = false, + modifier: Modifier = Modifier, +) { + if (chatLayoutStyle == ChatLayoutConstants.TWO_SIDED) { + TwoSidedStreamingBubble( + streamingContent = streamingContent, + senderName = senderName, + senderIconUrl = senderIconUrl, + fontSizeMultiplier = fontSizeMultiplier, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + modifier = modifier, + ) + } else { + ThreadStreamingBubble( + streamingContent = streamingContent, + senderName = senderName, + senderIconUrl = senderIconUrl, + fontSizeMultiplier = fontSizeMultiplier, + endpointIconRes = endpointIconRes, + tintEndpointIcon = tintEndpointIcon, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + modifier = modifier, + ) + } +} + +@Composable +private fun ThreadStreamingBubble( + streamingContent: String, + senderName: String, + senderIconUrl: String?, + fontSizeMultiplier: Float, + @DrawableRes endpointIconRes: Int?, + tintEndpointIcon: Boolean, + showAvatars: Boolean, + showBubbles: Boolean, + useKatex: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + // Sender row with avatar + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (showAvatars) { + AvatarImage( + imageUrl = senderIconUrl, + fallbackText = senderName, + fallbackIconRes = if (senderIconUrl == null) endpointIconRes else null, + tintIcon = if (senderIconUrl == null) tintEndpointIcon else false, + size = 28.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = senderName, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Streaming content with blinking cursor + val contentStartPadding = if (showAvatars) 36.dp else 0.dp + Column( + modifier = Modifier + .padding(start = contentStartPadding) + .fillMaxWidth() + .then( + if (showBubbles) { + Modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = BubbleShape, + ) + .padding(12.dp) + } else { + Modifier + }, + ) + .semantics { + liveRegion = LiveRegionMode.Polite + contentDescription = if (streamingContent.isNotBlank()) { + "Assistant is responding: $streamingContent" + } else { + "Assistant is generating a response" + } + }, + ) { + if (streamingContent.isNotBlank()) { + MarkdownContent(text = streamingContent, fontSizeMultiplier = fontSizeMultiplier, useKatex = useKatex) + StreamingIndicator() + } else { + StreamingIndicator() + } + } + } +} + +@Composable +private fun TwoSidedStreamingBubble( + streamingContent: String, + senderName: String, + senderIconUrl: String?, + fontSizeMultiplier: Float, + @DrawableRes endpointIconRes: Int?, + tintEndpointIcon: Boolean, + showAvatars: Boolean, + showBubbles: Boolean, + useKatex: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = 8.dp, + vertical = 4.dp, + ), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Top, + ) { + // Agent avatar on left + if (showAvatars) { + AvatarImage( + imageUrl = senderIconUrl, + fallbackText = senderName, + fallbackIconRes = if (senderIconUrl == null) endpointIconRes else null, + tintIcon = if (senderIconUrl == null) tintEndpointIcon else false, + size = 28.dp, + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + Column( + modifier = Modifier + .weight(1f) + .then( + if (showBubbles) { + Modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = BubbleShape, + ) + .padding(12.dp) + } else { + Modifier.padding( + horizontal = 4.dp, + vertical = 8.dp, + ) + }, + ) + .semantics { + liveRegion = LiveRegionMode.Polite + contentDescription = if (streamingContent.isNotBlank()) { + "Assistant is responding: $streamingContent" + } else { + "Assistant is generating a response" + } + }, + ) { + Text( + text = senderName, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = if (showBubbles) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Spacer(modifier = Modifier.height(4.dp)) + if (streamingContent.isNotBlank()) { + MarkdownContent(text = streamingContent, fontSizeMultiplier = fontSizeMultiplier, useKatex = useKatex) + StreamingIndicator() + } else { + StreamingIndicator() + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingToolCallCard.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingToolCallCard.kt new file mode 100644 index 0000000..7b2db06 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/StreamingToolCallCard.kt @@ -0,0 +1,126 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.chat.viewmodel.ActiveToolCall +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun StreamingToolCallCard( + toolCall: ActiveToolCall, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + val canExpand = toolCall.isComplete && !toolCall.output.isNullOrBlank() + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (canExpand) Modifier.clickable { isExpanded = !isExpanded } + else Modifier, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = toolCall.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + + if (toolCall.isComplete) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.cd_tool_complete), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (canExpand) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + AnimatedVisibility( + visible = isExpanded && canExpand, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tool_call_output), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = toolCall.output.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/TempChatToggle.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/TempChatToggle.kt new file mode 100644 index 0000000..585bcbc --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/TempChatToggle.kt @@ -0,0 +1,52 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics + +/** + * Ghost icon toggle for temporary chat mode. + * When enabled, the conversation will not be saved to history. + * Visible only when starting a new chat (no existing conversation). + */ +@Composable +fun TempChatToggle( + isTemporary: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + onClick = onToggle, + modifier = modifier.semantics { + contentDescription = if (isTemporary) { + "Disable temporary chat" + } else { + "Enable temporary chat" + } + role = Role.Switch + }, + ) { + Icon( + imageVector = if (isTemporary) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + tint = if (isTemporary) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ToolsDropdownMenu.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ToolsDropdownMenu.kt new file mode 100644 index 0000000..f885a9a --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ToolsDropdownMenu.kt @@ -0,0 +1,89 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.feature.chat.R + +private data class ToolMenuItem( + val id: String, + val label: String, + val icon: ImageVector, +) + +private val extraTools = listOf( + ToolMenuItem("dalle", "DALL-E", Icons.Default.Image), + ToolMenuItem(ToolConstants.CODE_INTERPRETER, "Code Interpreter", Icons.Default.Code), + ToolMenuItem(ToolConstants.WEB_SEARCH, "Web Search", Icons.Default.Search), +) + +@Composable +fun ToolsDropdownMenu( + enabledTools: Set, + onToggleTool: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + isCodeInterpreterAvailable: Boolean = true, +) { + val visibleTools = remember(isCodeInterpreterAvailable) { + if (isCodeInterpreterAvailable) { + extraTools + } else { + extraTools.filter { it.id != ToolConstants.CODE_INTERPRETER } + } + } + + DropdownMenu( + expanded = true, + onDismissRequest = onDismiss, + modifier = modifier, + ) { + visibleTools.forEach { tool -> + val isEnabled = tool.id in enabledTools + DropdownMenuItem( + text = { + Text( + text = tool.label, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = { + onToggleTool(tool.id) + }, + leadingIcon = { + Icon( + imageVector = tool.icon, + contentDescription = null, + tint = if (isEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + trailingIcon = { + if (isEnabled) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.cd_tool_enabled), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContent.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContent.kt new file mode 100644 index 0000000..bee5b2e --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContent.kt @@ -0,0 +1,94 @@ +package com.librechat.android.feature.chat.components + +import android.net.Uri +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun VideoContent( + url: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + + val exoPlayer = remember(url) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(Uri.parse(url))) + prepare() + } + } + + DisposableEffect(exoPlayer) { + onDispose { + exoPlayer.release() + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f), + factory = { + PlayerView(it).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + player = exoPlayer + useController = true + } + }, + ) + + if (!isPlaying) { + IconButton( + onClick = { + exoPlayer.play() + isPlaying = true + }, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.cd_play_video), + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContentPlayer.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContentPlayer.kt new file mode 100644 index 0000000..542a9b4 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/VideoContentPlayer.kt @@ -0,0 +1,116 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** Plays video via ExoPlayer (media3) in a 16:9 card. Releases player on dispose via DisposableEffect. */ +@Composable +fun VideoContentPlayer( + videoUrl: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var isLoading by remember { mutableStateOf(true) } + var hasStarted by remember { mutableStateOf(false) } + + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(videoUrl)) + prepare() + playWhenReady = false + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + isLoading = playbackState == Player.STATE_BUFFERING + if (playbackState == Player.STATE_READY) { + hasStarted = true + } + } + }) + } + } + + DisposableEffect(Unit) { + onDispose { + exoPlayer.release() + } + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Box( + contentAlignment = Alignment.Center, + ) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = true + } + }, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f), + ) + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + + if (!hasStarted && !isLoading) { + FilledIconButton( + onClick = { exoPlayer.play() }, + modifier = Modifier.size(56.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.85f), + ), + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.cd_play_video), + modifier = Modifier + .size(32.dp) + .padding(start = 2.dp), + ) + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/WebSearchResultCard.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/WebSearchResultCard.kt new file mode 100644 index 0000000..e5a31a0 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/WebSearchResultCard.kt @@ -0,0 +1,185 @@ +package com.librechat.android.feature.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Language +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * Data class representing a single web search result parsed from tool call output. + */ +data class WebSearchResult( + val title: String, + val url: String, + val snippet: String, + val favicon: String? = null, +) + +/** + * Card displaying a web search result with favicon, title, URL, and expandable snippet. + * Clicking the card opens the URL in an external browser. + */ +@Composable +fun WebSearchResultCard( + result: WebSearchResult, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + var isSnippetExpanded by remember { mutableStateOf(false) } + + val searchResultCd = stringResource(R.string.cd_search_result, result.title) + Card( + modifier = modifier + .fillMaxWidth() + .clickable { + try { + uriHandler.openUri(result.url) + } catch (_: Exception) { + // URL might be malformed + } + } + .semantics { + role = Role.Button + contentDescription = searchResultCd + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + ) { + // Favicon + val faviconUrl = result.favicon + ?: result.url.let { url -> + try { + val host = android.net.Uri.parse(url).host + if (host != null) "https://www.google.com/s2/favicons?domain=$host&sz=48" else null + } catch (_: Exception) { + null + } + } + + if (faviconUrl != null) { + SubcomposeAsyncImage( + model = faviconUrl, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + error = { + Icon( + imageVector = Icons.Default.Language, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } else { + Icon( + imageVector = Icons.Default.Language, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + // Title + Text( + text = result.title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + // URL + Text( + text = result.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (result.snippet.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + + // Snippet (expandable) + Text( + text = result.snippet, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (isSnippetExpanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable( + onClick = { isSnippetExpanded = !isSnippetExpanded }, + ), + ) + } + } + } + } +} + +/** + * Renders a list of web search results as stacked cards. + */ +@Composable +fun WebSearchResultList( + results: List, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + results.forEachIndexed { index, result -> + WebSearchResultCard(result = result) + if (index < results.lastIndex) { + Spacer(modifier = Modifier.height(6.dp)) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactButton.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactButton.kt new file mode 100644 index 0000000..1e49c7e --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactButton.kt @@ -0,0 +1,130 @@ +package com.librechat.android.feature.chat.components.artifact + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Widgets +import androidx.compose.material.icons.filled.Web +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun ArtifactButton( + artifact: Artifact, + onClick: () -> Unit, + modifier: Modifier = Modifier, + versionCount: Int = 1, +) { + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = artifactTypeIcon(artifact.type), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = artifact.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = artifactTypeSubtitle(artifact.type), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f), + maxLines = 1, + ) + } + if (versionCount > 1) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(20.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "$versionCount", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.cd_open_artifact), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f), + ) + } + } +} + +private fun artifactTypeIcon(type: String): ImageVector { + return when { + type.contains("mermaid") -> Icons.Default.AccountTree + type.contains("react") -> Icons.Default.Widgets + type.contains("html") || type.contains("code-html") -> Icons.Default.Web + type.contains("svg") || type.contains("image") -> Icons.Default.Image + type.contains("markdown") || type == "text/md" -> Icons.AutoMirrored.Filled.Article + else -> Icons.Default.Code + } +} + +private fun artifactTypeSubtitle(type: String): String { + return when { + type.contains("mermaid") -> "Mermaid Diagram" + type.contains("react") -> "React Component" + type.contains("svg") -> "SVG Image" + type.contains("markdown") || type == "text/md" -> "Markdown Document" + type.contains("html") || type.contains("code-html") -> "HTML Page" + type == "text/plain" -> "Plain Text" + else -> "Code" + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetector.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetector.kt new file mode 100644 index 0000000..53652db --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetector.kt @@ -0,0 +1,119 @@ +package com.librechat.android.feature.chat.components.artifact + +/** + * Parsed artifact extracted from message content. Artifacts are marked in LLM + * responses using the remark-directive markdown format: + * + * ``` + * :::artifact{identifier="id" type="mime-type" title="Title"} + * ```language + * ...content... + * ``` + * ::: + * ``` + * + * Supported types: text/html, image/svg+xml, application/vnd.react, + * application/vnd.mermaid, text/markdown, text/md, text/plain, + * application/vnd.code-html. + */ +data class Artifact( + val identifier: String, + val type: String, + val title: String, + val language: String?, + val content: String, + val version: Int = 1, +) + +/** + * Segment of message text that is either plain content or an artifact. + */ +sealed interface ArtifactSegment { + data class Text(val text: String) : ArtifactSegment + data class ArtifactReference(val artifact: Artifact) : ArtifactSegment +} + +/** + * Matches the remark-directive artifact format: + * :::artifact{key="value" ...} + * ```optionalLanguage + * content + * ``` + * ::: + * + * Group 1: attribute string inside braces + * Group 2: optional language hint after opening backticks + * Group 3: content between the fences + */ +private val ARTIFACT_REGEX = Regex( + """:::artifact\{([^}]*)\}\s*```(\w*)\n([\s\S]*?)```\s*:::""", +) + +/** + * Parses key="value" pairs from the attribute string inside {braces}. + */ +private val ATTR_REGEX = Regex( + """(\w+)\s*=\s*"([^"]*)"""", +) + +/** + * Detects artifact markers in message text and returns a list of segments + * with the artifacts extracted. + */ +fun detectArtifacts(text: String): List { + val segments = mutableListOf() + var lastIndex = 0 + + ARTIFACT_REGEX.findAll(text).forEach { match -> + if (match.range.first > lastIndex) { + val before = text.substring(lastIndex, match.range.first).trim() + if (before.isNotEmpty()) { + segments.add(ArtifactSegment.Text(before)) + } + } + + val attrString = match.groupValues[1] + val languageHint = match.groupValues[2].ifEmpty { null } + val content = match.groupValues[3].trimEnd() + val attrs = mutableMapOf() + ATTR_REGEX.findAll(attrString).forEach { attrMatch -> + attrs[attrMatch.groupValues[1]] = attrMatch.groupValues[2] + } + + val artifact = Artifact( + identifier = attrs["identifier"] ?: attrs["id"] ?: "artifact-${match.range.first}", + type = attrs["type"] ?: "text/plain", + title = attrs["title"] ?: "Artifact", + language = languageHint ?: attrs["language"], + content = content, + version = attrs["version"]?.toIntOrNull() ?: 1, + ) + segments.add(ArtifactSegment.ArtifactReference(artifact)) + lastIndex = match.range.last + 1 + } + + if (lastIndex < text.length) { + val remaining = text.substring(lastIndex).trim() + if (remaining.isNotEmpty()) { + segments.add(ArtifactSegment.Text(remaining)) + } + } + + if (segments.isEmpty() && text.isNotBlank()) { + segments.add(ArtifactSegment.Text(text)) + } + + return segments +} + +/** + * Groups artifacts by identifier and returns a map of identifier to list of + * versioned artifacts (sorted by version number ascending). + */ +fun groupArtifactVersions(segments: List): Map> { + return segments + .filterIsInstance() + .map { it.artifact } + .groupBy { it.identifier } + .mapValues { (_, artifacts) -> artifacts.sortedBy { it.version } } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDownloadHelper.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDownloadHelper.kt new file mode 100644 index 0000000..41242d1 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDownloadHelper.kt @@ -0,0 +1,144 @@ +package com.librechat.android.feature.chat.components.artifact + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.core.content.FileProvider +import java.io.File + +/** + * Shares artifacts via FileProvider temp file + system share sheet. Falls back to plain text. + * FileProvider authority must match the app's declared authority in AndroidManifest. + * Temp files are written to cacheDir/artifacts/ and cleaned up after sharing. + */ +object ArtifactDownloadHelper { + + /** How long to wait before deleting the shared file (30s gives the share target time to read). */ + private const val CLEANUP_DELAY_MS = 30_000L + + /** Files older than this are considered stale and cleaned up opportunistically. */ + private const val STALE_THRESHOLD_MS = 60_000L + + fun share(context: Context, artifact: Artifact) { + val intent = try { + shareViaFile(context, artifact) + } catch (_: Exception) { + shareAsText(artifact) + } + context.startActivity(Intent.createChooser(intent, "Share artifact")) + } + + private fun shareViaFile(context: Context, artifact: Artifact): Intent { + val extension = extensionForArtifact(artifact) + val fileName = sanitizeFileName(artifact.title) + extension + val dir = File(context.cacheDir, "artifacts").apply { mkdirs() } + val file = File(dir, fileName).apply { writeText(artifact.content) } + + // Schedule cleanup: delete this file and any stale artifacts after a delay + scheduleCleanup(file, dir) + + val uri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + + return Intent(Intent.ACTION_SEND).apply { + type = mimeTypeForExtension(extension) + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, artifact.title) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + /** + * Schedules deletion of the shared [file] and any stale files in [dir] after a delay. + * This gives the share target enough time to read the file before it is removed. + */ + private fun scheduleCleanup(file: File, dir: File) { + Handler(Looper.getMainLooper()).postDelayed({ + file.delete() + // Clean up any other stale artifacts while we're at it + val staleThreshold = System.currentTimeMillis() - STALE_THRESHOLD_MS + dir.listFiles()?.filter { it.lastModified() < staleThreshold }?.forEach { it.delete() } + }, CLEANUP_DELAY_MS) + } + + private fun shareAsText(artifact: Artifact): Intent { + return Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, artifact.title) + putExtra(Intent.EXTRA_TEXT, artifact.content) + } + } + + private fun extensionForArtifact(artifact: Artifact): String { + artifact.language?.let { lang -> + LANGUAGE_EXTENSIONS[lang.lowercase()]?.let { return it } + } + return when { + artifact.type.contains("mermaid") -> ".mmd" + artifact.type.contains("html") || artifact.type.contains("code-html") -> ".html" + artifact.type.contains("react") -> ".jsx" + artifact.type.contains("svg") -> ".svg" + artifact.type.contains("css") -> ".css" + artifact.type.contains("json") -> ".json" + artifact.type.contains("xml") -> ".xml" + artifact.type.contains("markdown") || artifact.type == "text/md" -> ".md" + else -> ".txt" + } + } + + private fun mimeTypeForExtension(extension: String): String { + return when (extension) { + ".html" -> "text/html" + ".svg" -> "image/svg+xml" + ".mmd" -> "text/plain" + ".jsx" -> "text/javascript" + ".css" -> "text/css" + ".json" -> "application/json" + ".xml" -> "application/xml" + ".md" -> "text/markdown" + ".py" -> "text/x-python" + ".js", ".ts", ".tsx", ".jsx" -> "text/javascript" + ".kt", ".kts" -> "text/x-kotlin" + ".java" -> "text/x-java-source" + ".sh" -> "text/x-shellscript" + else -> "text/plain" + } + } + + private fun sanitizeFileName(name: String): String { + return name.replace(Regex("[^a-zA-Z0-9._\\- ]"), "_").take(100) + } + + private val LANGUAGE_EXTENSIONS = mapOf( + "python" to ".py", + "javascript" to ".js", + "typescript" to ".ts", + "tsx" to ".tsx", + "jsx" to ".jsx", + "kotlin" to ".kt", + "java" to ".java", + "html" to ".html", + "css" to ".css", + "json" to ".json", + "xml" to ".xml", + "markdown" to ".md", + "shell" to ".sh", + "bash" to ".sh", + "sql" to ".sql", + "rust" to ".rs", + "go" to ".go", + "ruby" to ".rb", + "swift" to ".swift", + "c" to ".c", + "cpp" to ".cpp", + "csharp" to ".cs", + "yaml" to ".yaml", + "toml" to ".toml", + "svg" to ".svg", + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactPanel.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactPanel.kt new file mode 100644 index 0000000..ea7a667 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactPanel.kt @@ -0,0 +1,639 @@ +package com.librechat.android.feature.chat.components.artifact + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.net.http.SslError +import android.view.MotionEvent +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.librechat.android.feature.chat.components.CodeBlock +import kotlinx.coroutines.launch +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArtifactPanel( + artifact: Artifact, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + versions: List = listOf(artifact), +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.fillMaxSize(), + dragHandle = { BottomSheetDefaults.DragHandle() }, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + ) { + ArtifactPanelContent( + artifact = artifact, + versions = versions, + sheetState = sheetState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .navigationBarsPadding(), + ) + } +} + +private fun isPreviewableType(type: String): Boolean { + return type.contains("html") || + type.contains("svg") || + type.contains("react") || + type.contains("mermaid") || + type.contains("markdown") || + type == "text/md" || + type == "text/plain" || + type.contains("code-html") +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ArtifactPanelContent( + artifact: Artifact, + versions: List, + sheetState: SheetState, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var selectedTab by remember { mutableIntStateOf(0) } + var currentVersionIndex by remember { + mutableIntStateOf(versions.indexOfFirst { it.version == artifact.version }.coerceAtLeast(0)) + } + var showFullscreen by remember { mutableIntStateOf(-1) } // -1 = hidden, 0 = code, 1 = preview + + val currentArtifact = versions.getOrElse(currentVersionIndex) { artifact } + val isPreviewable = isPreviewableType(currentArtifact.type) + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f + + Column(modifier = modifier.fillMaxWidth()) { + // Title row with version nav and action buttons + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = currentArtifact.title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + if (versions.size > 1) { + ArtifactVersionNav( + currentIndex = currentVersionIndex, + totalVersions = versions.size, + onPrevious = { currentVersionIndex-- }, + onNext = { currentVersionIndex++ }, + ) + } + } + + // Action row: share + fullscreen + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = { + ArtifactDownloadHelper.share( + context = context, + artifact = currentArtifact, + ) + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.cd_share_artifact), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = { showFullscreen = selectedTab }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Fullscreen, + contentDescription = stringResource(R.string.cd_fullscreen), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (isPreviewable) { + TabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text(stringResource(R.string.code)) }, + ) + Tab( + selected = selectedTab == 1, + onClick = { + selectedTab = 1 + scope.launch { sheetState.expand() } + }, + text = { Text(stringResource(R.string.preview)) }, + ) + } + } + + when { + !isPreviewable || selectedTab == 0 -> { + val codeScrollState = rememberScrollState() + val codeScrollBlocker = remember { + SheetScrollBlocker(codeScrollState) + } + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(top = 8.dp) + .nestedScroll(codeScrollBlocker) + .verticalScroll(codeScrollState), + ) { + CodeBlock( + code = currentArtifact.content, + language = currentArtifact.language, + modifier = Modifier.fillMaxWidth(), + ) + } + } + selectedTab == 1 -> { + ArtifactPreviewWebView( + content = currentArtifact.content, + type = currentArtifact.type, + isDarkTheme = isDarkTheme, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(top = 8.dp), + ) + } + } + } + + // Fullscreen dialog + if (showFullscreen >= 0) { + Dialog( + onDismissRequest = { showFullscreen = -1 }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + when { + !isPreviewable || showFullscreen == 0 -> { + CodeBlock( + code = currentArtifact.content, + language = currentArtifact.language, + modifier = Modifier + .fillMaxSize() + .padding(top = 48.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + } + showFullscreen == 1 -> { + ArtifactPreviewWebView( + content = currentArtifact.content, + type = currentArtifact.type, + isDarkTheme = isDarkTheme, + modifier = Modifier + .fillMaxSize() + .padding(top = 48.dp), + ) + } + } + IconButton( + onClick = { showFullscreen = -1 }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close_fullscreen), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") +@Composable +private fun ArtifactPreviewWebView( + content: String, + type: String, + isDarkTheme: Boolean, + modifier: Modifier = Modifier, +) { + val bgColor = MaterialTheme.colorScheme.surface.toArgb() + var isLoading by remember { mutableStateOf(true) } + + val html = remember(content, type, isDarkTheme) { + buildWebViewHtml(content, type, isDarkTheme) + } + + Box(modifier = modifier) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + // Prevent touch events from propagating to the bottom sheet + setOnTouchListener { v, event -> + when (event.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> + v.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + v.parent?.requestDisallowInterceptTouchEvent(false) + } + false // let the WebView handle the event normally + } + setBackgroundColor(bgColor) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.allowFileAccess = false + settings.allowContentAccess = false + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + if (newProgress >= 80) isLoading = false + } + } + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + isLoading = true + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + if (request?.isForMainFrame == true) { + view?.loadDataWithBaseURL( + null, + buildErrorHtml(error?.description?.toString() ?: "Unknown error"), + "text/html", + "UTF-8", + null, + ) + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + handler?.cancel() + Timber.w("SSL error in artifact WebView: ${error?.primaryError}") + } + } + loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) + } + }, + update = { webView -> + webView.setBackgroundColor(bgColor) + webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) + }, + ) + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .size(32.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + } + } +} + +private fun buildWebViewHtml(content: String, type: String, isDarkTheme: Boolean): String { + val bgColor = if (isDarkTheme) "#1C1B1F" else "#FFFBFE" + val fgColor = if (isDarkTheme) "#E6E1E5" else "#1C1B1F" + + return when { + type.contains("mermaid") -> MermaidWebContent.buildHtml(content, isDarkTheme) + type.contains("markdown") || type == "text/md" -> MarkdownWebContent.buildHtml(content, isDarkTheme) + type == "text/plain" -> MarkdownWebContent.buildHtml(content, isDarkTheme) + type.contains("react") -> buildReactHtml(content, bgColor, fgColor) + type.contains("svg") -> buildSvgHtml(content, bgColor) + type.contains("html") || type.contains("code-html") -> buildEnhancedHtml(content, bgColor, fgColor) + else -> { + val escapedContent = escapeHtml(content) + """ + + + + + + + +
$escapedContent
+ + """.trimIndent() + } + } +} + +// Security note: HTML artifacts intentionally render unsanitized HTML content. +// This is by design — HTML artifacts are meant to be rendered as-is. The WebView +// is sandboxed with a Content Security Policy restricting script/resource origins. +private fun buildEnhancedHtml(content: String, bgColor: String, fgColor: String): String { + // If content already contains or , inject Tailwind + theme vars + val hasHtmlTag = content.contains(" + + + """.trimIndent() + // Insert after if present, otherwise before content + return if (content.contains("", ignoreCase = true)) { + content.replaceFirst( + Regex("", RegexOption.IGNORE_CASE), + "$themeStyle", + ) + } else if (content.contains("]*>", RegexOption.IGNORE_CASE).find(content) + if (headMatch != null) { + content.replaceRange(headMatch.range.last + 1, headMatch.range.last + 1, themeStyle) + } else { + "$themeStyle\n$content" + } + } else { + "$themeStyle\n$content" + } + } + + return """ + + + + + + + + + $content + + """.trimIndent() +} + +// Security note: SVG content is rendered unsanitized because SVG artifacts are +// designed to display user-provided vector graphics. CSP restricts script execution. +private fun buildSvgHtml(content: String, bgColor: String): String { + return """ + + + + + + + +
$content
+ + """.trimIndent() +} + +// Security note: React artifacts intentionally render unsanitized content because they +// must execute user-provided JSX/JS code. 'unsafe-inline' and 'unsafe-eval' are required +// in script-src for Babel transpilation and React rendering. CSP restricts script origins +// to specific CDN hosts (unpkg.com, cdn.tailwindcss.com). +private fun buildReactHtml(content: String, bgColor: String, fgColor: String): String { + // Preprocess: strip ES module imports/exports for browser compatibility. + // React/ReactDOM are loaded as UMD globals, so `import { useState } from 'react'` + // becomes a destructuring from the global React object. + val processed = content + .replace(Regex("""import\s*\{([^}]+)\}\s*from\s*['"]react['"];?""")) { + "const {${it.groupValues[1]}} = React;" + } + .replace(Regex("""import\s*React\s*from\s*['"]react['"];?"""), "") + .replace(Regex("""import\s*\{([^}]+)\}\s*from\s*['"]react-dom['"];?""")) { + "const {${it.groupValues[1]}} = ReactDOM;" + } + .replace(Regex("""export\s+default\s+function\s+"""), "function ") + .replace(Regex("""export\s+default\s+"""), "const _DefaultExport = ") + + return """ + + + + + + + + + + + + +
+
+ + + + + """.trimIndent() +} + +/** + * Intercepts all vertical scroll and manually dispatches it to the given [ScrollState], + * then reports it all as consumed so the parent ModalBottomSheet never receives it. + * This isolates the content's scrolling from the sheet's drag gesture. + */ +private class SheetScrollBlocker(private val scrollState: ScrollState) : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Manually scroll the content and consume ALL vertical delta + // so none reaches the sheet's drag handler. + if (available.y != 0f) { + scrollState.dispatchRawDelta(-available.y) + } + return Offset(0f, available.y) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return Offset(0f, available.y) + } +} + +/** + * Escapes HTML special characters to prevent XSS when embedding user-supplied + * or error-derived text into WebView HTML. + */ +private fun escapeHtml(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + +private fun buildErrorHtml(errorMessage: String): String { + val safeMessage = escapeHtml(errorMessage) + return """ + + + + +
+ Failed to load preview
+ $safeMessage +
+ + + """.trimIndent() +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactVersionNav.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactVersionNav.kt new file mode 100644 index 0000000..b9dfd52 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactVersionNav.kt @@ -0,0 +1,74 @@ +package com.librechat.android.feature.chat.components.artifact + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** Prev/next version navigation for artifacts. [currentIndex] is 0-based; display shows 1-based "v1/N". */ +@Composable +fun ArtifactVersionNav( + currentIndex: Int, + totalVersions: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onPrevious, + enabled = currentIndex > 0, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.cd_previous_version), + modifier = Modifier.size(20.dp), + tint = if (currentIndex > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + }, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.artifact_version, currentIndex + 1, totalVersions), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = onNext, + enabled = currentIndex < totalVersions - 1, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.cd_next_version), + modifier = Modifier.size(20.dp), + tint = if (currentIndex < totalVersions - 1) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + }, + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MarkdownWebContent.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MarkdownWebContent.kt new file mode 100644 index 0000000..225375c --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MarkdownWebContent.kt @@ -0,0 +1,128 @@ +package com.librechat.android.feature.chat.components.artifact + +/** + * Builds an HTML page that renders Markdown content using marked.js (GFM) + * with highlight.js for code syntax highlighting. + */ +object MarkdownWebContent { + + fun buildHtml(markdownContent: String, isDarkTheme: Boolean): String { + val bgColor = if (isDarkTheme) "#1C1B1F" else "#FFFBFE" + val fgColor = if (isDarkTheme) "#E6E1E5" else "#1C1B1F" + val codeBg = if (isDarkTheme) "#2B2930" else "#F3EDF7" + val borderColor = if (isDarkTheme) "#48464C" else "#CAC4D0" + val linkColor = if (isDarkTheme) "#D0BCFF" else "#6750A4" + val hlTheme = if (isDarkTheme) "github-dark" else "github" + val escapedContent = markdownContent + .replace("\\", "\\\\") + .replace("`", "\\`") + .replace("$", "\\$") + + return """ + + + + + + + + + +
+ + + + + + + """.trimIndent() + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MermaidWebContent.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MermaidWebContent.kt new file mode 100644 index 0000000..3065d6a --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/artifact/MermaidWebContent.kt @@ -0,0 +1,120 @@ +package com.librechat.android.feature.chat.components.artifact + +/** + * Builds an HTML page that renders a Mermaid diagram using the Mermaid.js CDN. + * Includes zoom controls and theme support. + */ +object MermaidWebContent { + + fun buildHtml(mermaidCode: String, isDarkTheme: Boolean): String { + val theme = if (isDarkTheme) "dark" else "default" + val bgColor = if (isDarkTheme) "#1C1B1F" else "#FFFBFE" + val fgColor = if (isDarkTheme) "#E6E1E5" else "#1C1B1F" + val btnBg = if (isDarkTheme) "#332D41" else "#E8DEF8" + val escapedCode = mermaidCode + .replace("\\", "\\\\") + .replace("`", "\\`") + .replace("$", "\\$") + + return """ + + + + + + + + +
+
+
+ + +
+ + + + + """.trimIndent() + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/navigation/ChatNavigation.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/navigation/ChatNavigation.kt new file mode 100644 index 0000000..6c50224 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/navigation/ChatNavigation.kt @@ -0,0 +1,136 @@ +package com.librechat.android.feature.chat.navigation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navigation +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.chat.prompts.PromptEditorScreen +import com.librechat.android.feature.chat.prompts.PromptsLibraryScreen +import com.librechat.android.feature.chat.screen.ChatScreen +import com.librechat.android.feature.chat.screen.NewChatScreen + +const val CHAT_GRAPH_ROUTE = "chat_graph" +const val NEW_CHAT_ROUTE = "new_chat" +const val CHAT_ROUTE = "chat/{conversationId}" +const val PROMPTS_LIBRARY_ROUTE = "prompts_library" +const val PROMPT_EDITOR_ROUTE = "prompt_editor?groupId={groupId}" + +fun NavController.navigateToChat(conversationId: String) { + val currentEntry = currentBackStackEntry + val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE + val currentConvoId = currentEntry?.arguments?.getString("conversationId") + + // Already viewing this exact conversation -- no-op to avoid reload/flash + if (isCurrentlyInChat && currentConvoId == conversationId) return + + val currentDestId = currentEntry?.destination?.id + navigate("chat/$conversationId") { + if (isCurrentlyInChat && currentDestId != null) { + // Already viewing a chat -- replace it so switching chats + // doesn't stack entries. Back will go to whatever was + // before the first chat (e.g. new_chat, settings, agents). + popUpTo(currentDestId) { inclusive = true } + } + // From non-chat screens just push onto the backstack normally + launchSingleTop = true + } +} + +fun NavController.navigateToPromptsLibrary() { + navigate(PROMPTS_LIBRARY_ROUTE) +} + +fun NavController.navigateToPromptEditor(groupId: String? = null) { + if (groupId != null) { + navigate("prompt_editor?groupId=$groupId") + } else { + navigate("prompt_editor") + } +} + +fun NavGraphBuilder.chatGraph( + navController: NavController, + onOpenDrawer: (() -> Unit)? = null, +) { + navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) { + composable( + route = NEW_CHAT_ROUTE, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { null }, + popExitTransition = { null }, + ) { + ScreenTransitionWrapper(transition) { + NewChatScreen( + onConversationStarted = { conversationId -> + // Navigate to chat/{id} immediately when the conversationId + // is known (at StreamEvent.Created). The new ChatViewModel + // will resume the active stream. The new_chat landing page + // stays clean in the back stack. + navController.navigateToChat(conversationId) + }, + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, + ) + } + } + composable( + route = CHAT_ROUTE, + arguments = listOf( + navArgument("conversationId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { null }, + popExitTransition = { null }, + ) { + ScreenTransitionWrapper(transition) { + ChatScreen( + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, + onNavigateBack = { navController.popBackStack() }, + onNavigateToConversation = { navController.navigateToChat(it) }, + ) + } + } + composable(PROMPTS_LIBRARY_ROUTE) { + ScreenTransitionWrapper(transition) { + PromptsLibraryScreen( + onNavigateBack = { navController.popBackStack() }, + onUseInChat = { promptText -> + navController.popBackStack() + // Navigate to new chat (the prompt text would be inserted via shared state) + }, + onNavigateToEditor = { groupId -> + navController.navigateToPromptEditor(groupId) + }, + ) + } + } + composable( + route = PROMPT_EDITOR_ROUTE, + arguments = listOf( + navArgument("groupId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { + ScreenTransitionWrapper(transition) { + PromptEditorScreen( + onBack = { navController.popBackStack() }, + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDetailScreen.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDetailScreen.kt new file mode 100644 index 0000000..31b851c --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDetailScreen.kt @@ -0,0 +1,168 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.core.ui.components.LibreChatTopBar +import com.librechat.android.feature.chat.prompts.components.PromptPreviewPanel +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromptDetailScreen( + group: PromptGroupDetailDisplayData, + onBack: () -> Unit, + onDelete: () -> Unit, + onUseInChat: (String) -> Unit, + onEdit: (String) -> Unit, + onShare: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + LibreChatTopBar( + title = group.name, + onNavigateBack = onBack, + actions = { + IconButton(onClick = onShare) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.cd_share_prompt), + ) + } + IconButton(onClick = { onEdit(group.id) }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit_prompt), + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_prompt), + tint = MaterialTheme.colorScheme.error, + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + // Leading icon + name row for visual hierarchy + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Description, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + val category = group.category + if (!category.isNullOrBlank()) { + Text( + text = category, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = stringResource(R.string.prompt_author, group.authorName), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + + val oneliner = group.oneliner + if (!oneliner.isNullOrBlank()) { + Text( + text = oneliner, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (!group.command.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.prompt_command_label), + style = MaterialTheme.typography.titleSmall, + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Text( + text = "/${group.command}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(12.dp), + ) + } + } + + // Preview panel for the production prompt + val productionPromptText = group.productionPromptText + if (productionPromptText != null) { + Spacer(modifier = Modifier.height(16.dp)) + PromptPreviewPanel( + promptTemplate = productionPromptText, + variableValues = emptyMap(), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { onUseInChat(group.command ?: group.name) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.use_in_chat)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDisplayData.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDisplayData.kt new file mode 100644 index 0000000..3dd4d83 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptDisplayData.kt @@ -0,0 +1,34 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.compose.runtime.Immutable + +/** + * Display data for a prompt group in the library list view. + */ +@Immutable +data class PromptGroupDisplayData( + val id: String, + val name: String, + val oneliner: String?, + val category: String?, + val authorName: String, + val command: String?, + val promptText: String?, +) + +/** + * Display data for the prompt group detail screen, + * including command, production prompt text, and prompt metadata. + */ +@Immutable +data class PromptGroupDetailDisplayData( + val id: String, + val name: String, + val oneliner: String?, + val category: String?, + val authorName: String, + val command: String?, + val productionId: String?, + val productionPromptText: String?, + val promptCount: Int, +) diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorScreen.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorScreen.kt new file mode 100644 index 0000000..2733c70 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorScreen.kt @@ -0,0 +1,214 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.ui.components.LoadingIndicator +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromptEditorScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PromptEditorViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.error) { + val error = uiState.error + if (error != null) { + snackbarHostState.showSnackbar(error) + viewModel.dismissError() + } + } + + LaunchedEffect(uiState.saved) { + if (uiState.saved) { + viewModel.consumeSaved() + onBack() + } + } + + if (uiState.showVersionsSheet && uiState.prompts.isNotEmpty()) { + PromptVersionsSheet( + prompts = uiState.prompts, + productionId = uiState.productionId, + onDismiss = viewModel::hideVersionsSheet, + onSetProduction = viewModel::setProductionTag, + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text(if (uiState.isNewPrompt) "Create Prompt" else "Edit Prompt") + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + actions = { + if (!uiState.isNewPrompt && uiState.prompts.isNotEmpty()) { + IconButton(onClick = viewModel::showVersionsSheet) { + Icon( + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.cd_version_history), + ) + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + if (uiState.isLoading) { + LoadingIndicator( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) + return@Scaffold + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.prompt_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.oneliner, + onValueChange = viewModel::updateOneliner, + label = { Text(stringResource(R.string.prompt_description_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = if (uiState.command.isNotBlank()) "/${uiState.command}" else "", + onValueChange = viewModel::updateCommand, + label = { Text(stringResource(R.string.prompt_command_label)) }, + singleLine = true, + placeholder = { Text("/my-command") }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.prompt_content), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(4.dp)) + + OutlinedTextField( + value = uiState.promptText, + onValueChange = viewModel::updatePromptText, + label = { Text(stringResource(R.string.prompt_text_label)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + minLines = 5, + maxLines = 12, + modifier = Modifier.fillMaxWidth(), + supportingText = { + Text("Use {{variable_name}} to add variables") + }, + ) + + if (uiState.promptText.isNotBlank()) { + Spacer(modifier = Modifier.height(16.dp)) + + PromptVariablesSection( + promptText = uiState.promptText, + variableValues = uiState.variableValues, + onVariableChanged = viewModel::updateVariable, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::save, + enabled = !uiState.isSaving && uiState.name.isNotBlank() && uiState.promptText.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (uiState.isSaving) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text(if (uiState.isNewPrompt) "Create" else "Save") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onBack, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.cancel)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorViewModel.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorViewModel.kt new file mode 100644 index 0000000..41182bd --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptEditorViewModel.kt @@ -0,0 +1,243 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.PromptRepository +import com.librechat.android.core.model.Prompt +import com.librechat.android.core.model.PromptGroup +import com.librechat.android.core.model.request.AddPromptToGroupRequest +import com.librechat.android.core.model.request.CreatePromptData +import com.librechat.android.core.model.request.CreatePromptGroupData +import com.librechat.android.core.model.request.CreatePromptRequest +import com.librechat.android.core.model.request.UpdatePromptGroupRequest +import com.librechat.android.core.model.request.UpdatePromptTagRequest +import androidx.compose.runtime.Immutable +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class PromptEditorUiState( + val groupId: String? = null, + val name: String = "", + val oneliner: String = "", + val command: String = "", + val promptText: String = "", + val variableValues: Map = emptyMap(), + val prompts: List = emptyList(), + val productionId: String? = null, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val saved: Boolean = false, + val showVersionsSheet: Boolean = false, +) { + val isNewPrompt: Boolean get() = groupId == null +} + +@HiltViewModel +class PromptEditorViewModel @Inject constructor( + private val promptRepository: PromptRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PromptEditorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + val groupId = savedStateHandle.get("groupId") + if (groupId != null) { + loadGroup(groupId) + } + } + + private fun loadGroup(groupId: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + val groupResult = promptRepository.getGroup(groupId) + val promptsResult = promptRepository.getPromptsByGroupId(groupId) + + when (groupResult) { + is Result.Success -> { + val group = groupResult.data + val prompts = (promptsResult as? Result.Success)?.data ?: emptyList() + val mergedGroup = group.copy(prompts = prompts) + val productionPrompt = mergedGroup.prompts.find { it.id == mergedGroup.productionId } + _uiState.value = _uiState.value.copy( + groupId = mergedGroup.id, + name = mergedGroup.name, + oneliner = mergedGroup.oneliner ?: "", + command = mergedGroup.command ?: "", + promptText = productionPrompt?.prompt ?: mergedGroup.prompts.firstOrNull()?.prompt ?: "", + prompts = mergedGroup.prompts, + productionId = mergedGroup.productionId, + isLoading = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = groupResult.message ?: "Failed to load prompt", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun updateName(name: String) { + _uiState.value = _uiState.value.copy(name = name) + } + + fun updateOneliner(oneliner: String) { + _uiState.value = _uiState.value.copy(oneliner = oneliner) + } + + fun updateCommand(command: String) { + _uiState.value = _uiState.value.copy(command = command.removePrefix("/")) + } + + fun updatePromptText(text: String) { + _uiState.value = _uiState.value.copy(promptText = text) + } + + fun updateVariable(name: String, value: String) { + val current = _uiState.value.variableValues.toMutableMap() + current[name] = value + _uiState.value = _uiState.value.copy(variableValues = current) + } + + fun showVersionsSheet() { + _uiState.value = _uiState.value.copy(showVersionsSheet = true) + } + + fun hideVersionsSheet() { + _uiState.value = _uiState.value.copy(showVersionsSheet = false) + } + + fun setProductionTag(promptId: String) { + val state = _uiState.value + val groupId = state.groupId ?: return + viewModelScope.launch { + val request = UpdatePromptTagRequest(productionPromptId = promptId) + when (val result = promptRepository.updatePromptProductionTag(promptId, request)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + productionId = promptId, + showVersionsSheet = false, + ) + // Reload group to get updated state + loadGroup(groupId) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to update production tag", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun save() { + val state = _uiState.value + if (state.name.isBlank() || state.promptText.isBlank()) { + _uiState.value = state.copy(error = "Name and prompt text are required") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, error = null) + + if (state.isNewPrompt) { + createNewPrompt(state) + } else { + updateExistingPrompt(state) + } + } + } + + private suspend fun createNewPrompt(state: PromptEditorUiState) { + val request = CreatePromptRequest( + prompt = CreatePromptData( + prompt = state.promptText, + type = "text", + ), + group = CreatePromptGroupData(name = state.name), + ) + when (val result = promptRepository.create(request)) { + is Result.Success -> { + val createdGroup = result.data + // If there is metadata to update (oneliner, command), do a follow-up update + if (state.oneliner.isNotBlank() || state.command.isNotBlank()) { + val updateRequest = UpdatePromptGroupRequest( + name = state.name, + oneliner = state.oneliner.ifBlank { null }, + command = state.command.ifBlank { null }, + ) + createdGroup.id?.let { promptRepository.update(it, updateRequest) } + } + _uiState.value = _uiState.value.copy( + isSaving = false, + saved = true, + groupId = createdGroup.id, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isSaving = false, + error = result.message ?: "Failed to create prompt", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + + private suspend fun updateExistingPrompt(state: PromptEditorUiState) { + val groupId = state.groupId ?: return + + // Update group metadata + val updateRequest = UpdatePromptGroupRequest( + name = state.name, + oneliner = state.oneliner.ifBlank { null }, + command = state.command.ifBlank { null }, + ) + when (val result = promptRepository.update(groupId, updateRequest)) { + is Result.Success -> { + // Check if prompt text changed; if so, add as new version + val currentProduction = state.prompts.find { it.id == state.productionId } + if (currentProduction == null || currentProduction.prompt != state.promptText) { + val addRequest = AddPromptToGroupRequest( + prompt = state.promptText, + type = "text", + ) + promptRepository.addPromptToGroup(groupId, addRequest) + } + _uiState.value = _uiState.value.copy( + isSaving = false, + saved = true, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isSaving = false, + error = result.message ?: "Failed to update prompt", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + + fun consumeSaved() { + _uiState.value = _uiState.value.copy(saved = false) + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVariablesSection.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVariablesSection.kt new file mode 100644 index 0000000..08b9514 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVariablesSection.kt @@ -0,0 +1,104 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +private val VARIABLE_REGEX = Regex("\\{\\{\\s*(\\w+)\\s*\\}\\}") + +/** + * Extracts unique variable names from a prompt template string. + * Variables are written as `{{variable_name}}`. + */ +fun extractVariables(promptText: String): List { + return VARIABLE_REGEX.findAll(promptText) + .map { it.groupValues[1] } + .distinct() + .toList() +} + +/** + * Substitutes `{{variable_name}}` placeholders in [promptText] + * with the corresponding values from [variableValues]. + */ +fun substituteVariables(promptText: String, variableValues: Map): String { + return VARIABLE_REGEX.replace(promptText) { matchResult -> + val varName = matchResult.groupValues[1] + variableValues[varName]?.ifBlank { matchResult.value } ?: matchResult.value + } +} + +/** + * Composable that parses `{{variable_name}}` patterns from [promptText], + * renders an [OutlinedTextField] per variable, and shows a preview of the + * prompt with variables substituted. + */ +@Composable +fun PromptVariablesSection( + promptText: String, + variableValues: Map, + onVariableChanged: (name: String, value: String) -> Unit, + modifier: Modifier = Modifier, +) { + val variables = remember(promptText) { extractVariables(promptText) } + + if (variables.isEmpty()) return + + Column(modifier = modifier) { + Text( + text = stringResource(R.string.label_variables), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + variables.forEach { varName -> + OutlinedTextField( + value = variableValues[varName] ?: "", + onValueChange = { onVariableChanged(varName, it) }, + label = { Text(varName) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.preview), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(4.dp)) + + val preview = remember(promptText, variableValues) { + substituteVariables(promptText, variableValues) + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Text( + text = preview, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVersionsSheet.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVersionsSheet.kt new file mode 100644 index 0000000..3c58280 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptVersionsSheet.kt @@ -0,0 +1,150 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Badge +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.Prompt +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +/** + * A ModalBottomSheet that shows the version history of prompts within a group. + * Each version is a [Prompt]. The production version is indicated by a badge. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromptVersionsSheet( + prompts: List, + productionId: String?, + onDismiss: () -> Unit, + onSetProduction: (promptId: String) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(R.string.version_history), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + if (prompts.isEmpty()) { + Text( + text = stringResource(R.string.no_versions), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp), + ) + } else { + LazyColumn { + itemsIndexed(prompts, key = { _, prompt -> prompt.id ?: "" }) { index, prompt -> + PromptVersionItem( + prompt = prompt, + versionNumber = prompts.size - index, + isProduction = prompt.id == productionId, + onSetProduction = { prompt.id?.let(onSetProduction) }, + ) + if (index < prompts.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun PromptVersionItem( + prompt: Prompt, + versionNumber: Int, + isProduction: Boolean, + onSetProduction: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onSetProduction) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.version_number, versionNumber), + style = MaterialTheme.typography.titleSmall, + ) + if (isProduction) { + Spacer(modifier = Modifier.width(8.dp)) + Badge( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Text( + text = stringResource(R.string.label_production), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + } + } + Text( + text = prompt.prompt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + prompt.createdAt?.let { createdAt -> + Text( + text = createdAt, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + + if (!isProduction) { + TextButton(onClick = onSetProduction) { + Text(stringResource(R.string.set_production)) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsLibraryScreen.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsLibraryScreen.kt new file mode 100644 index 0000000..3e48b94 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsLibraryScreen.kt @@ -0,0 +1,361 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.librechat.android.core.ui.components.EmptyState +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LibreChatTopBar +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.chat.prompts.components.PromptFilterSheet +import com.librechat.android.feature.chat.prompts.components.PromptShareDialog +import com.librechat.android.feature.chat.prompts.components.PromptSortOrder +import com.librechat.android.feature.chat.prompts.components.VariableInputDialog +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun PromptsLibraryScreen( + onNavigateBack: () -> Unit, + onUseInChat: (String) -> Unit, + onNavigateToEditor: (groupId: String?) -> Unit, + modifier: Modifier = Modifier, + viewModel: PromptsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(uiState.error) { + val error = uiState.error + if (error != null) { + snackbarHostState.showSnackbar(error) + viewModel.dismissError() + } + } + + if (uiState.selectedGroup != null) { + val selectedGroup = uiState.selectedGroup!! + PromptDetailScreen( + group = selectedGroup, + onBack = viewModel::clearSelectedGroup, + onDelete = { viewModel.deleteGroup(selectedGroup.id) }, + onUseInChat = { command -> + val promptText = selectedGroup.productionPromptText + if (promptText != null && promptText.contains("{{")) { + viewModel.showVariableDialog(promptText) + } else { + onUseInChat(command) + } + }, + onEdit = { groupId -> + viewModel.clearSelectedGroup() + onNavigateToEditor(groupId) + }, + onShare = { + viewModel.showShareDialog(selectedGroup.id) + }, + ) + + // Variable input dialog + if (uiState.showVariableDialog) { + VariableInputDialog( + promptTemplate = uiState.variablePromptTemplate, + variables = uiState.variableNames, + onInsert = { interpolated, _ -> + viewModel.dismissVariableDialog() + onUseInChat(interpolated) + }, + onDismiss = viewModel::dismissVariableDialog, + ) + } + + // Share dialog + if (uiState.showShareDialog) { + PromptShareDialog( + promptName = selectedGroup.name, + isCurrentlyShared = false, + onShareToggled = { _, _ -> viewModel.dismissShareDialog() }, + onDismiss = viewModel::dismissShareDialog, + ) + } + + return + } + + // Filter sheet + if (uiState.showFilterSheet) { + PromptFilterSheet( + categories = uiState.availableCategories, + selectedCategory = uiState.selectedCategory, + onCategorySelected = viewModel::onCategorySelected, + sortOrder = uiState.sortOrder, + onSortOrderChanged = viewModel::onSortOrderChanged, + onDismiss = viewModel::dismissFilterSheet, + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + LibreChatTopBar( + title = stringResource(R.string.prompts_library), + onNavigateBack = onNavigateBack, + actions = { + IconButton(onClick = viewModel::showFilterSheet) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = stringResource(R.string.cd_filter_sort), + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + FloatingActionButton(onClick = { onNavigateToEditor(null) }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.cd_create_prompt)) + } + }, + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Active filter chips + val hasActiveFilters = uiState.selectedCategory != null || + uiState.sortOrder != PromptSortOrder.RECENT + if (hasActiveFilters) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val selectedCat = uiState.selectedCategory + if (selectedCat != null) { + FilterChip( + selected = true, + onClick = { viewModel.onCategorySelected(null) }, + label = { Text(selectedCat) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.cd_remove_filter), + modifier = Modifier.width(16.dp), + ) + }, + ) + } + if (uiState.sortOrder != PromptSortOrder.RECENT) { + FilterChip( + selected = true, + onClick = { + viewModel.onSortOrderChanged(PromptSortOrder.RECENT) + }, + label = { Text(stringResource(R.string.prompt_sort_label, uiState.sortOrder.label)) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.cd_reset_sort), + modifier = Modifier.width(16.dp), + ) + }, + ) + } + } + } + + val displayGroups = uiState.filteredGroups.ifEmpty { uiState.groups } + + if (uiState.isLoading && displayGroups.isEmpty()) { + LoadingIndicator(modifier = Modifier.fillMaxSize()) + } else if (uiState.error != null && displayGroups.isEmpty()) { + ErrorBanner( + message = uiState.error ?: "Failed to load prompts", + onRetry = { + viewModel.dismissError() + viewModel.loadGroups() + }, + ) + } else if (displayGroups.isEmpty()) { + EmptyState( + title = if (uiState.selectedCategory != null) { + "No prompts in this category" + } else { + "No prompts yet" + }, + description = if (uiState.selectedCategory != null) { + "Try a different category or clear the filter" + } else { + "Tap + to create one" + }, + icon = Icons.Default.Add, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 80.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(displayGroups, key = { it.id }, contentType = { "prompt_group" }) { group -> + PromptGroupItem( + group = group, + onClick = { viewModel.selectGroup(group.id) }, + onShare = { viewModel.showShareDialog(group.id) }, + ) + } + } + } + } + } + } + + // Share dialog on list view + if (uiState.showShareDialog && uiState.selectedGroup == null) { + val shareGroup = uiState.groups.firstOrNull { it.id == uiState.shareGroupId } + PromptShareDialog( + promptName = shareGroup?.name ?: "", + isCurrentlyShared = false, + onShareToggled = { _, _ -> viewModel.dismissShareDialog() }, + onDismiss = viewModel::dismissShareDialog, + ) + } +} + +@Composable +private fun PromptGroupItem( + group: PromptGroupDisplayData, + onClick: () -> Unit, + onShare: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Description, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = group.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val oneliner = group.oneliner + if (!oneliner.isNullOrBlank()) { + Text( + text = oneliner, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onShare) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.cd_share), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + val promptText = group.promptText + if (!promptText.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = promptText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row { + val category = group.category + if (!category.isNullOrBlank()) { + Text( + text = category, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = stringResource(R.string.prompt_author, group.authorName), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsViewModel.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsViewModel.kt new file mode 100644 index 0000000..3e1eba9 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/PromptsViewModel.kt @@ -0,0 +1,380 @@ +package com.librechat.android.feature.chat.prompts + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.PromptRepository +import com.librechat.android.core.model.PromptGroup +import com.librechat.android.core.model.request.AddPromptToGroupRequest +import com.librechat.android.core.model.request.CreatePromptData +import com.librechat.android.core.model.request.CreatePromptGroupData +import com.librechat.android.core.model.request.CreatePromptRequest +import com.librechat.android.core.model.request.UpdatePromptGroupRequest +import com.librechat.android.core.model.request.UpdatePromptTagRequest +import com.librechat.android.feature.chat.prompts.components.PromptSortOrder +import com.librechat.android.feature.chat.prompts.components.extractVariables +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class PromptsUiState( + val groups: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + val selectedGroup: PromptGroupDetailDisplayData? = null, + val totalPages: Int = 0, + val currentPage: Int = 1, + // Filter and sort state + val selectedCategory: String? = null, + val sortOrder: PromptSortOrder = PromptSortOrder.RECENT, + val availableCategories: List = emptyList(), + val showFilterSheet: Boolean = false, + val filteredGroups: List = emptyList(), + // Share state + val showShareDialog: Boolean = false, + val shareGroupId: String? = null, + // Variable dialog state + val showVariableDialog: Boolean = false, + val variablePromptTemplate: String = "", + val variableNames: List = emptyList(), +) + +@HiltViewModel +class PromptsViewModel @Inject constructor( + private val promptRepository: PromptRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PromptsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Keep raw domain groups for filtering/sorting by fields not in display data + private var rawGroups: List = emptyList() + + init { + loadGroups() + } + + fun loadGroups() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (val result = promptRepository.getGroups()) { + is Result.Success -> { + val response = result.data + val groups = response.promptGroups + rawGroups = groups + val categories = groups + .mapNotNull { it.category } + .filter { it.isNotBlank() } + .distinct() + .sorted() + + _uiState.value = _uiState.value.copy( + groups = groups.map { it.toDisplayData() }, + totalPages = if (response.hasMore) 9999 else 1, + currentPage = 1, + isLoading = false, + availableCategories = categories, + ) + applyFilters() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Failed to load prompts", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true, error = null) + when (val result = promptRepository.getGroups()) { + is Result.Success -> { + val response = result.data + val groups = response.promptGroups + rawGroups = groups + val categories = groups + .mapNotNull { it.category } + .filter { it.isNotBlank() } + .distinct() + .sorted() + + _uiState.value = _uiState.value.copy( + groups = groups.map { it.toDisplayData() }, + totalPages = if (response.hasMore) 9999 else 1, + currentPage = 1, + isRefreshing = false, + availableCategories = categories, + ) + applyFilters() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = result.message ?: "Failed to refresh prompts", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun selectGroup(groupId: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + val groupResult = promptRepository.getGroup(groupId) + val promptsResult = promptRepository.getPromptsByGroupId(groupId) + + when (groupResult) { + is Result.Success -> { + val group = groupResult.data + val prompts = (promptsResult as? Result.Success)?.data ?: emptyList() + val mergedGroup = group.copy(prompts = prompts) + _uiState.value = _uiState.value.copy( + selectedGroup = mergedGroup.toDetailDisplayData(), + isLoading = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = groupResult.message ?: "Failed to load prompt details", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun clearSelectedGroup() { + _uiState.value = _uiState.value.copy(selectedGroup = null) + } + + fun onCategorySelected(category: String?) { + _uiState.value = _uiState.value.copy(selectedCategory = category) + applyFilters() + } + + fun onSortOrderChanged(sortOrder: PromptSortOrder) { + _uiState.value = _uiState.value.copy(sortOrder = sortOrder) + applyFilters() + } + + fun showFilterSheet() { + _uiState.value = _uiState.value.copy(showFilterSheet = true) + } + + fun dismissFilterSheet() { + _uiState.value = _uiState.value.copy(showFilterSheet = false) + } + + private fun applyFilters() { + val state = _uiState.value + var filtered = rawGroups + + // Apply category filter + val selectedCat = state.selectedCategory + if (selectedCat != null) { + filtered = filtered.filter { it.category == selectedCat } + } + + // Apply sort + filtered = when (state.sortOrder) { + PromptSortOrder.RECENT -> filtered.sortedByDescending { it.updatedAt ?: it.createdAt } + PromptSortOrder.POPULAR -> filtered.sortedByDescending { it.numberOfGenerations } + PromptSortOrder.ALPHABETICAL -> filtered.sortedBy { it.name.lowercase() } + } + + _uiState.value = state.copy(filteredGroups = filtered.map { it.toDisplayData() }) + } + + fun createPrompt(promptText: String, type: String, groupName: String) { + viewModelScope.launch { + val request = CreatePromptRequest( + prompt = CreatePromptData(prompt = promptText, type = type), + group = CreatePromptGroupData(name = groupName), + ) + try { + promptRepository.create(request) + refresh() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = "Failed to create prompt", + ) + } + } + } + + fun updateGroup(groupId: String, name: String?, oneliner: String?, command: String?) { + viewModelScope.launch { + val request = UpdatePromptGroupRequest( + name = name, + oneliner = oneliner, + command = command, + ) + try { + promptRepository.update(groupId, request) + refresh() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = "Failed to update prompt", + ) + } + } + } + + fun deleteGroup(groupId: String) { + viewModelScope.launch { + try { + promptRepository.delete(groupId) + _uiState.value = _uiState.value.copy(selectedGroup = null) + refresh() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = "Failed to delete prompt", + ) + } + } + } + + fun addPromptVersion(groupId: String, promptText: String) { + viewModelScope.launch { + val request = AddPromptToGroupRequest(prompt = promptText, type = "text") + when (val result = promptRepository.addPromptToGroup(groupId, request)) { + is Result.Success -> { + // Reload group to see updated versions + selectGroup(groupId) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to add prompt version", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun setProductionTag(promptId: String) { + viewModelScope.launch { + val request = UpdatePromptTagRequest(productionPromptId = promptId) + when (val result = promptRepository.updatePromptProductionTag(promptId, request)) { + is Result.Success -> { + // Reload the selected group to reflect the new production tag + val groupId = _uiState.value.selectedGroup?.id + if (groupId != null) { + selectGroup(groupId) + } + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to update production tag", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deletePromptVersion(promptId: String) { + viewModelScope.launch { + when (val result = promptRepository.deletePrompt(promptId)) { + is Result.Success -> { + val groupId = _uiState.value.selectedGroup?.id + if (groupId != null) { + selectGroup(groupId) + } + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to delete prompt version", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } + + // Share + + fun showShareDialog(groupId: String) { + _uiState.value = _uiState.value.copy( + showShareDialog = true, + shareGroupId = groupId, + ) + } + + fun dismissShareDialog() { + _uiState.value = _uiState.value.copy( + showShareDialog = false, + shareGroupId = null, + ) + } + + // Variable dialog + + fun showVariableDialog(promptTemplate: String) { + val variables = extractVariables(promptTemplate) + if (variables.isEmpty()) return + _uiState.value = _uiState.value.copy( + showVariableDialog = true, + variablePromptTemplate = promptTemplate, + variableNames = variables, + ) + } + + fun dismissVariableDialog() { + _uiState.value = _uiState.value.copy( + showVariableDialog = false, + variablePromptTemplate = "", + variableNames = emptyList(), + ) + } +} + +private fun PromptGroup.toDisplayData(): PromptGroupDisplayData { + // List endpoint: productionPrompt comes from $lookup + // Detail endpoint: prompts array is populated + val promptText = productionPrompt?.prompt + ?: prompts.firstOrNull { it.id == productionId }?.prompt + ?: prompts.firstOrNull()?.prompt + return PromptGroupDisplayData( + id = id ?: name, + name = name, + oneliner = oneliner, + category = category, + authorName = authorName, + command = command, + promptText = promptText, + ) +} + +private fun PromptGroup.toDetailDisplayData(): PromptGroupDetailDisplayData { + val promptText = productionPrompt?.prompt + ?: prompts.firstOrNull { it.id == productionId }?.prompt + ?: prompts.firstOrNull()?.prompt + return PromptGroupDetailDisplayData( + id = id ?: name, + name = name, + oneliner = oneliner, + category = category, + authorName = authorName, + command = command, + productionId = productionId, + productionPromptText = promptText, + promptCount = prompts.size, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptFilterSheet.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptFilterSheet.kt new file mode 100644 index 0000000..83bfc66 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptFilterSheet.kt @@ -0,0 +1,116 @@ +package com.librechat.android.feature.chat.prompts.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +enum class PromptSortOrder(val label: String) { + RECENT("Recent"), + POPULAR("Popular"), + ALPHABETICAL("Alphabetical"), +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun PromptFilterSheet( + categories: List, + selectedCategory: String?, + onCategorySelected: (String?) -> Unit, + sortOrder: PromptSortOrder, + onSortOrderChanged: (PromptSortOrder) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .navigationBarsPadding(), + ) { + Text( + text = "Filter & Sort", + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Category section + Text( + text = stringResource(R.string.label_category), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + FilterChip( + selected = selectedCategory == null, + onClick = { onCategorySelected(null) }, + label = { Text(stringResource(R.string.label_all)) }, + ) + categories.forEach { category -> + FilterChip( + selected = selectedCategory == category, + onClick = { + onCategorySelected( + if (selectedCategory == category) null else category, + ) + }, + label = { Text(category) }, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Sort section + Text( + text = stringResource(R.string.label_sort_by), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + PromptSortOrder.entries.forEach { order -> + FilterChip( + selected = sortOrder == order, + onClick = { onSortOrderChanged(order) }, + label = { Text(order.label) }, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptPreviewPanel.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptPreviewPanel.kt new file mode 100644 index 0000000..b48b389 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptPreviewPanel.kt @@ -0,0 +1,50 @@ +package com.librechat.android.feature.chat.prompts.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@Composable +fun PromptPreviewPanel( + promptTemplate: String, + variableValues: Map, + modifier: Modifier = Modifier, +) { + val interpolated = interpolateTemplate(promptTemplate, variableValues) + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.preview), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(bottom = 8.dp), + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Text( + text = interpolated.ifBlank { "Enter variable values to see preview" }, + style = MaterialTheme.typography.bodyMedium, + color = if (interpolated.isBlank()) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptShareDialog.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptShareDialog.kt new file mode 100644 index 0000000..02e3196 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/PromptShareDialog.kt @@ -0,0 +1,140 @@ +package com.librechat.android.feature.chat.prompts.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +enum class SharePermission(val label: String) { + VIEW_ONLY("View only"), + CAN_EDIT("Can edit"), +} + +@Composable +fun PromptShareDialog( + promptName: String, + isCurrentlyShared: Boolean, + onShareToggled: (shared: Boolean, permission: SharePermission) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var isShared by remember { mutableStateOf(isCurrentlyShared) } + var permission by remember { mutableStateOf(SharePermission.VIEW_ONLY) } + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.dialog_title_share_prompt)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = promptName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isShared) "Shared" else "Private", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Switch( + checked = isShared, + onCheckedChange = { isShared = it }, + ) + } + + if (isShared) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.label_permission), + style = MaterialTheme.typography.titleSmall, + ) + SharePermission.entries.forEach { perm -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = permission == perm, + onClick = { permission = perm }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = perm.label, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton( + onClick = { + val clipboard = context.getSystemService( + Context.CLIPBOARD_SERVICE, + ) as ClipboardManager + clipboard.setPrimaryClip( + ClipData.newPlainText("Prompt Link", "prompt://$promptName"), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.action_copy_link)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { onShareToggled(isShared, permission) }, + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/VariableInputDialog.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/VariableInputDialog.kt new file mode 100644 index 0000000..fad6361 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/prompts/components/VariableInputDialog.kt @@ -0,0 +1,134 @@ +package com.librechat.android.feature.chat.prompts.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +private val VARIABLE_PATTERN = Regex("\\{\\{(\\w+)\\}\\}") + +fun extractVariables(template: String): List { + return VARIABLE_PATTERN.findAll(template) + .map { it.groupValues[1] } + .distinct() + .toList() +} + +fun interpolateTemplate(template: String, values: Map): String { + var result = template + values.forEach { (key, value) -> + result = result.replace("{{$key}}", value) + } + return result +} + +@Composable +fun VariableInputDialog( + promptTemplate: String, + variables: List, + onInsert: (interpolatedText: String, autoSend: Boolean) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val variableValues = remember { mutableStateMapOf() } + var autoSend by remember { mutableStateOf(false) } + + val preview = interpolateTemplate(promptTemplate, variableValues) + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.dialog_title_fill_variables)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + variables.forEach { variable -> + OutlinedTextField( + value = variableValues[variable] ?: "", + onValueChange = { variableValues[variable] = it }, + label = { Text(variable) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.preview), + style = MaterialTheme.typography.titleSmall, + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Text( + text = preview, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Auto-send", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = autoSend, + onCheckedChange = { autoSend = it }, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onInsert(preview, autoSend) }, + enabled = variables.all { (variableValues[it] ?: "").isNotBlank() }, + ) { + Text(stringResource(R.string.action_insert)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/ChatScreen.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/ChatScreen.kt new file mode 100644 index 0000000..0cfc8dd --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/ChatScreen.kt @@ -0,0 +1,1119 @@ +package com.librechat.android.feature.chat.screen + +import android.Manifest +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.PackageManager +import android.speech.RecognizerIntent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Compare +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material.icons.outlined.SaveAs +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.librechat.android.core.model.ContentType +import com.librechat.android.core.model.MessageContentPart +import com.librechat.android.feature.chat.util.MessageNode +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import android.content.res.Configuration +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.chat.components.ChatInput +import com.librechat.android.feature.chat.components.ComparisonDualPane +import com.librechat.android.feature.chat.components.ComparisonTabBar +import com.librechat.android.feature.chat.components.ForkOptionsBottomSheet +import com.librechat.android.feature.chat.components.TempChatToggle +import com.librechat.android.feature.chat.components.InConvoSearchBar +import com.librechat.android.feature.chat.components.LandingContent +import com.librechat.android.feature.chat.components.MessageList +import com.librechat.android.feature.chat.components.ModelSelectorButton +import com.librechat.android.feature.chat.components.ModelSelectorSheet +import com.librechat.android.feature.chat.components.PresetPicker +import com.librechat.android.feature.chat.components.SavePresetDialog +import com.librechat.android.feature.chat.components.SecondaryMessageList +import com.librechat.android.core.ui.components.ModelParameterSheet +import com.librechat.android.core.data.datastore.ChatFontSize +import com.librechat.android.core.data.datastore.LatexRenderer +import com.librechat.android.core.ui.components.endpointIconRes +import com.librechat.android.core.ui.components.isMonochromeEndpointIcon +import com.librechat.android.feature.chat.viewmodel.ChatScreenState +import com.librechat.android.feature.chat.viewmodel.ChatViewModel +import kotlinx.coroutines.launch +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.chat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + modifier: Modifier = Modifier, + viewModel: ChatViewModel = hiltViewModel(), + onConversationStarted: ((String) -> Unit)? = null, + onNavigateToConversation: ((String) -> Unit)? = null, + onOpenDrawer: (() -> Unit)? = null, + onNavigateToPromptsLibrary: (() -> Unit)? = null, + onNavigateBack: (() -> Unit)? = null, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() + val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle() + val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle() + val showImageDescriptions = prefs.showImageDescriptions + val dismissKeyboardOnSend = prefs.dismissKeyboardOnSend + val chatLayoutStyle = prefs.chatLayoutStyle + val showAvatars = prefs.showAvatars + val showBubbles = prefs.showBubbles + val useKatex = prefs.latexRenderer == LatexRenderer.KATEX + val sttEngine = prefs.sttEngine + val sttLanguage = prefs.sttLanguage + val keyboardController = LocalSoftwareKeyboardController.current + val fontSizeMultiplier = when (uiState.chatFontSize) { + ChatFontSize.SMALL -> 0.85f + ChatFontSize.MEDIUM -> 1.0f + ChatFontSize.LARGE -> 1.2f + } + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + var showPresetPicker by remember { mutableStateOf(false) } + var showSavePresetDialog by remember { mutableStateOf(false) } + var showModelSheet by remember { mutableStateOf(false) } + var showSecondaryModelSheet by remember { mutableStateOf(false) } + var activeComparisonTab by remember { mutableStateOf(0) } + + // Resolve agent name for model display + val displayModel = if (uiState.selectedEndpoint == EndpointConstants.AGENTS && uiState.selectedModel != null) { + uiState.agents.find { it.id == uiState.selectedModel }?.name ?: uiState.selectedModel + } else { + uiState.selectedModel + } + + // Device speech recognizer launcher (used when server STT is not available) + val speechRecognizerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val matches = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + val transcribed = matches?.firstOrNull() + if (!transcribed.isNullOrBlank()) { + viewModel.onDeviceSpeechResult(transcribed) + } + } + } + + // Runtime permission request for RECORD_AUDIO (server STT path) + val audioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + viewModel.startRecording() + } else { + coroutineScope.launch { + snackbarHostState.showSnackbar("Microphone permission is required for voice input") + } + } + } + + val onStartRecordingWithPermission: () -> Unit = { + val useServerStt = sttEngine.equals("whisper", ignoreCase = true) || + (sttEngine.isBlank() || sttEngine.equals("default", ignoreCase = true)) && + uiState.serverSttEnabled + + if (useServerStt) { + if (!uiState.serverSttEnabled && sttEngine.equals("whisper", ignoreCase = true)) { + // User selected Whisper but server STT is not configured + coroutineScope.launch { + snackbarHostState.showSnackbar( + "Server speech-to-text is not enabled. " + + "Ask your server admin to enable STT, or switch to Device or Google engine.", + ) + } + } else { + // Server STT path: record audio and upload for transcription + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + if (hasPermission) { + viewModel.startRecording() + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } else { + // Device speech recognizer path (Device, Google, or Default without server) + val intent = android.content.Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + ) + putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...") + // Apply user's STT language preference + val languageLocale = mapSttLanguageToLocale(sttLanguage) + if (languageLocale != null) { + putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageLocale) + putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, languageLocale) + } + } + // Apply user's STT engine preference (set the recognizer package) + val enginePackage = mapSttEngineToPackage(sttEngine) + if (enginePackage != null) { + intent.setPackage(enginePackage) + } + try { + speechRecognizerLauncher.launch(intent) + } catch (_: Exception) { + val engineLabel = if (sttEngine.equals("google", ignoreCase = true)) { + "Google speech recognition is not available. Is the Google app installed?" + } else { + "Speech recognition is not available on this device" + } + coroutineScope.launch { + snackbarHostState.showSnackbar(engineLabel) + } + } + } + } + + // When a new conversation starts, navigate to chat/{conversationId} immediately + // (at StreamEvent.Created) so the new_chat landing page stays clean in the back + // stack. The new ChatViewModel at chat/{id} will resume the active stream. + // onPendingNavigationHandled() resets this ViewModel to a fresh landing state. + LaunchedEffect(uiState.pendingNavigationConversationId) { + val pendingId = uiState.pendingNavigationConversationId + if (pendingId != null && onConversationStarted != null) { + onConversationStarted(pendingId) + viewModel.onPendingNavigationHandled() + } + } + + // Show errors in snackbar + LaunchedEffect(uiState.error) { + val error = uiState.error + if (error != null) { + snackbarHostState.showSnackbar( + message = error, + actionLabel = "Dismiss", + ) + viewModel.dismissError() + } + } + + // Stream resume on foreground + LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { viewModel.onPause() } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onResume() } + + // Navigate to forked conversation + LaunchedEffect(uiState.forkedConversationId) { + val forkId = uiState.forkedConversationId + if (forkId != null) { + viewModel.onForkedConversationHandled() + if (onNavigateToConversation != null) { + onNavigateToConversation(forkId) + } else if (onConversationStarted != null) { + onConversationStarted(forkId) + } + } + } + + // Navigate to duplicated conversation + LaunchedEffect(uiState.duplicatedConversationId) { + val dupId = uiState.duplicatedConversationId + if (dupId != null) { + viewModel.onDuplicatedConversationHandled() + if (onNavigateToConversation != null) { + onNavigateToConversation(dupId) + } else if (onConversationStarted != null) { + onConversationStarted(dupId) + } + } + } + + // Copy share link to clipboard + LaunchedEffect(shareLinkUrl) { + val url = shareLinkUrl + if (url != null) { + clipboardManager.setPrimaryClip(ClipData.newPlainText("Share Link", url)) + viewModel.onShareLinkHandled() + snackbarHostState.showSnackbar("Share link copied to clipboard") + } + } + + // Navigate back after delete/archive (conversationId becomes null) + var hadConversation by remember { mutableStateOf(uiState.conversationId != null) } + LaunchedEffect(uiState.conversationId) { + if (hadConversation && uiState.conversationId == null) { + onNavigateBack?.invoke() + } + hadConversation = uiState.conversationId != null + } + + Scaffold( + modifier = modifier + .fillMaxSize() + .imePadding(), + topBar = { + Column { + ChatTopBar( + onLoadPreset = { showPresetPicker = true }, + onSavePreset = { showSavePresetDialog = true }, + onOpenDrawer = onOpenDrawer, + onOpenSearch = viewModel::openSearch, + onOpenPromptsLibrary = onNavigateToPromptsLibrary, + isTemporaryChat = uiState.isTemporaryChat, + onToggleTemporaryChat = viewModel::toggleTemporaryChat, + showTempChatToggle = uiState.conversationId == null, + isComparisonEnabled = uiState.comparisonState.isEnabled, + onToggleComparison = viewModel::toggleComparison, + conversationId = uiState.conversationId, + conversationTitle = uiState.conversationTitle, + sharedLinksEnabled = uiState.sharedLinksEnabled, + onShare = viewModel::shareConversation, + onRename = viewModel::showRenameDialog, + onDuplicate = viewModel::duplicateConversation, + onArchive = viewModel::archiveConversation, + onDelete = viewModel::showDeleteConfirmation, + ) + // In-conversation search bar overlay + if (uiState.isSearchOpen) { + InConvoSearchBar( + query = uiState.searchQuery, + onQueryChanged = viewModel::onSearchQueryChanged, + currentMatchIndex = uiState.currentSearchMatchIndex, + totalMatches = uiState.searchMatchIndices.size, + onPreviousMatch = viewModel::previousSearchMatch, + onNextMatch = viewModel::nextSearchMatch, + onClose = viewModel::closeSearch, + ) + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + when (uiState.screenState) { + ChatScreenState.LANDING -> { + LandingContent( + selectedModel = uiState.selectedModel, + modifier = Modifier.weight(1f), + ) + } + ChatScreenState.LOADING -> { + LoadingIndicator( + modifier = Modifier.weight(1f), + ) + } + ChatScreenState.ACTIVE -> { + val comparisonState = uiState.comparisonState + if (comparisonState.isEnabled) { + val screenWidthDp = LocalConfiguration.current.screenWidthDp + val isWideScreen = screenWidthDp >= 600 + + val canBranch = comparisonState.parallelMessageId != null && + !comparisonState.primaryIsStreaming && + !comparisonState.secondaryIsStreaming + + // Replace the parallel response message's content with + // the captured streaming buffer for each agent's pane. + // The server-loaded message may only contain the primary + // agent's content, so we substitute from the buffers. + val primarySenderName = run { + val model = uiState.selectedModel + if (uiState.selectedEndpoint == EndpointConstants.AGENTS && model != null) { + uiState.agents.find { it.id == model }?.name ?: model + } else { + model ?: "Assistant" + } + } + val primaryDisplayMessages = remember( + uiState.displayMessages, + comparisonState.parallelMessageId, + comparisonState.primaryFinalContent, + ) { + buildComparisonDisplayMessages( + uiState.displayMessages, + comparisonState.parallelMessageId, + comparisonState.primaryFinalContent, + primarySenderName, + ) + } + val secondarySenderName = viewModel.getSecondaryModelDisplayName() + ?: comparisonState.secondaryModel ?: "Assistant" + val secondaryDisplayMessages = remember( + uiState.displayMessages, + comparisonState.parallelMessageId, + comparisonState.secondaryFinalContent, + secondarySenderName, + ) { + buildComparisonDisplayMessages( + uiState.displayMessages, + comparisonState.parallelMessageId, + comparisonState.secondaryFinalContent, + secondarySenderName, + ) + } + + val primaryMessageList: @Composable () -> Unit = { + MessageList( + displayMessages = primaryDisplayMessages, + isStreaming = comparisonState.primaryIsStreaming || uiState.isStreaming, + streamingContent = if (comparisonState.primaryIsStreaming) comparisonState.primaryStreamingContent else uiState.streamingContent, + activeToolCalls = if (comparisonState.primaryIsStreaming) comparisonState.primaryActiveToolCalls else uiState.activeToolCalls, + onSiblingNavigation = viewModel::switchBranch, + onEditMessage = viewModel::startEditing, + onRegenerateMessage = viewModel::regenerateMessage, + onCopyMessage = { messageId -> + val text = viewModel.getMessageText(messageId) + if (text.isNotBlank()) { + clipboardManager.setPrimaryClip( + ClipData.newPlainText("Message", text), + ) + } + }, + onFeedback = viewModel::submitFeedback, + onContinue = { viewModel.continueGeneration() }, + onReadAloud = viewModel::readAloud, + onFork = viewModel::showForkOptions, + currentlyReadingMessageId = uiState.currentlyReadingMessageId, + editingMessageId = uiState.editingMessageId, + editingText = uiState.editingText, + onEditTextChanged = viewModel::onEditTextChanged, + onEditSaveAndSubmit = viewModel::submitEdit, + onEditSaveOnly = viewModel::saveEditOnly, + onEditCancel = viewModel::cancelEditing, + baseUrl = uiState.serverUrl, + fontSizeMultiplier = fontSizeMultiplier, + isRefreshing = uiState.isRefreshingMessages, + onRefresh = viewModel::refreshMessages, + userAvatarUrl = uiState.userAvatarUrl, + userName = uiState.userName, + endpointIconRes = endpointIconRes(uiState.selectedEndpoint), + tintEndpointIcon = isMonochromeEndpointIcon(uiState.selectedEndpoint), + streamingSenderName = primarySenderName, + showImageDescriptions = showImageDescriptions, + chatLayoutStyle = chatLayoutStyle, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + searchQuery = if (uiState.isSearchOpen) uiState.searchQuery else null, + searchMatchIndices = uiState.searchMatchIndices, + currentSearchMatchIndex = uiState.currentSearchMatchIndex, + searchScrollToIndex = uiState.searchScrollToIndex, + onSearchScrollHandled = viewModel::onSearchScrollHandled, + modifier = Modifier.fillMaxSize(), + ) + } + + val secondaryEndpoint = comparisonState.secondaryEndpoint ?: "agents" + val secondaryModelName = viewModel.getSecondaryModelDisplayName() + ?: comparisonState.secondaryModel + ?: stringResource(R.string.select_model) + + val secondaryMessageList: @Composable () -> Unit = { + SecondaryMessageList( + displayMessages = secondaryDisplayMessages, + isStreaming = comparisonState.secondaryIsStreaming, + streamingContent = comparisonState.secondaryStreamingContent, + activeToolCalls = comparisonState.secondaryActiveToolCalls, + error = null, + baseUrl = uiState.serverUrl, + fontSizeMultiplier = fontSizeMultiplier, + endpointIconRes = endpointIconRes(secondaryEndpoint), + tintEndpointIcon = isMonochromeEndpointIcon(secondaryEndpoint), + streamingSenderName = secondaryModelName, + showImageDescriptions = showImageDescriptions, + chatLayoutStyle = chatLayoutStyle, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + modifier = Modifier.fillMaxSize(), + ) + } + + val onContinuePrimary = if (canBranch && comparisonState.primaryAgentId != null) { + { viewModel.branchFromComparison(comparisonState.primaryAgentId) } + } else { + null + } + val onContinueSecondary = if (canBranch && comparisonState.secondaryAgentId != null) { + { viewModel.branchFromComparison(comparisonState.secondaryAgentId) } + } else { + null + } + + if (isWideScreen) { + // Tablet: dual pane side-by-side + ComparisonDualPane( + primaryModelSelector = { + ModelSelectorButton( + modelName = displayModel, + onClick = { showModelSheet = true }, + ) + }, + secondaryModelSelector = { + ModelSelectorButton( + modelName = secondaryModelName, + onClick = { showSecondaryModelSheet = true }, + ) + }, + primaryContent = primaryMessageList, + secondaryContent = secondaryMessageList, + onContinueWithPrimary = onContinuePrimary, + onContinueWithSecondary = onContinueSecondary, + modifier = Modifier.weight(1f), + ) + } else { + // Phone: tab bar with pager + ComparisonTabBar( + primaryModelName = displayModel ?: "Primary", + secondaryModelName = secondaryModelName, + primaryContent = primaryMessageList, + secondaryContent = secondaryMessageList, + onContinueWithPrimary = onContinuePrimary, + onContinueWithSecondary = onContinueSecondary, + onTabChanged = { activeComparisonTab = it }, + modifier = Modifier.weight(1f), + ) + } + } else { + MessageList( + displayMessages = uiState.displayMessages, + isStreaming = uiState.isStreaming, + streamingContent = uiState.streamingContent, + activeToolCalls = uiState.activeToolCalls, + onSiblingNavigation = viewModel::switchBranch, + onEditMessage = viewModel::startEditing, + onRegenerateMessage = viewModel::regenerateMessage, + onCopyMessage = { messageId -> + val text = viewModel.getMessageText(messageId) + if (text.isNotBlank()) { + clipboardManager.setPrimaryClip( + ClipData.newPlainText("Message", text), + ) + } + }, + onFeedback = viewModel::submitFeedback, + onContinue = { viewModel.continueGeneration() }, + onReadAloud = viewModel::readAloud, + onFork = viewModel::showForkOptions, + currentlyReadingMessageId = uiState.currentlyReadingMessageId, + editingMessageId = uiState.editingMessageId, + editingText = uiState.editingText, + onEditTextChanged = viewModel::onEditTextChanged, + onEditSaveAndSubmit = viewModel::submitEdit, + onEditSaveOnly = viewModel::saveEditOnly, + onEditCancel = viewModel::cancelEditing, + baseUrl = uiState.serverUrl, + fontSizeMultiplier = fontSizeMultiplier, + isRefreshing = uiState.isRefreshingMessages, + onRefresh = viewModel::refreshMessages, + userAvatarUrl = uiState.userAvatarUrl, + userName = uiState.userName, + endpointIconRes = endpointIconRes(uiState.selectedEndpoint), + tintEndpointIcon = isMonochromeEndpointIcon(uiState.selectedEndpoint), + streamingSenderName = run { + val model = uiState.selectedModel + if (uiState.selectedEndpoint == EndpointConstants.AGENTS && model != null) { + uiState.agents.find { it.id == model }?.name ?: model + } else { + model ?: "Assistant" + } + }, + showImageDescriptions = showImageDescriptions, + chatLayoutStyle = chatLayoutStyle, + showAvatars = showAvatars, + showBubbles = showBubbles, + useKatex = useKatex, + searchQuery = if (uiState.isSearchOpen) uiState.searchQuery else null, + searchMatchIndices = uiState.searchMatchIndices, + currentSearchMatchIndex = uiState.currentSearchMatchIndex, + searchScrollToIndex = uiState.searchScrollToIndex, + onSearchScrollHandled = viewModel::onSearchScrollHandled, + modifier = Modifier.weight(1f), + ) + } + } + } + } + + // ChatInput overlays at the bottom so gradient shows content behind + val isAnyStreaming = uiState.isStreaming || + uiState.comparisonState.primaryIsStreaming || + uiState.comparisonState.secondaryIsStreaming + ChatInput( + inputText = uiState.inputText, + isStreaming = isAnyStreaming, + onInputChanged = viewModel::onInputChanged, + onSend = { + viewModel.sendMessage() + if (dismissKeyboardOnSend) { + keyboardController?.hide() + } + }, + onStop = viewModel::stopGeneration, + attachedFiles = attachedFiles, + onFilesSelected = viewModel::onFilesSelected, + onRemoveFile = viewModel::removeFile, + promptSuggestions = uiState.availablePrompts, + onPromptSelected = viewModel::handlePromptMention, + onSlashCommandSelected = viewModel::handleSlashCommand, + isRecording = uiState.isRecording, + isTranscribing = uiState.isTranscribing, + onStartRecording = onStartRecordingWithPermission, + onStopRecording = viewModel::stopRecording, + enabledTools = uiState.effectiveEnabledTools, + onToggleTool = viewModel::toggleTool, + mcpServers = uiState.mcpServers, + selectedMcpServerNames = uiState.selectedMcpServerNames, + onToggleMcpServer = viewModel::toggleMcpServer, + onOpenModelParameters = viewModel::showModelParameters, + onOpenModelSelector = { + if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) { + showSecondaryModelSheet = true + } else { + showModelSheet = true + } + }, + selectedModelDisplay = if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) { + viewModel.getSecondaryModelDisplayName() + ?: uiState.comparisonState.secondaryModel + ?: displayModel + } else { + displayModel + }, + isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + + if (showPresetPicker) { + PresetPicker( + presets = uiState.presets, + onPresetSelected = { preset -> + viewModel.loadPreset(preset) + showPresetPicker = false + }, + onDismiss = { showPresetPicker = false }, + onEditPreset = { preset -> + viewModel.loadPreset(preset) + showPresetPicker = false + }, + onDeletePreset = { preset -> + preset.presetId?.let { viewModel.deletePreset(it) } + }, + ) + } + + if (showSavePresetDialog) { + SavePresetDialog( + currentEndpoint = uiState.selectedEndpoint, + currentModel = uiState.selectedModel, + onSave = { name -> + viewModel.savePreset(name) + showSavePresetDialog = false + }, + onDismiss = { showSavePresetDialog = false }, + ) + } + + if (uiState.showForkOptionsForMessageId != null) { + ForkOptionsBottomSheet( + onDismiss = viewModel::dismissForkOptions, + onFork = { option, splitAtTarget -> + viewModel.forkFromMessage( + messageId = uiState.showForkOptionsForMessageId!!, + option = option, + splitAtTarget = splitAtTarget, + ) + }, + ) + } + + if (uiState.showModelParameters) { + ModelParameterSheet( + parameters = uiState.modelParameters, + onParametersChanged = viewModel::updateModelParameters, + onDismiss = viewModel::hideModelParameters, + selectedEndpoint = uiState.selectedEndpoint, + ) + } + + if (uiState.showRenameDialog) { + ChatRenameDialog( + currentTitle = uiState.conversationTitle ?: "", + onDismiss = viewModel::dismissRenameDialog, + onConfirm = viewModel::renameConversation, + ) + } + + if (uiState.showDeleteConfirmation) { + ChatDeleteConfirmationDialog( + conversationTitle = uiState.conversationTitle ?: "this conversation", + onDismiss = viewModel::dismissDeleteConfirmation, + onConfirm = viewModel::deleteConversation, + ) + } + + if (showModelSheet) { + ModelSelectorSheet( + endpointConfigs = uiState.endpointConfigs, + availableModels = uiState.availableModels, + agents = uiState.agents, + selectedEndpoint = uiState.selectedEndpoint, + selectedModel = uiState.selectedModel, + onModelSelected = { endpoint, model -> + viewModel.onModelSelected(endpoint, model) + showModelSheet = false + }, + onDismiss = { showModelSheet = false }, + serverUrl = uiState.serverUrl, + ) + } + + // Secondary model selector sheet for comparison mode + if (showSecondaryModelSheet) { + ModelSelectorSheet( + endpointConfigs = uiState.endpointConfigs, + availableModels = uiState.availableModels, + agents = uiState.agents, + selectedEndpoint = uiState.comparisonState.secondaryEndpoint, + selectedModel = uiState.comparisonState.secondaryModel, + onModelSelected = { endpoint, model -> + viewModel.setSecondaryModel(endpoint, model) + showSecondaryModelSheet = false + }, + onDismiss = { showSecondaryModelSheet = false }, + serverUrl = uiState.serverUrl, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatTopBar( + onLoadPreset: () -> Unit, + onSavePreset: () -> Unit, + onOpenDrawer: (() -> Unit)?, + onOpenSearch: () -> Unit = {}, + onOpenPromptsLibrary: (() -> Unit)? = null, + isTemporaryChat: Boolean = false, + onToggleTemporaryChat: () -> Unit = {}, + showTempChatToggle: Boolean = false, + isComparisonEnabled: Boolean = false, + onToggleComparison: () -> Unit = {}, + conversationId: String? = null, + conversationTitle: String? = null, + sharedLinksEnabled: Boolean = false, + onShare: () -> Unit = {}, + onRename: () -> Unit = {}, + onDuplicate: () -> Unit = {}, + onArchive: () -> Unit = {}, + onDelete: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + var showOverflowMenu by remember { mutableStateOf(false) } + + Row( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + // Hamburger menu button to open drawer + if (onOpenDrawer != null) { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Open navigation drawer", + ) + } + } + + // Show conversation title when viewing an existing conversation + if (conversationId != null && !conversationTitle.isNullOrBlank()) { + Text( + text = conversationTitle, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(4.dp)) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + if (showTempChatToggle) { + TempChatToggle( + isTemporary = isTemporaryChat, + onToggle = onToggleTemporaryChat, + ) + } + Box { + IconButton(onClick = { showOverflowMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more_options), + ) + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false }, + shape = RoundedCornerShape(16.dp), + ) { + if (conversationId != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_search)) }, + onClick = { + showOverflowMenu = false + onOpenSearch() + }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.load_preset)) }, + onClick = { + showOverflowMenu = false + onLoadPreset() + }, + leadingIcon = { + Icon(Icons.Outlined.FileOpen, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.save_as_preset)) }, + onClick = { + showOverflowMenu = false + onSavePreset() + }, + leadingIcon = { + Icon(Icons.Outlined.SaveAs, contentDescription = null) + }, + ) + if (onOpenPromptsLibrary != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.prompts_library)) }, + onClick = { + showOverflowMenu = false + onOpenPromptsLibrary() + }, + leadingIcon = { + Icon(Icons.Outlined.AutoAwesome, contentDescription = null) + }, + ) + } + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.compare_models), + modifier = Modifier.weight(1f), + ) + if (isComparisonEnabled) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(R.string.cd_comparison_enabled), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + }, + onClick = { + showOverflowMenu = false + onToggleComparison() + }, + leadingIcon = { + Icon(Icons.Outlined.Compare, contentDescription = null) + }, + ) + if (conversationId != null) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Text( + text = conversationTitle ?: "New Chat", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + if (sharedLinksEnabled) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_share)) }, + onClick = { + showOverflowMenu = false + onShare() + }, + leadingIcon = { + Icon(Icons.Outlined.Share, contentDescription = null) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.action_rename)) }, + onClick = { + showOverflowMenu = false + onRename() + }, + leadingIcon = { + Icon(Icons.Outlined.Edit, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_duplicate)) }, + onClick = { + showOverflowMenu = false + onDuplicate() + }, + leadingIcon = { + Icon(Icons.Outlined.ContentCopy, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_archive)) }, + onClick = { + showOverflowMenu = false + onArchive() + }, + leadingIcon = { + Icon(Icons.Outlined.Archive, contentDescription = null) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Text( + "Delete", + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showOverflowMenu = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + } + } + +} + +@Composable +private fun ChatRenameDialog( + currentTitle: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var title by remember { mutableStateOf(currentTitle) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_rename)) }, + text = { + Column { + Text( + text = "Enter a new title for this conversation.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.hint_title)) }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(title) }, + enabled = title.isNotBlank(), + ) { + Text(stringResource(R.string.action_rename)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun ChatDeleteConfirmationDialog( + conversationTitle: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_delete_conversation)) }, + text = { + Text( + text = "Are you sure you want to delete \"$conversationTitle\"? This action cannot be undone.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +/** + * Maps the user-facing STT engine name (from settings) to an Android package name + * that can be set on the device speech recognition intent. Returns null for "Default", + * empty/unknown values, or "Whisper" (which uses server-side STT, not a device package). + * + * Note: "Whisper" is handled separately in [onStartRecordingWithPermission] above -- + * it routes through the server STT path (record audio + upload) rather than the device + * speech recognizer. This function is only called for the device recognizer path. + */ +private fun mapSttEngineToPackage(engine: String): String? = when (engine.lowercase()) { + "google" -> "com.google.android.googlequicksearchbox" + "whisper" -> null // Server-side engine; never reaches device recognizer path + "device" -> null // Explicit on-device; uses system default speech recognizer + "default", "" -> null + else -> null +} + +/** + * Maps the user-facing STT language name (from settings) to a BCP-47 locale tag + * for [RecognizerIntent.EXTRA_LANGUAGE]. Returns null for "Auto-detect" or + * empty values, which lets the recognizer use the device default. + */ +private fun mapSttLanguageToLocale(language: String): String? = when (language.lowercase()) { + "english" -> "en-US" + "spanish" -> "es-ES" + "french" -> "fr-FR" + "german" -> "de-DE" + "japanese" -> "ja-JP" + "chinese" -> "zh-CN" + "auto-detect", "" -> null + else -> null +} + +/** + * Replaces the parallel response message's content with [finalContent] captured from + * the streaming buffer. The server-loaded message may only contain the primary agent's + * content, so for the secondary pane we substitute the captured text. + * Also updates the sender name so the bubble shows the correct model. + */ +private fun buildComparisonDisplayMessages( + displayMessages: List, + parallelMessageId: String?, + finalContent: String?, + senderName: String?, +): List { + if (parallelMessageId == null || finalContent.isNullOrBlank()) return displayMessages + return displayMessages.map { node -> + if (node.message.messageId == parallelMessageId) { + // Substitute the parallel message content with the captured final content for this pane + node.copy( + message = node.message.copy( + content = listOf( + MessageContentPart(type = ContentType.TEXT, text = finalContent), + ), + sender = senderName ?: node.message.sender, + ), + ) + } else { + node + } + } +} + diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/NewChatScreen.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/NewChatScreen.kt new file mode 100644 index 0000000..b7f16f3 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/screen/NewChatScreen.kt @@ -0,0 +1,25 @@ +package com.librechat.android.feature.chat.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Thin wrapper that shows ChatScreen with no conversationId. + * When a conversation starts via streaming, the [onConversationStarted] + * callback is invoked with the new conversationId, allowing navigation + * to the full chat route. + */ +@Composable +fun NewChatScreen( + onConversationStarted: (String) -> Unit, + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + onNavigateToPromptsLibrary: (() -> Unit)? = null, +) { + ChatScreen( + modifier = modifier, + onConversationStarted = onConversationStarted, + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = onNavigateToPromptsLibrary, + ) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/FileUtils.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/FileUtils.kt new file mode 100644 index 0000000..ce52e1b --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/FileUtils.kt @@ -0,0 +1,263 @@ +package com.librechat.android.feature.chat.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import timber.log.Timber + +/** Draft key used for new chats that don't have a conversation ID yet. */ +const val NEW_CHAT_DRAFT_KEY = "__new_chat__" + +/** + * Resolves a human-readable filename from a content URI using the ContentResolver. + * Falls back to the last path segment if DISPLAY_NAME is not available. + */ +internal fun resolveFileName(context: Context, uri: Uri): String? { + if (uri.scheme == "content") { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + return cursor.getString(nameIndex) + } + } + } + return uri.lastPathSegment +} + +/** + * Guesses MIME type from file extension. Defaults to application/octet-stream. + */ +internal fun guessMimeType(filename: String): String { + val extension = filename.substringAfterLast('.', "").lowercase() + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + ?: "application/octet-stream" +} + +/** + * Detects the actual MIME type of image data by inspecting magic bytes (file signature). + * + * This is critical because Android's ContentResolver.getType() and file extension-based + * guessing can return incorrect MIME types. For example, a PNG screenshot might have a + * .jpg extension, or a WebP image shared from another app may be reported as image/jpeg. + * When the declared MIME type doesn't match the actual data, AI providers (e.g. Anthropic) + * reject the image with a 400 error. + * + * Returns the detected MIME type for known image formats, or null if the bytes don't + * match any recognized image signature. + * + * Supported formats: JPEG, PNG, GIF, WebP, BMP, TIFF, HEIF/HEIC, AVIF, ICO, SVG. + */ +internal fun detectMimeTypeFromBytes(bytes: ByteArray): String? { + if (bytes.size < 12) return null + + // JPEG: FF D8 FF + if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) { + return "image/jpeg" + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && + bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() && + bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte() && + bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte() + ) { + return "image/png" + } + + // GIF: "GIF87a" or "GIF89a" + if (bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && + bytes[2] == 0x46.toByte() && bytes[3] == 0x38.toByte() && + (bytes[4] == 0x37.toByte() || bytes[4] == 0x39.toByte()) && + bytes[5] == 0x61.toByte() + ) { + return "image/gif" + } + + // WebP: "RIFF" at offset 0 and "WEBP" at offset 8 + if (bytes[0] == 0x52.toByte() && bytes[1] == 0x49.toByte() && + bytes[2] == 0x46.toByte() && bytes[3] == 0x46.toByte() && + bytes[8] == 0x57.toByte() && bytes[9] == 0x45.toByte() && + bytes[10] == 0x42.toByte() && bytes[11] == 0x50.toByte() + ) { + return "image/webp" + } + + // BMP: "BM" + if (bytes[0] == 0x42.toByte() && bytes[1] == 0x4D.toByte()) { + return "image/bmp" + } + + // TIFF: "II" (little-endian) or "MM" (big-endian) followed by 42 + if ((bytes[0] == 0x49.toByte() && bytes[1] == 0x49.toByte() && + bytes[2] == 0x2A.toByte() && bytes[3] == 0x00.toByte()) || + (bytes[0] == 0x4D.toByte() && bytes[1] == 0x4D.toByte() && + bytes[2] == 0x00.toByte() && bytes[3] == 0x2A.toByte()) + ) { + return "image/tiff" + } + + // HEIF/HEIC and AVIF: ftyp box at offset 4 + if (bytes.size >= 12 && + bytes[4] == 0x66.toByte() && bytes[5] == 0x74.toByte() && + bytes[6] == 0x79.toByte() && bytes[7] == 0x70.toByte() + ) { + // Read the brand (4 bytes at offset 8) + val brand = String(bytes, 8, 4, Charsets.US_ASCII) + return when { + brand.startsWith("heic") || brand.startsWith("heix") || + brand.startsWith("heim") || brand.startsWith("heis") || + brand.startsWith("mif1") -> "image/heic" + brand.startsWith("avif") || brand.startsWith("avis") -> "image/avif" + else -> null // Unknown ftyp brand + } + } + + // ICO: 00 00 01 00 + if (bytes[0] == 0x00.toByte() && bytes[1] == 0x00.toByte() && + bytes[2] == 0x01.toByte() && bytes[3] == 0x00.toByte() + ) { + return "image/x-icon" + } + + return null +} + +/** + * Returns the canonical file extension for a given MIME type. + * Used to ensure the filename extension matches the actual content type. + */ +internal fun extensionForMimeType(mimeType: String): String? = when (mimeType) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + "image/bmp" -> "bmp" + "image/tiff" -> "tiff" + "image/heic" -> "heic" + "image/heif" -> "heif" + "image/avif" -> "avif" + "image/x-icon" -> "ico" + "image/svg+xml" -> "svg" + else -> null +} + +/** + * Fixes the filename extension to match the detected MIME type. + * + * The LibreChat server uses the filename extension (via multer) to determine + * whether image conversion is needed. If the extension doesn't match the + * server's configured imageOutputType, the image gets converted but the + * stored MIME type (from our Content-Type header) does not get updated, + * causing a mismatch that AI providers reject. + * + * By ensuring the extension matches our detected MIME type, we maximize the + * chance that extension == imageOutputType, avoiding unnecessary conversion. + */ +internal fun fixFilenameExtension(filename: String, mimeType: String): String { + val expectedExtension = extensionForMimeType(mimeType) ?: return filename + + val dotIndex = filename.lastIndexOf('.') + val currentExtension = if (dotIndex >= 0) { + filename.substring(dotIndex + 1).lowercase() + } else { + "" + } + + // Check if current extension already matches (accounting for jpeg/jpg equivalence) + val normalizedCurrent = when (currentExtension) { + "jpeg" -> "jpg" + "tif" -> "tiff" + else -> currentExtension + } + val normalizedExpected = when (expectedExtension) { + "jpeg" -> "jpg" + "tif" -> "tiff" + else -> expectedExtension + } + + if (normalizedCurrent == normalizedExpected) { + return filename + } + + // Replace the extension + return if (dotIndex >= 0) { + "${filename.substring(0, dotIndex)}.$expectedExtension" + } else { + "$filename.$expectedExtension" + } +} + +/** + * Data class holding the result of image re-encoding. + */ +data class ReEncodedImage( + val bytes: ByteArray, + val mimeType: String, +) + +/** + * Re-encodes ALL images to PNG to prevent MIME type mismatches. + * + * The LibreChat server has a bug in processAgentFileUpload: it converts + * uploaded images to its configured imageOutputType (default: PNG) using + * sharp, but stores the *original* MIME type from the upload Content-Type + * header. When the AI provider (e.g. Anthropic) later receives the base64 + * image, it gets converted bytes (e.g. PNG) with the original media_type + * (e.g. image/jpeg), causing a 400 "Image does not match the provided + * media type" error. + * + * The only reliable client-side fix is to ensure NO conversion happens + * on the server. The server skips conversion when the file extension + * matches its imageOutputType. By always re-encoding to PNG (the server + * default) and setting the extension to .png, the server sees + * .png == .png and leaves the bytes untouched, so the stored MIME type + * (image/png) matches the actual bytes. + * + * This means JPEG photos get re-encoded to PNG, which increases file + * size, but the alternative is a 400 error that makes the app unusable. + * The server will resize large images anyway, so the size impact on the + * AI provider request is bounded. + * + * Returns null only if the image cannot be decoded by BitmapFactory. + */ +internal fun reEncodeImageIfNeeded(bytes: ByteArray, mimeType: String): ReEncodedImage? { + // Always re-encode to PNG to match the server's default imageOutputType. + // Previously we skipped JPEG/PNG/GIF/WebP as "safe", but the server + // converts non-matching extensions (e.g. .jpg when imageOutputType=png) + // while storing the original MIME type, causing the Anthropic 400 error. + return try { + val bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: return null // BitmapFactory can't decode this format + + // If already PNG with correct magic bytes, skip re-encoding to save CPU/memory + if (mimeType == "image/png" && detectMimeTypeFromBytes(bytes) == "image/png") { + bitmap.recycle() + return null + } + + val outputStream = java.io.ByteArrayOutputStream() + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, outputStream) + bitmap.recycle() + + val encodedBytes = outputStream.toByteArray() + // Verify the re-encoded bytes are valid PNG + val verifiedType = detectMimeTypeFromBytes(encodedBytes) + if (verifiedType != "image/png") { + Timber.w( + "reEncodeImageIfNeeded: re-encoded bytes don't match expected type image/png (got %s), using original", + verifiedType, + ) + return null + } + + Timber.d( + "reEncodeImageIfNeeded: re-encoded %s -> image/png (%d -> %d bytes)", + mimeType, bytes.size, encodedBytes.size, + ) + ReEncodedImage(bytes = encodedBytes, mimeType = "image/png") + } catch (e: Exception) { + Timber.w(e, "reEncodeImageIfNeeded: failed to re-encode %s, using original bytes", mimeType) + null + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/MessageTree.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/MessageTree.kt new file mode 100644 index 0000000..da77e46 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/util/MessageTree.kt @@ -0,0 +1,100 @@ +package com.librechat.android.feature.chat.util + +import androidx.compose.runtime.Immutable +import com.librechat.android.core.model.Message +import com.librechat.android.core.model.request.NO_PARENT + +@Immutable +data class MessageNode( + val message: Message, + val children: List, + val siblingIndex: Int, + val siblingCount: Int, + /** The key used in the tree grouping — may differ from message.parentMessageId + * when orphan re-parenting moves the message under NO_PARENT. Use this as the + * key for activeBranches in switchBranch(). */ + val treeParentKey: String = NO_PARENT, +) + +/** + * Build the "active path" from a flat list of messages using parentMessageId. + * + * Groups messages by parentMessageId, then walks from the root following the + * selected (or last) child at each level. Returns an ordered list of + * [MessageNode]s representing the currently visible conversation thread. + * + * @param messages flat list of messages from the API + * @param activeBranches map of parentMessageId -> selected child index; + * when absent, the last child (most recent) is shown + */ +fun buildActiveMessagePath( + messages: List, + activeBranches: Map = emptyMap(), +): List { + if (messages.isEmpty()) return emptyList() + + // Group children by their parentMessageId + val childrenByParent = mutableMapOf>() + val messageIds = messages.mapTo(mutableSetOf()) { it.messageId } + for (message in messages) { + val parentId = message.parentMessageId + ?.takeIf { it != NO_PARENT && it.isNotBlank() } + ?: NO_PARENT + childrenByParent.getOrPut(parentId) { mutableListOf() }.add(message) + } + + // Re-parent orphans: messages whose parentId isn't NO_PARENT and isn't + // in the message set (e.g. system/root messages not returned by the API). + // Move them under NO_PARENT so the tree walk can find them. + val orphanParents = childrenByParent.keys + .filter { it != NO_PARENT && it !in messageIds } + if (orphanParents.isNotEmpty()) { + val roots = childrenByParent.getOrPut(NO_PARENT) { mutableListOf() } + for (orphanParentId in orphanParents) { + childrenByParent.remove(orphanParentId)?.let { roots.addAll(it) } + } + } + + // Walk the tree from roots, building the flat active path + val result = mutableListOf() + var currentParentId = NO_PARENT + + while (true) { + val siblings = childrenByParent[currentParentId] ?: break + if (siblings.isEmpty()) break + + val siblingCount = siblings.size + // Default to last child (most recent), but allow override via activeBranches + val selectedIndex = activeBranches[currentParentId] + ?.coerceIn(0, siblingCount - 1) + ?: (siblingCount - 1) + + val selectedMessage = siblings[selectedIndex] + + // Build children list for this node (all siblings as MessageNode without recursion) + val childrenNodes = siblings.mapIndexed { index, msg -> + MessageNode( + message = msg, + children = emptyList(), + siblingIndex = index, + siblingCount = siblingCount, + treeParentKey = currentParentId, + ) + } + + result.add( + MessageNode( + message = selectedMessage, + children = childrenNodes, + siblingIndex = selectedIndex, + siblingCount = siblingCount, + treeParentKey = currentParentId, + ) + ) + + // Follow the selected child's subtree + currentParentId = selectedMessage.messageId + } + + return result +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatStateHandle.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatStateHandle.kt new file mode 100644 index 0000000..60efc94 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatStateHandle.kt @@ -0,0 +1,19 @@ +package com.librechat.android.feature.chat.viewmodel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Shared state accessor passed to all ChatViewModel delegates. + * Provides read/write access to the UI state and a coroutine scope. + */ +class ChatStateHandle( + val stateFlow: MutableStateFlow, + val scope: CoroutineScope, +) { + val state: ChatUiState get() = stateFlow.value + + fun update(transform: ChatUiState.() -> ChatUiState) { + stateFlow.value = stateFlow.value.transform() + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt new file mode 100644 index 0000000..5ecc35d --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt @@ -0,0 +1,151 @@ +package com.librechat.android.feature.chat.viewmodel + +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.core.data.datastore.ChatFontSize +import com.librechat.android.core.data.datastore.LatexRenderer +import com.librechat.android.core.model.Agent +import com.librechat.android.core.model.EndpointConfig +import com.librechat.android.core.ui.components.ModelParameters +import com.librechat.android.feature.chat.McpServerDisplayData +import com.librechat.android.feature.chat.PresetDisplayData +import com.librechat.android.feature.chat.PromptMentionDisplayData +import com.librechat.android.feature.chat.util.MessageNode + +enum class ChatScreenState { LANDING, LOADING, ACTIVE } + +/** + * Consolidated chat-related user preferences from [SettingsDataStore]. + * Exposed as a single [StateFlow] to reduce the number of individual subscriptions + * in the UI layer. + */ +@androidx.compose.runtime.Immutable +data class ChatPreferences( + val showImageDescriptions: Boolean = false, + val dismissKeyboardOnSend: Boolean = false, + val chatLayoutStyle: String = ChatLayoutConstants.THREAD, + val showAvatars: Boolean = true, + val showBubbles: Boolean = false, + val latexRenderer: LatexRenderer = LatexRenderer.KATEX, + val autoSendAfterStt: Boolean = false, + val sttEngine: String = "", + val sttLanguage: String = "", +) + +/** + * Represents a single occurrence of a search query match. + * @param messageIndex Index into [ChatUiState.displayMessages] containing this occurrence. + * @param occurrenceInMessage 0-based index of this occurrence within the message text. + */ +@androidx.compose.runtime.Immutable +data class SearchMatch( + val messageIndex: Int, + val occurrenceInMessage: Int, +) + +@androidx.compose.runtime.Immutable +data class ActiveToolCall( + val id: String, + val name: String, + val isComplete: Boolean = false, + val output: String? = null, +) + +@androidx.compose.runtime.Immutable +data class ChatUiState( + val screenState: ChatScreenState = ChatScreenState.LANDING, + val messages: List = emptyList(), + val displayMessages: List = emptyList(), + val activeBranches: Map = emptyMap(), + val inputText: String = "", + val isStreaming: Boolean = false, + val streamingContent: String = "", + val activeToolCalls: List = emptyList(), + /** Attachments received during SSE streaming (e.g., tool-generated images). + * Cleared when streaming ends. Used to provide attachment context while the + * final message (with full attachments) has not yet been persisted to Room. */ + val streamingAttachments: List = emptyList(), + val selectedModel: String? = null, + val selectedEndpoint: String = EndpointConstants.AGENTS, + val endpointConfigs: Map = emptyMap(), + val availableModels: Map> = emptyMap(), + val agents: List = emptyList(), + val conversationId: String? = null, + val error: String? = null, + val presets: List = emptyList(), + val availablePrompts: List = emptyList(), + val editingMessageId: String? = null, + val editingText: String = "", + val modelParameters: ModelParameters = ModelParameters.DEFAULT, + val showModelParameters: Boolean = false, + val isRecording: Boolean = false, + val isTranscribing: Boolean = false, + /** Whether the server has STT configured. When false, use device speech recognition. */ + val serverSttEnabled: Boolean = false, + // TTS state + val currentlyReadingMessageId: String? = null, + // Temporary chat state + val isTemporaryChat: Boolean = false, + // In-conversation search state + val isSearchOpen: Boolean = false, + val searchQuery: String = "", + val searchMatchIndices: List = emptyList(), + val currentSearchMatchIndex: Int = 0, + /** The displayMessages index to scroll to when search match changes. Consumed by MessageList. */ + val searchScrollToIndex: Int? = null, + // Tool toolbar state + val enabledTools: Set = emptySet(), + // MCP server state + val mcpServers: List = emptyList(), + val selectedMcpServerNames: Set = emptySet(), + // Pull-to-refresh state + val isRefreshingMessages: Boolean = false, + // Merged from separate StateFlows + val serverUrl: String = "", + val chatFontSize: ChatFontSize = ChatFontSize.MEDIUM, + val forkedConversationId: String? = null, + val showForkOptionsForMessageId: String? = null, + val isForkInProgress: Boolean = false, + // User profile info for message avatars + val userName: String? = null, + val userAvatarUrl: String? = null, + // Conversation actions state + val conversationTitle: String? = null, + val sharedLinksEnabled: Boolean = false, + val showRenameDialog: Boolean = false, + val showDeleteConfirmation: Boolean = false, + val duplicatedConversationId: String? = null, + /** Set at StreamEvent.Created when a new conversation's conversationId becomes available. + * The UI navigates to chat/{id} and then clears this via [ChatViewModel.onPendingNavigationHandled], + * which also resets this ViewModel to a clean landing state. */ + val pendingNavigationConversationId: String? = null, + // Model comparison state + val comparisonState: ComparisonState = ComparisonState(), +) { + /** + * Effective tool set that merges [enabledTools] with the web search state from + * [modelParameters]. This ensures the toolbar, bottom sheet, and dropdown all + * reflect the same web search toggle as the Model Parameters sheet. + */ + val effectiveEnabledTools: Set + get() = if (modelParameters.webSearch) { + enabledTools + ToolConstants.WEB_SEARCH + } else { + enabledTools - ToolConstants.WEB_SEARCH + } + + /** + * Whether code interpreter (execute_code) is available on this server. + * Derived from the agents endpoint config's capabilities list. + * Mirrors the web frontend's `useAgentCapabilities` + `codeEnabled` check. + */ + val isCodeInterpreterAvailable: Boolean + get() { + val agentsConfig = endpointConfigs[EndpointConstants.AGENTS] + // If no agents config is loaded yet, default to true to avoid + // hiding the toggle before config arrives. Once config loads, + // the actual capabilities list is authoritative. + return agentsConfig?.capabilities?.contains(ToolConstants.EXECUTE_CODE) ?: true + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..c93f4c8 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt @@ -0,0 +1,1366 @@ +package com.librechat.android.feature.chat.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import android.content.Context +import android.net.Uri +import android.util.Log +import timber.log.Timber +import com.librechat.android.feature.chat.util.NEW_CHAT_DRAFT_KEY +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.common.result.getOrNull +import com.librechat.android.core.data.datastore.LatexRenderer +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.AgentRepository +import com.librechat.android.core.data.repository.ChatRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.DraftRepository +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.data.repository.MessageRepository +import com.librechat.android.core.data.repository.PresetRepository +import com.librechat.android.core.data.repository.PromptRepository +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.core.data.repository.UserRepository +import com.librechat.android.feature.chat.components.AttachedFile +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.model.request.AddedConversation +import com.librechat.android.core.model.request.EphemeralAgent +import com.librechat.android.core.model.Preset +import com.librechat.android.core.model.StreamEvent +import com.librechat.android.core.ui.components.ModelParameters +import com.librechat.android.feature.chat.PresetDisplayData +import com.librechat.android.feature.chat.PromptMentionDisplayData +import com.librechat.android.feature.chat.util.buildActiveMessagePath +import com.librechat.android.feature.chat.ShareIntentConsumer +import com.librechat.android.feature.chat.viewmodel.delegate.ConversationActionsDelegate +import com.librechat.android.feature.chat.viewmodel.delegate.FileAttachmentDelegate +import com.librechat.android.feature.chat.viewmodel.delegate.InConversationSearchDelegate +import com.librechat.android.feature.chat.viewmodel.delegate.ModelSelectionDelegate +import com.librechat.android.feature.chat.viewmodel.delegate.PresetPromptDelegate +import com.librechat.android.feature.chat.viewmodel.delegate.TextToSpeechDelegate +import com.librechat.android.feature.chat.viewmodel.delegate.VoiceInputDelegate +import com.librechat.android.feature.chat.viewmodel.delegate.toSerialName +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class ChatViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + @ApplicationContext private val appContext: Context, + private val agentRepository: AgentRepository, + private val chatRepository: ChatRepository, + private val messageRepository: MessageRepository, + private val configRepository: ConfigRepository, + private val conversationRepository: ConversationRepository, + private val draftRepository: DraftRepository, + fileRepository: FileRepository, + presetRepository: PresetRepository, + promptRepository: PromptRepository, + shareRepository: ShareRepository, + private val speechRepository: SpeechRepository, + mcpRepository: McpRepository, + private val userRepository: UserRepository, + serverDataStore: ServerDataStore, + private val settingsDataStore: SettingsDataStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ChatUiState()) + + private val stateHandle = ChatStateHandle(_uiState, viewModelScope) + + // --- Delegates --- + private val searchDelegate = InConversationSearchDelegate(stateHandle) + private val conversationActionsDelegate = ConversationActionsDelegate(stateHandle, conversationRepository, shareRepository) + private val ttsDelegate = TextToSpeechDelegate(stateHandle, appContext, speechRepository, settingsDataStore, ::getMessageText) + private val voiceDelegate = VoiceInputDelegate(stateHandle, appContext, speechRepository, autoSendAfterStt = settingsDataStore.autoSendAfterStt.stateIn(viewModelScope, SharingStarted.Eagerly, false), onTranscriptionComplete = ::sendMessage) + private val fileDelegate = FileAttachmentDelegate(stateHandle, appContext, fileRepository) + private val presetPromptDelegate = PresetPromptDelegate(stateHandle, presetRepository, promptRepository) + private val modelDelegate = ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore, chatRepository) + + // --- Delegate-owned flows exposed to the UI --- + val attachedFiles: StateFlow> get() = fileDelegate.attachedFiles + val shareLinkUrl: StateFlow get() = conversationActionsDelegate.shareLinkUrl + + @Suppress("UNCHECKED_CAST") + val chatPreferences: StateFlow = combine( + listOf( + settingsDataStore.showImageDescriptions, + settingsDataStore.dismissKeyboardOnSend, + settingsDataStore.chatLayoutStyle, + settingsDataStore.showAvatars, + settingsDataStore.showBubbles, + settingsDataStore.latexRenderer, + settingsDataStore.autoSendAfterStt, + settingsDataStore.sttEngine, + settingsDataStore.sttLanguage, + ), + ) { values -> + ChatPreferences( + showImageDescriptions = values[0] as Boolean, + dismissKeyboardOnSend = values[1] as Boolean, + chatLayoutStyle = values[2] as String, + showAvatars = values[3] as Boolean, + showBubbles = values[4] as Boolean, + latexRenderer = values[5] as LatexRenderer, + autoSendAfterStt = values[6] as Boolean, + sttEngine = values[7] as String, + sttLanguage = values[8] as String, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, ChatPreferences()) + + val uiState: StateFlow = combine( + _uiState, + serverDataStore.currentUrlFlow, + settingsDataStore.chatFontSize, + ) { state, url, fontSize -> + state.copy( + serverUrl = url, + chatFontSize = fontSize, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, ChatUiState()) + + private var streamJob: Job? = null + private var roomObserverJob: Job? = null + private var streamingUpdateJob: Job? = null + private val streamingBuffer = StringBuilder() + private var streamingBufferDirty = false + private var wasStreaming = false + + companion object { + /** Minimum interval between streaming UI state updates to avoid recomposition spam. */ + private const val STREAMING_UI_UPDATE_INTERVAL_MS = 50L + } + + /** True when the current stream is from an edit, regenerate, or continue operation. */ + private var isEditOrRegenerate = false + + /** True when this ViewModel was opened for a brand-new chat (no conversationId from navigation). */ + private val isNewConversation: Boolean + /** Guard to ensure we only attempt title generation once per conversation. */ + private var titleGenerationRequested = false + + init { + val conversationId = savedStateHandle.get("conversationId") + isNewConversation = conversationId == null + if (conversationId != null) { + _uiState.value = _uiState.value.copy( + conversationId = conversationId, + screenState = ChatScreenState.LOADING, + ) + loadConversation(conversationId) + loadConversationModel(conversationId) + restoreDraft(conversationId) + // Check if there's an active stream for this conversation (e.g. when + // navigating here from new_chat immediately after sending). If so, + // resume it so the user sees streaming content on this screen. + resumeActiveStreamIfNeeded(conversationId) + } else { + // For new chats, mark conversationModelLoaded so refilterModels + // doesn't wait for a conversation model that will never arrive. + modelDelegate.conversationModelLoaded = true + // Consume any pending share intent data (text and/or files shared from another app) + consumeShareIntent() + restoreDraft(NEW_CHAT_DRAFT_KEY) + } + + // Observe share intents that arrive while this ViewModel is already active + viewModelScope.launch { + ShareIntentConsumer.shareAvailable.collect { + consumeShareIntent() + } + } + + // Eagerly load last-used model from DataStore so refilterModels can + // use it as a fallback. + viewModelScope.launch { + val endpoint = settingsDataStore.lastUsedEndpoint.first() + val model = settingsDataStore.lastUsedModel.first() + modelDelegate.cachedLastUsedEndpoint = endpoint + modelDelegate.cachedLastUsedModel = model + modelDelegate.lastUsedModelLoaded = true + // For new chats, apply the last-used model directly as the + // initial selection. refilterModels will validate it when + // the available models list arrives. + if (isNewConversation && endpoint != null && model != null) { + _uiState.value = _uiState.value.copy( + selectedEndpoint = endpoint, + selectedModel = model, + ) + } + // Re-run validation now that we have the DataStore values. + modelDelegate.refilterModels(isNewConversation) + } + + viewModelScope.launch { + configRepository.endpointConfigs.collect { configs -> + _uiState.value = _uiState.value.copy(endpointConfigs = configs) + modelDelegate.refilterModels(isNewConversation) + // If code interpreter is no longer available, remove it from enabled tools + val agentsCapabilities = configs[EndpointConstants.AGENTS]?.capabilities ?: emptyList() + if (agentsCapabilities.isNotEmpty() && ToolConstants.EXECUTE_CODE !in agentsCapabilities) { + val currentTools = _uiState.value.enabledTools + if (ToolConstants.CODE_INTERPRETER in currentTools) { + _uiState.value = _uiState.value.copy( + enabledTools = currentTools - ToolConstants.CODE_INTERPRETER, + ) + } + } + } + } + + viewModelScope.launch { + configRepository.availableModels.collect { models -> + _uiState.value = _uiState.value.copy(availableModels = models) + modelDelegate.refilterModels(isNewConversation) + } + } + + viewModelScope.launch { + val endpointsResult = configRepository.fetchEndpoints() + if (endpointsResult is Result.Error) { + _uiState.value = _uiState.value.copy( + error = endpointsResult.message ?: "Could not load endpoint configuration", + ) + return@launch + } + val modelsResult = configRepository.fetchModels() + if (modelsResult is Result.Error) { + _uiState.value = _uiState.value.copy( + error = modelsResult.message ?: "Could not load available models", + ) + } + } + + // Restore MCP server and tool selections from DataStore so they + // survive the new_chat -> chat/{id} navigation re-creation. + viewModelScope.launch { + val mcpServers = settingsDataStore.selectedMcpServers.first() + val tools = settingsDataStore.enabledTools.first() + if (mcpServers.isNotEmpty() || tools.isNotEmpty()) { + _uiState.update { + it.copy( + selectedMcpServerNames = mcpServers, + enabledTools = tools, + ) + } + } + } + + presetPromptDelegate.loadPresets() + presetPromptDelegate.loadAvailablePrompts() + modelDelegate.loadMcpServers() + loadUserProfile() + modelDelegate.loadAgents() + loadSharedLinksEnabled() + voiceDelegate.loadSpeechConfig() + } + + // ── Core chat flow ────────────────────────────────────────────── + + private fun loadConversation(conversationId: String) { + // Cancel any previous Room observer to avoid duplicate collectors + roomObserverJob?.cancel() + roomObserverJob = viewModelScope.launch { + try { + messageRepository.getMessages(conversationId) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch messages for $conversationId") + _uiState.value = _uiState.value.copy( + error = "Could not load messages", + screenState = ChatScreenState.ACTIVE, + ) + } + messageRepository.observeMessages(conversationId).collect { messages -> + val state = _uiState.value + val displayMessages = buildActiveMessagePath(messages, state.activeBranches) + _uiState.value = state.copy( + messages = messages, + displayMessages = displayMessages, + screenState = ChatScreenState.ACTIVE, + ) + } + } + } + + /** + * Restores a previously saved draft for the given key (conversation ID or [NEW_CHAT_DRAFT_KEY]). + */ + private fun restoreDraft(draftKey: String) { + viewModelScope.launch { + val draft = draftRepository.getDraft(draftKey) + if (!draft.isNullOrBlank() && _uiState.value.inputText.isBlank()) { + _uiState.value = _uiState.value.copy(inputText = draft) + } + } + } + + private fun loadConversationModel(conversationId: String) { + viewModelScope.launch { + val result = conversationRepository.getConversation(conversationId) + val conversation = result.getOrNull() + if (conversation != null) { + val endpoint = conversation.endpoint?.toSerialName() + val model = conversation.model + val isAgentConversation = endpoint == EModelEndpoint.AGENTS.toSerialName() + val resolvedModel = if (isAgentConversation) { + conversation.agentId ?: model + } else { + model + } + _uiState.value = _uiState.value.copy( + selectedEndpoint = if (endpoint != null && (resolvedModel != null || isAgentConversation)) endpoint else _uiState.value.selectedEndpoint, + selectedModel = if (endpoint != null && resolvedModel != null) resolvedModel else _uiState.value.selectedModel, + conversationTitle = conversation.title, + ) + } + modelDelegate.conversationModelLoaded = true + modelDelegate.refilterModels(isNewConversation) + } + } + + private fun refreshConversationTitle(conversationId: String) { + viewModelScope.launch { + val result = conversationRepository.getConversation(conversationId) + val conversation = result.getOrNull() ?: return@launch + _uiState.value = _uiState.value.copy( + conversationTitle = conversation.title, + ) + } + } + + private fun generateAndSetTitle(conversationId: String) { + viewModelScope.launch { + when (val result = conversationRepository.generateTitle(conversationId)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + conversationTitle = result.data, + ) + } + is Result.Error -> { + Timber.d("Title generation failed for $conversationId: ${result.message}") + refreshConversationTitle(conversationId) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun rebuildDisplayMessages() { + val state = _uiState.value + val displayMessages = buildActiveMessagePath(state.messages, state.activeBranches) + _uiState.value = state.copy(displayMessages = displayMessages) + } + + fun switchBranch(parentMessageId: String, siblingIndex: Int) { + val state = _uiState.value + val newBranches = state.activeBranches.toMutableMap() + newBranches[parentMessageId] = siblingIndex + _uiState.value = state.copy(activeBranches = newBranches) + rebuildDisplayMessages() + } + + fun onInputChanged(text: String) { + _uiState.value = _uiState.value.copy(inputText = text) + val draftKey = _uiState.value.conversationId ?: NEW_CHAT_DRAFT_KEY + viewModelScope.launch { + draftRepository.saveDraft(draftKey, text) + } + } + + private fun consumeShareIntent() { + val shareData = ShareIntentConsumer.consume() ?: return + Timber.d("consumeShareIntent: text=%s, files=%d", shareData.text != null, shareData.fileUris.size) + + if (!shareData.text.isNullOrBlank()) { + _uiState.value = _uiState.value.copy(inputText = shareData.text) + } + + if (shareData.fileUris.isNotEmpty()) { + fileDelegate.onFilesSelected(shareData.fileUris) + } + } + + // --- Message sending --- + + fun sendMessage() { + val text = _uiState.value.inputText.trim() + if (_uiState.value.isStreaming) return + + // Prevent double-send while waiting for uploads to finish + if (fileDelegate.pendingUploadSendJob?.isActive == true) return + + // Check if there are files still uploading. + if (fileDelegate.hasPendingUploads()) { + Timber.d("sendMessage: waiting for pending upload(s) to complete") + fileDelegate.pendingUploadSendJob = viewModelScope.launch { + fileDelegate.waitForUploadsAndSend(text) { doSendMessage(it) } + } + return + } + + doSendMessage(text) + } + + /** + * Builds an [EphemeralAgent] from the current UI state (selected MCP servers + * and enabled tools). Returns null when there is nothing to send. + */ + private fun buildEphemeralAgent(): EphemeralAgent? { + val state = _uiState.value + val mcpServers = state.selectedMcpServerNames.toList().ifEmpty { null } + val enabledTools = state.enabledTools + val webSearchEnabled = state.modelParameters.webSearch + + val hasAnything = mcpServers != null || enabledTools.isNotEmpty() || webSearchEnabled + if (!hasAnything) return null + + return EphemeralAgent( + mcp = mcpServers, + webSearch = if (webSearchEnabled) true else null, + fileSearch = if (ToolConstants.FILE_SEARCH in enabledTools) true else null, + executeCode = if (ToolConstants.CODE_INTERPRETER in enabledTools || ToolConstants.EXECUTE_CODE in enabledTools) true else null, + ) + } + + private fun doSendMessage(text: String) { + val fileRefs = fileDelegate.buildFileReferences() + val hasFiles = fileRefs.isNotEmpty() + if ((text.isBlank() && !hasFiles) || _uiState.value.isStreaming) return + + val conversationId = _uiState.value.conversationId + val lastMessageId = _uiState.value.displayMessages.lastOrNull()?.message?.messageId + + // Add optimistic user message to display immediately + val messageText = text.ifBlank { + if (hasFiles) "" else return + } + val optimisticMessage = com.librechat.android.core.model.Message( + messageId = UUID.randomUUID().toString(), + conversationId = conversationId ?: "", + parentMessageId = lastMessageId, + text = messageText, + isCreatedByUser = true, + sender = "User", + createdAt = java.time.Instant.now().toString(), + files = fileRefs.takeIf { it.isNotEmpty() }, + ) + val updatedMessages = _uiState.value.messages + optimisticMessage + val updatedDisplay = buildActiveMessagePath(updatedMessages, _uiState.value.activeBranches) + + isEditOrRegenerate = false + + val isNewChat = conversationId == null + _uiState.value = _uiState.value.copy( + inputText = "", + isStreaming = true, + streamingContent = "", + activeToolCalls = emptyList(), + streamingAttachments = emptyList(), + screenState = if (isNewChat) ChatScreenState.LANDING else ChatScreenState.ACTIVE, + error = null, + messages = updatedMessages, + displayMessages = updatedDisplay, + ) + // Clear draft + val draftKey = conversationId ?: NEW_CHAT_DRAFT_KEY + viewModelScope.launch { draftRepository.deleteDraft(draftKey) } + // Clear attached files + fileDelegate.clearAttachedFiles() + streamingBuffer.clear() + streamingBufferDirty = false + startStreamingUpdater() + + val isAgent = _uiState.value.selectedEndpoint == EndpointConstants.AGENTS + val webSearchEnabled = _uiState.value.modelParameters.webSearch + val ephemeralAgent = buildEphemeralAgent() + Timber.d("sendMessage: webSearch=%s, endpoint=%s, model=%s, files=%d, ephemeralAgent=%s", webSearchEnabled, _uiState.value.selectedEndpoint, _uiState.value.selectedModel, fileRefs.size, ephemeralAgent) + + // Build addedConvo if comparison mode is enabled + val addedConvo = if (_uiState.value.comparisonState.isEnabled) { + modelDelegate.buildAddedConvo(parentMessageId = lastMessageId) + } else null + + // Resolve effective endpoint/agentId for comparison mode. + // All requests go through api/agents/chat/{endpoint} — the server's + // middleware creates ephemeral agents for non-agent endpoints, so no + // swapping is needed. Just keep the primary's original endpoint. + val effectiveEndpoint = _uiState.value.selectedEndpoint + val effectiveAgentId = if (isAgent) _uiState.value.selectedModel else null + val effectiveAddedConvo = addedConvo // null when no comparison + if (addedConvo != null) { + Timber.d("[Comparison] endpoint=%s, agentId=%s, addedConvo.endpoint=%s, addedConvo.agentId=%s", + effectiveEndpoint, effectiveAgentId, addedConvo.endpoint, addedConvo.agentId) + } + + // If comparison enabled, prepare comparison streaming state + if (addedConvo != null) { + modelDelegate.primaryComparisonBuffer.clear() + modelDelegate.secondaryComparisonBuffer.clear() + _uiState.update { + it.copy(comparisonState = it.comparisonState.copy( + primaryIsStreaming = true, + secondaryIsStreaming = true, + primaryStreamingContent = "", + secondaryStreamingContent = "", + primaryActiveToolCalls = emptyList(), + secondaryActiveToolCalls = emptyList(), + primaryAgentId = null, + secondaryAgentId = null, + parallelMessageId = null, + primaryFinalContent = null, + secondaryFinalContent = null, + )) + } + } + + streamJob?.cancel() + streamJob = viewModelScope.launch { + collectStreamSafely( + chatRepository.startChat( + text = messageText, + conversationId = conversationId, + endpoint = effectiveEndpoint, + model = _uiState.value.selectedModel, + parentMessageId = lastMessageId, + agentId = effectiveAgentId, + webSearch = webSearchEnabled, + files = fileRefs.takeIf { it.isNotEmpty() }, + addedConvo = effectiveAddedConvo, + ephemeralAgent = ephemeralAgent, + ), + ) + // Safety net: if the flow ends without Final or Error, clear streaming + if (_uiState.value.isStreaming) { + val cid = _uiState.value.conversationId + if (cid != null && roomObserverJob?.isActive != true) { + loadConversation(cid) + } else if (cid == null) { + _uiState.value = _uiState.value.copy(isStreaming = false) + } + } + } + } + + /** + * Resets streaming-related UI state and clears the buffer in preparation + * for a new stream (edit, regenerate, or continue). + */ + private fun prepareForStreaming() { + _uiState.value = _uiState.value.copy( + isStreaming = true, + streamingContent = "", + activeToolCalls = emptyList(), + streamingAttachments = emptyList(), + error = null, + ) + streamingBuffer.clear() + streamingBufferDirty = false + startStreamingUpdater() + } + + /** + * Launches a periodic coroutine that flushes the [streamingBuffer] to UI state + * at most every [STREAMING_UI_UPDATE_INTERVAL_MS] ms. This avoids recomposition spam + * from high-frequency SSE chunks (each chunk would otherwise trigger a full state copy). + */ + private fun startStreamingUpdater() { + streamingUpdateJob?.cancel() + streamingUpdateJob = viewModelScope.launch { + while (isActive) { + delay(STREAMING_UI_UPDATE_INTERVAL_MS) + flushStreamingBuffer() + } + } + } + + /** + * Flushes the streaming buffer to UI state if it has been modified since the last flush. + * Called both periodically (by the updater) and immediately on stream completion/error. + */ + private fun flushStreamingBuffer() { + if (!streamingBufferDirty) return + streamingBufferDirty = false + _uiState.value = _uiState.value.copy( + streamingContent = streamingBuffer.toString(), + ) + } + + /** + * Stops the periodic streaming updater and performs a final flush so the last + * chunk is never lost. + */ + private fun stopStreamingUpdater() { + streamingUpdateJob?.cancel() + streamingUpdateJob = null + flushStreamingBuffer() + } + + fun editMessage(messageId: String, newText: String) { + if (newText.isBlank() || _uiState.value.isStreaming) return + + val originalMessage = _uiState.value.messages.find { it.messageId == messageId } ?: return + + if (originalMessage.isCreatedByUser) { + editUserMessage(originalMessage, newText) + } else { + editAiMessage(originalMessage, newText) + } + } + + private fun editUserMessage(originalMessage: com.librechat.android.core.model.Message, newText: String) { + val parentMessageId = originalMessage.parentMessageId + + isEditOrRegenerate = true + prepareForStreaming() + + val isAgent = _uiState.value.selectedEndpoint == EndpointConstants.AGENTS + val webSearchEnabled = _uiState.value.modelParameters.webSearch + val ephemeralAgent = buildEphemeralAgent() + Timber.d("editUserMessage: webSearch=%s, ephemeralAgent=%s", webSearchEnabled, ephemeralAgent) + streamJob?.cancel() + streamJob = viewModelScope.launch { + collectStreamSafely( + chatRepository.startChat( + text = newText, + conversationId = _uiState.value.conversationId, + endpoint = _uiState.value.selectedEndpoint, + model = _uiState.value.selectedModel, + parentMessageId = parentMessageId, + agentId = if (isAgent) _uiState.value.selectedModel else null, + isEdited = true, + webSearch = webSearchEnabled, + ephemeralAgent = ephemeralAgent, + ), + ) + } + } + + private fun editAiMessage(aiMessage: com.librechat.android.core.model.Message, newText: String) { + val parentUserMessage = _uiState.value.messages.find { + it.messageId == aiMessage.parentMessageId + } ?: return + + val conversationId = _uiState.value.conversationId ?: return + + isEditOrRegenerate = true + prepareForStreaming() + + val isAgent = _uiState.value.selectedEndpoint == EndpointConstants.AGENTS + val webSearchEnabled = _uiState.value.modelParameters.webSearch + val ephemeralAgent = buildEphemeralAgent() + Timber.d("editAiMessage: webSearch=%s, ephemeralAgent=%s", webSearchEnabled, ephemeralAgent) + streamJob?.cancel() + streamJob = viewModelScope.launch { + messageRepository.updateMessageText(conversationId, aiMessage.messageId, newText) + collectStreamSafely( + chatRepository.startChat( + text = parentUserMessage.text, + conversationId = conversationId, + endpoint = _uiState.value.selectedEndpoint, + model = _uiState.value.selectedModel, + parentMessageId = parentUserMessage.parentMessageId, + agentId = if (isAgent) _uiState.value.selectedModel else null, + overrideParentMessageId = parentUserMessage.messageId, + isEdited = true, + isRegenerate = true, + webSearch = webSearchEnabled, + ephemeralAgent = ephemeralAgent, + ), + ) + } + } + + fun regenerateMessage(messageId: String) { + if (_uiState.value.isStreaming) return + + val aiMessage = _uiState.value.messages.find { it.messageId == messageId } ?: return + if (aiMessage.isCreatedByUser) return + + val parentUserMessage = _uiState.value.messages.find { + it.messageId == aiMessage.parentMessageId + } ?: return + + isEditOrRegenerate = true + prepareForStreaming() + + val isAgentRegen = _uiState.value.selectedEndpoint == EndpointConstants.AGENTS + val webSearchEnabled = _uiState.value.modelParameters.webSearch + val ephemeralAgent = buildEphemeralAgent() + Timber.d("regenerateMessage: webSearch=%s, ephemeralAgent=%s", webSearchEnabled, ephemeralAgent) + streamJob?.cancel() + streamJob = viewModelScope.launch { + collectStreamSafely( + chatRepository.startChat( + text = parentUserMessage.text, + conversationId = _uiState.value.conversationId, + endpoint = _uiState.value.selectedEndpoint, + model = _uiState.value.selectedModel, + parentMessageId = parentUserMessage.parentMessageId, + agentId = if (isAgentRegen) _uiState.value.selectedModel else null, + overrideParentMessageId = parentUserMessage.messageId, + isRegenerate = true, + webSearch = webSearchEnabled, + ephemeralAgent = ephemeralAgent, + ), + ) + } + } + + fun getMessageText(messageId: String): String { + val message = _uiState.value.messages.find { it.messageId == messageId } ?: return "" + val contentParts = message.content + if (!contentParts.isNullOrEmpty()) { + return contentParts.mapNotNull { part -> + part.text ?: part.think + }.joinToString("") + } + return message.text + } + + private suspend fun collectStreamSafely(stream: Flow) { + try { + stream.collect { event -> handleStreamEvent(event) } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e // Never swallow cancellation + } catch (e: Exception) { + Timber.e(e, "Stream collection failed") + stopStreamingUpdater() + streamingBuffer.clear() + streamingBufferDirty = false + _uiState.value = _uiState.value.copy( + isStreaming = false, + streamingContent = "", + activeToolCalls = emptyList(), + streamingAttachments = emptyList(), + error = e.message ?: "Chat request failed", + ) + } + } + + private fun handleStreamEvent(event: StreamEvent) { + when (event) { + is StreamEvent.Created -> { + if (isNewConversation && event.conversationId != null) { + viewModelScope.launch { + val existingDraft = draftRepository.getDraft(NEW_CHAT_DRAFT_KEY) + if (existingDraft != null) { + draftRepository.saveDraft(event.conversationId, existingDraft) + draftRepository.deleteDraft(NEW_CHAT_DRAFT_KEY) + } + } + } + _uiState.value = _uiState.value.copy( + conversationId = event.conversationId, + ) + if (isNewConversation && event.conversationId != null && + _uiState.value.pendingNavigationConversationId == null && + !_uiState.value.comparisonState.isEnabled + ) { + _uiState.value = _uiState.value.copy( + pendingNavigationConversationId = event.conversationId, + ) + } + } + is StreamEvent.ContentDelta -> { + val isComparison = _uiState.value.comparisonState.isEnabled + Timber.d("[Comparison] ContentDelta: agentId=%s, groupId=%s, isComparison=%s, buffer=%s", + event.agentId, event.groupId, isComparison, + if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) "secondary" else "primary") + if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) { + modelDelegate.secondaryComparisonBuffer.append(event.chunk) + _uiState.update { + it.copy(comparisonState = it.comparisonState.copy( + secondaryStreamingContent = modelDelegate.secondaryComparisonBuffer.toString(), + secondaryIsStreaming = true, + secondaryAgentId = it.comparisonState.secondaryAgentId ?: event.agentId, + )) + } + } else if (isComparison) { + modelDelegate.primaryComparisonBuffer.append(event.chunk) + _uiState.update { + it.copy( + comparisonState = it.comparisonState.copy( + primaryStreamingContent = modelDelegate.primaryComparisonBuffer.toString(), + primaryIsStreaming = true, + primaryAgentId = it.comparisonState.primaryAgentId ?: event.agentId, + ), + streamingContent = modelDelegate.primaryComparisonBuffer.toString(), + ) + } + } else { + streamingBuffer.append(event.chunk) + streamingBufferDirty = true + } + } + is StreamEvent.ThinkingDelta -> { + val isComparison = _uiState.value.comparisonState.isEnabled + Timber.d("[Comparison] ThinkingDelta: agentId=%s, groupId=%s, isComparison=%s, buffer=%s", + event.agentId, event.groupId, isComparison, + if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) "secondary" else "primary") + if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) { + modelDelegate.secondaryComparisonBuffer.append(event.chunk) + _uiState.update { + it.copy(comparisonState = it.comparisonState.copy( + secondaryStreamingContent = modelDelegate.secondaryComparisonBuffer.toString(), + secondaryIsStreaming = true, + secondaryAgentId = it.comparisonState.secondaryAgentId ?: event.agentId, + )) + } + } else if (isComparison) { + modelDelegate.primaryComparisonBuffer.append(event.chunk) + _uiState.update { + it.copy( + comparisonState = it.comparisonState.copy( + primaryStreamingContent = modelDelegate.primaryComparisonBuffer.toString(), + primaryIsStreaming = true, + primaryAgentId = it.comparisonState.primaryAgentId ?: event.agentId, + ), + streamingContent = modelDelegate.primaryComparisonBuffer.toString(), + ) + } + } else { + streamingBuffer.append(event.chunk) + streamingBufferDirty = true + } + } + is StreamEvent.Final -> { + stopStreamingUpdater() + val isComparison = _uiState.value.comparisonState.isEnabled + Timber.d("[Comparison] Final: isComparison=%s, parallelMessageId=%s, primaryBuf=%d, secondaryBuf=%d, primaryAgentId=%s, secondaryAgentId=%s", + isComparison, + (event.responseMessage ?: event.message)?.messageId, + modelDelegate.primaryComparisonBuffer.length, + modelDelegate.secondaryComparisonBuffer.length, + _uiState.value.comparisonState.primaryAgentId, + _uiState.value.comparisonState.secondaryAgentId) + val conversationId = _uiState.value.conversationId + ?: event.conversation?.conversationId + val completedResponseText = if (isComparison) { + modelDelegate.primaryComparisonBuffer.toString() + } else { + streamingBuffer.toString() + } + val shouldAutoRead = !isEditOrRegenerate + if (isComparison) { + val responseMessage = event.responseMessage ?: event.message + val primaryContent = modelDelegate.primaryComparisonBuffer.toString() + val secondaryContent = modelDelegate.secondaryComparisonBuffer.toString() + _uiState.update { + it.copy( + isStreaming = false, + streamingContent = "", + activeToolCalls = emptyList(), + streamingAttachments = emptyList(), + conversationId = conversationId ?: it.conversationId, + comparisonState = it.comparisonState.copy( + primaryIsStreaming = false, + secondaryIsStreaming = false, + primaryStreamingContent = "", + secondaryStreamingContent = "", + primaryActiveToolCalls = emptyList(), + secondaryActiveToolCalls = emptyList(), + parallelMessageId = responseMessage?.messageId, + primaryFinalContent = primaryContent, + secondaryFinalContent = secondaryContent, + ), + ) + } + } else { + _uiState.value = _uiState.value.copy( + isStreaming = false, + streamingContent = "", + activeToolCalls = emptyList(), + streamingAttachments = emptyList(), + conversationId = conversationId ?: _uiState.value.conversationId, + ) + } + val finalConversation = event.conversation + if (finalConversation?.conversationId != null) { + viewModelScope.launch { + conversationRepository.saveConversation(finalConversation) + } + } + if (conversationId != null) { + loadConversation(conversationId) + val currentTitle = _uiState.value.conversationTitle + val needsTitle = currentTitle.isNullOrBlank() || currentTitle == "New Chat" + if (isNewConversation && needsTitle && !titleGenerationRequested) { + titleGenerationRequested = true + generateAndSetTitle(conversationId) + } else { + refreshConversationTitle(conversationId) + } + } + if (shouldAutoRead && completedResponseText.isNotBlank()) { + ttsDelegate.maybeAutoReadResponse(completedResponseText) + } + } + is StreamEvent.Error -> { + stopStreamingUpdater() + streamingBuffer.clear() + streamingBufferDirty = false + _uiState.value = _uiState.value.copy( + isStreaming = false, + streamingContent = "", + error = event.message, + activeToolCalls = emptyList(), + streamingAttachments = emptyList(), + comparisonState = _uiState.value.comparisonState.copy( + primaryIsStreaming = false, + secondaryIsStreaming = false, + primaryActiveToolCalls = emptyList(), + secondaryActiveToolCalls = emptyList(), + ), + ) + } + is StreamEvent.ToolCallStart -> { + val newToolCall = ActiveToolCall( + id = event.toolCallId, + name = event.toolName, + ) + val isComparison = _uiState.value.comparisonState.isEnabled + if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) { + _uiState.update { + it.copy(comparisonState = it.comparisonState.copy( + secondaryActiveToolCalls = it.comparisonState.secondaryActiveToolCalls + newToolCall, + )) + } + } else if (isComparison) { + _uiState.update { + it.copy(comparisonState = it.comparisonState.copy( + primaryActiveToolCalls = it.comparisonState.primaryActiveToolCalls + newToolCall, + )) + } + } else { + _uiState.value = _uiState.value.copy( + activeToolCalls = _uiState.value.activeToolCalls + newToolCall, + ) + } + } + is StreamEvent.ToolCallComplete -> { + val isComparison = _uiState.value.comparisonState.isEnabled + if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) { + _uiState.update { state -> + val updated = state.comparisonState.secondaryActiveToolCalls.map { tc -> + if (tc.id == event.toolCallId) tc.copy(isComplete = true, output = event.output) else tc + } + state.copy(comparisonState = state.comparisonState.copy(secondaryActiveToolCalls = updated)) + } + } else if (isComparison) { + _uiState.update { state -> + val updated = state.comparisonState.primaryActiveToolCalls.map { tc -> + if (tc.id == event.toolCallId) tc.copy(isComplete = true, output = event.output) else tc + } + state.copy(comparisonState = state.comparisonState.copy(primaryActiveToolCalls = updated)) + } + } else { + val updated = _uiState.value.activeToolCalls.map { tc -> + if (tc.id == event.toolCallId) { + tc.copy(isComplete = true, output = event.output) + } else { + tc + } + } + _uiState.value = _uiState.value.copy(activeToolCalls = updated) + } + } + is StreamEvent.AttachmentCreated -> { + val attachment = com.librechat.android.core.model.Attachment( + fileId = event.fileId, + filename = event.filename, + filepath = event.filepath, + type = event.type, + toolCallId = event.toolCallId, + width = event.width, + height = event.height, + ) + _uiState.value = _uiState.value.copy( + streamingAttachments = _uiState.value.streamingAttachments + attachment, + ) + } + is StreamEvent.Sync, + is StreamEvent.Step, + -> { /* no-op */ } + } + } + + fun stopGeneration() { + val conversationId = _uiState.value.conversationId ?: return + streamJob?.cancel() + stopStreamingUpdater() + streamingBuffer.clear() + streamingBufferDirty = false + viewModelScope.launch { + val abortResult = chatRepository.abortChat(conversationId) + if (abortResult is Result.Error) { + Timber.w(abortResult.exception, "Failed to abort chat: %s", abortResult.message) + } + // Clean up comparison state if active + val isComparison = _uiState.value.comparisonState.isEnabled + _uiState.value = _uiState.value.copy( + isStreaming = false, + streamingContent = "", + activeToolCalls = emptyList(), + streamingAttachments = emptyList(), + comparisonState = if (isComparison) { + _uiState.value.comparisonState.copy( + primaryIsStreaming = false, + secondaryIsStreaming = false, + primaryStreamingContent = "", + secondaryStreamingContent = "", + primaryActiveToolCalls = emptyList(), + secondaryActiveToolCalls = emptyList(), + ) + } else { + _uiState.value.comparisonState + }, + ) + // Refresh messages from server so the message tree reflects + // the partially-streamed response that was aborted. + loadConversation(conversationId) + } + } + + fun continueGeneration() { + if (_uiState.value.isStreaming) return + val lastAiMessage = _uiState.value.displayMessages.lastOrNull { + !it.message.isCreatedByUser + } ?: return + + val parentUserMessage = _uiState.value.messages.find { + it.messageId == lastAiMessage.message.parentMessageId + } ?: return + + isEditOrRegenerate = true + prepareForStreaming() + + val isAgentContinue = _uiState.value.selectedEndpoint == EndpointConstants.AGENTS + val webSearchEnabled = _uiState.value.modelParameters.webSearch + val ephemeralAgent = buildEphemeralAgent() + Timber.d("continueGeneration: webSearch=%s, ephemeralAgent=%s", webSearchEnabled, ephemeralAgent) + streamJob?.cancel() + streamJob = viewModelScope.launch { + collectStreamSafely( + chatRepository.startChat( + text = parentUserMessage.text, + conversationId = _uiState.value.conversationId, + endpoint = _uiState.value.selectedEndpoint, + model = _uiState.value.selectedModel, + parentMessageId = parentUserMessage.parentMessageId, + agentId = if (isAgentContinue) _uiState.value.selectedModel else null, + overrideParentMessageId = parentUserMessage.messageId, + responseMessageId = lastAiMessage.message.messageId, + isEdited = true, + isRegenerate = true, + isContinued = true, + webSearch = webSearchEnabled, + ephemeralAgent = ephemeralAgent, + ), + ) + } + } + + fun onPause() { + wasStreaming = _uiState.value.isStreaming + if (wasStreaming) { + streamJob?.cancel() + stopStreamingUpdater() + } + } + + fun onResume() { + if (!wasStreaming) return + wasStreaming = false + + val conversationId = _uiState.value.conversationId ?: return + + viewModelScope.launch { + try { + val status = chatRepository.checkStreamStatus(conversationId) + if (status.active) { + _uiState.value = _uiState.value.copy(isStreaming = true) + streamingBuffer.clear() + streamingBufferDirty = false + startStreamingUpdater() + streamJob?.cancel() + streamJob = viewModelScope.launch { + collectStreamSafely(chatRepository.resumeStream(conversationId)) + } + } else { + _uiState.value = _uiState.value.copy( + isStreaming = false, + streamingContent = "", + ) + loadConversation(conversationId) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isStreaming = false, + streamingContent = "", + error = "Could not resume stream", + ) + } + } + } + + private fun resumeActiveStreamIfNeeded(conversationId: String) { + viewModelScope.launch { + try { + val status = chatRepository.checkStreamStatus(conversationId) + if (status.active) { + _uiState.value = _uiState.value.copy( + isStreaming = true, + screenState = ChatScreenState.ACTIVE, + ) + streamingBuffer.clear() + streamingBufferDirty = false + startStreamingUpdater() + streamJob?.cancel() + streamJob = viewModelScope.launch { + collectStreamSafely(chatRepository.resumeStream(conversationId)) + } + } + } catch (e: Exception) { + Timber.d(e, "No active stream to resume for $conversationId") + } + } + } + + fun submitFeedback(messageId: String, rating: String?) { + val conversationId = _uiState.value.conversationId ?: return + viewModelScope.launch { + messageRepository.updateFeedback(conversationId, messageId, rating) + } + } + + fun startEditing(messageId: String) { + val text = getMessageText(messageId) + _uiState.value = _uiState.value.copy( + editingMessageId = messageId, + editingText = text, + ) + } + + fun onEditTextChanged(text: String) { + _uiState.value = _uiState.value.copy(editingText = text) + } + + fun cancelEditing() { + _uiState.value = _uiState.value.copy(editingMessageId = null, editingText = "") + } + + fun submitEdit() { + val messageId = _uiState.value.editingMessageId ?: return + val newText = _uiState.value.editingText.trim() + if (newText.isBlank()) return + + _uiState.value = _uiState.value.copy(editingMessageId = null, editingText = "") + editMessage(messageId, newText) + } + + fun saveEditOnly() { + val messageId = _uiState.value.editingMessageId ?: return + val conversationId = _uiState.value.conversationId ?: return + val newText = _uiState.value.editingText.trim() + if (newText.isBlank()) return + + _uiState.value = _uiState.value.copy(editingMessageId = null, editingText = "") + viewModelScope.launch { + messageRepository.updateMessageText(conversationId, messageId, newText) + } + } + + fun onPendingNavigationHandled() { + streamJob?.cancel() + streamJob = null + stopStreamingUpdater() + roomObserverJob?.cancel() + roomObserverJob = null + val current = _uiState.value + _uiState.value = ChatUiState( + selectedEndpoint = current.selectedEndpoint, + selectedModel = current.selectedModel, + availableModels = current.availableModels, + endpointConfigs = current.endpointConfigs, + agents = current.agents, + presets = current.presets, + availablePrompts = current.availablePrompts, + mcpServers = current.mcpServers, + selectedMcpServerNames = current.selectedMcpServerNames, + enabledTools = current.enabledTools, + serverSttEnabled = current.serverSttEnabled, + userName = current.userName, + userAvatarUrl = current.userAvatarUrl, + sharedLinksEnabled = current.sharedLinksEnabled, + ) + streamingBuffer.clear() + } + + fun toggleTemporaryChat() { + _uiState.value = _uiState.value.copy( + isTemporaryChat = !_uiState.value.isTemporaryChat, + ) + } + + fun refreshMessages() { + val conversationId = _uiState.value.conversationId ?: return + if (_uiState.value.isRefreshingMessages) return + _uiState.value = _uiState.value.copy(isRefreshingMessages = true) + viewModelScope.launch { + messageRepository.refreshMessages(conversationId) + loadConversation(conversationId) + _uiState.value = _uiState.value.copy(isRefreshingMessages = false) + } + } + + private fun loadSharedLinksEnabled() { + viewModelScope.launch { + configRepository.startupConfig.collect { config -> + _uiState.value = _uiState.value.copy( + sharedLinksEnabled = config?.sharedLinksEnabled ?: false, + ) + } + } + } + + private fun loadUserProfile() { + viewModelScope.launch { + when (val result = userRepository.getUser()) { + is Result.Success -> { + val user = result.data + _uiState.value = _uiState.value.copy( + userName = user.name ?: user.username, + userAvatarUrl = user.avatar, + ) + } + is Result.Error -> { + Log.d("ChatViewModel", "Failed to load user profile: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } + + override fun onCleared() { + super.onCleared() + voiceDelegate.release() + ttsDelegate.release() + } + + // ── Delegated public API ──────────────────────────────────────── + + // Search + fun openSearch() = searchDelegate.openSearch() + fun closeSearch() = searchDelegate.closeSearch() + fun onSearchQueryChanged(query: String) = searchDelegate.onSearchQueryChanged(query) + fun nextSearchMatch() = searchDelegate.nextSearchMatch() + fun previousSearchMatch() = searchDelegate.previousSearchMatch() + fun onSearchScrollHandled() = searchDelegate.onSearchScrollHandled() + + // Conversation actions + fun showRenameDialog() = conversationActionsDelegate.showRenameDialog() + fun dismissRenameDialog() = conversationActionsDelegate.dismissRenameDialog() + fun renameConversation(newTitle: String) = conversationActionsDelegate.renameConversation(newTitle) + fun showDeleteConfirmation() = conversationActionsDelegate.showDeleteConfirmation() + fun dismissDeleteConfirmation() = conversationActionsDelegate.dismissDeleteConfirmation() + fun deleteConversation() = conversationActionsDelegate.deleteConversation() + fun archiveConversation() = conversationActionsDelegate.archiveConversation() + fun duplicateConversation() = conversationActionsDelegate.duplicateConversation() + fun onDuplicatedConversationHandled() = conversationActionsDelegate.onDuplicatedConversationHandled() + fun shareConversation() = conversationActionsDelegate.shareConversation() + fun onShareLinkHandled() = conversationActionsDelegate.onShareLinkHandled() + fun showForkOptions(messageId: String) = conversationActionsDelegate.showForkOptions(messageId) + fun dismissForkOptions() = conversationActionsDelegate.dismissForkOptions() + fun forkFromMessage(messageId: String, option: String, splitAtTarget: Boolean = false) = + conversationActionsDelegate.forkFromMessage(messageId, option, splitAtTarget) + fun onForkedConversationHandled() = conversationActionsDelegate.onForkedConversationHandled() + + // TTS + fun readAloud(messageId: String) = ttsDelegate.readAloud(messageId) + fun stopReading() = ttsDelegate.stopReading() + + // Voice input + fun startRecording() = voiceDelegate.startRecording() + fun stopRecording() = voiceDelegate.stopRecording() + fun cancelRecording() = voiceDelegate.cancelRecording() + fun onDeviceSpeechResult(transcribedText: String) = voiceDelegate.onDeviceSpeechResult(transcribedText) + + // File attachments + fun onFilesSelected(uris: List) = fileDelegate.onFilesSelected(uris) + fun removeFile(file: AttachedFile) = fileDelegate.removeFile(file) + fun retryUpload(file: AttachedFile) = fileDelegate.retryUpload(file) + + // Presets and prompts + fun savePreset(name: String) = presetPromptDelegate.savePreset(name) + fun loadPreset(displayData: PresetDisplayData) = presetPromptDelegate.loadPreset(displayData) + fun deletePreset(presetId: String) = presetPromptDelegate.deletePreset(presetId) + fun editPreset(preset: Preset) = presetPromptDelegate.editPreset(preset) + fun handlePromptMention(displayData: PromptMentionDisplayData) = presetPromptDelegate.handlePromptMention(displayData) + fun handleSlashCommand(displayData: PromptMentionDisplayData) = presetPromptDelegate.handleSlashCommand(displayData) + + // Model selection and comparison + fun onModelSelected(endpoint: String, model: String) = modelDelegate.onModelSelected(endpoint, model) + fun toggleComparison() = modelDelegate.toggleComparison() + fun setSecondaryModel(endpoint: String, model: String) = modelDelegate.setSecondaryModel(endpoint, model) + fun getSecondaryModelDisplayName(): String? = modelDelegate.getSecondaryModelDisplayName() + fun toggleMcpServer(serverName: String) = modelDelegate.toggleMcpServer(serverName) + fun toggleTool(toolName: String) = modelDelegate.toggleTool(toolName) + fun showModelParameters() = modelDelegate.showModelParameters() + fun hideModelParameters() = modelDelegate.hideModelParameters() + fun updateModelParameters(parameters: ModelParameters) = modelDelegate.updateModelParameters(parameters) + + fun branchFromComparison(agentId: String) { + val messageId = _uiState.value.comparisonState.parallelMessageId ?: return + val conversationId = _uiState.value.conversationId ?: return + viewModelScope.launch { + try { + messageRepository.branchMessage( + conversationId = conversationId, + messageId = messageId, + agentId = agentId, + ) + // Disable comparison and continue with the branched response + _uiState.update { + it.copy(comparisonState = ComparisonState()) + } + // Trigger deferred navigation for new chats that skipped navigation during comparison + val cid = _uiState.value.conversationId + if (cid != null && _uiState.value.pendingNavigationConversationId == null) { + _uiState.update { it.copy(pendingNavigationConversationId = cid) } + } + // Refresh messages to show the branched message + loadConversation(conversationId) + } catch (e: Exception) { + Timber.e(e, "Failed to branch comparison message") + _uiState.update { + it.copy(error = "Failed to continue with selected response") + } + } + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ComparisonState.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ComparisonState.kt new file mode 100644 index 0000000..72e41a3 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ComparisonState.kt @@ -0,0 +1,26 @@ +package com.librechat.android.feature.chat.viewmodel + +import androidx.compose.runtime.Immutable + +@Immutable +data class ComparisonState( + val isEnabled: Boolean = false, + // Secondary agent/model selection (set by user via model selector) + val secondaryEndpoint: String? = null, + val secondaryModel: String? = null, + // Resolved agent IDs from SSE events (set during streaming) + val primaryAgentId: String? = null, + val secondaryAgentId: String? = null, + // Per-agent streaming content + val primaryStreamingContent: String = "", + val secondaryStreamingContent: String = "", + val primaryIsStreaming: Boolean = false, + val secondaryIsStreaming: Boolean = false, + val primaryActiveToolCalls: List = emptyList(), + val secondaryActiveToolCalls: List = emptyList(), + // The message containing parallel content (for branching) + val parallelMessageId: String? = null, + // Captured final content from streaming buffers (server may not preserve per-agent content) + val primaryFinalContent: String? = null, + val secondaryFinalContent: String? = null, +) diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ConversationActionsDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ConversationActionsDelegate.kt new file mode 100644 index 0000000..3a89860 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ConversationActionsDelegate.kt @@ -0,0 +1,177 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ConversationActionsDelegate( + private val stateHandle: ChatStateHandle, + private val conversationRepository: ConversationRepository, + private val shareRepository: ShareRepository, +) { + + private val _shareLinkUrl = MutableStateFlow(null) + val shareLinkUrl: StateFlow = _shareLinkUrl.asStateFlow() + + fun showRenameDialog() { + stateHandle.update { copy(showRenameDialog = true) } + } + + fun dismissRenameDialog() { + stateHandle.update { copy(showRenameDialog = false) } + } + + fun renameConversation(newTitle: String) { + val conversationId = stateHandle.state.conversationId ?: return + stateHandle.update { copy(showRenameDialog = false) } + stateHandle.scope.launch { + when (conversationRepository.updateTitle(conversationId, newTitle)) { + is Result.Success -> { + stateHandle.update { copy(conversationTitle = newTitle) } + } + is Result.Error -> { + stateHandle.update { copy(error = "Failed to rename conversation") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun showDeleteConfirmation() { + stateHandle.update { copy(showDeleteConfirmation = true) } + } + + fun dismissDeleteConfirmation() { + stateHandle.update { copy(showDeleteConfirmation = false) } + } + + fun deleteConversation() { + val conversationId = stateHandle.state.conversationId ?: return + stateHandle.update { copy(showDeleteConfirmation = false) } + stateHandle.scope.launch { + when (conversationRepository.delete(conversationId)) { + is Result.Success -> { + // Signal navigation back by clearing the conversation + stateHandle.update { copy(conversationId = null) } + } + is Result.Error -> { + stateHandle.update { copy(error = "Failed to delete conversation") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun archiveConversation() { + val conversationId = stateHandle.state.conversationId ?: return + stateHandle.scope.launch { + when (conversationRepository.archive(conversationId, true)) { + is Result.Success -> { + // Signal navigation back by clearing the conversation + stateHandle.update { copy(conversationId = null) } + } + is Result.Error -> { + stateHandle.update { copy(error = "Failed to archive conversation") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun duplicateConversation() { + val conversationId = stateHandle.state.conversationId ?: return + val title = stateHandle.state.conversationTitle + stateHandle.scope.launch { + when (val result = conversationRepository.duplicateConversation(conversationId, title)) { + is Result.Success -> { + stateHandle.update { copy(duplicatedConversationId = result.data.conversationId) } + } + is Result.Error -> { + stateHandle.update { copy(error = "Failed to duplicate conversation") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun onDuplicatedConversationHandled() { + stateHandle.update { copy(duplicatedConversationId = null) } + } + + fun shareConversation() { + val conversationId = stateHandle.state.conversationId ?: return + stateHandle.scope.launch { + when (val result = shareRepository.createShareLink(conversationId)) { + is Result.Success -> { + stateHandle.update { copy(error = null) } + // Store the share URL to be copied by the UI + _shareLinkUrl.value = result.data + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to create share link") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun onShareLinkHandled() { + _shareLinkUrl.value = null + } + + fun showForkOptions(messageId: String) { + stateHandle.update { copy(showForkOptionsForMessageId = messageId) } + } + + fun dismissForkOptions() { + stateHandle.update { copy(showForkOptionsForMessageId = null) } + } + + fun forkFromMessage(messageId: String, option: String, splitAtTarget: Boolean = false) { + val conversationId = stateHandle.state.conversationId ?: return + val latestMessageId = stateHandle.state.displayMessages.lastOrNull()?.message?.messageId + stateHandle.update { + copy( + showForkOptionsForMessageId = null, + isForkInProgress = true, + ) + } + stateHandle.scope.launch { + val result = conversationRepository.forkConversation( + conversationId = conversationId, + messageId = messageId, + option = option, + splitAtTarget = if (splitAtTarget) true else null, + latestMessageId = if (splitAtTarget) latestMessageId else null, + ) + when (result) { + is Result.Success -> { + stateHandle.update { + copy( + forkedConversationId = result.data.conversationId, + isForkInProgress = false, + ) + } + } + is Result.Error -> { + stateHandle.update { + copy( + error = result.message ?: "Could not fork conversation", + isForkInProgress = false, + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun onForkedConversationHandled() { + stateHandle.update { copy(forkedConversationId = null) } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/FileAttachmentDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/FileAttachmentDelegate.kt new file mode 100644 index 0000000..c2940ed --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/FileAttachmentDelegate.kt @@ -0,0 +1,320 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import android.content.Context +import android.net.Uri +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.model.FileReference +import com.librechat.android.feature.chat.components.AttachedFile +import com.librechat.android.feature.chat.util.detectMimeTypeFromBytes +import com.librechat.android.feature.chat.util.fixFilenameExtension +import com.librechat.android.feature.chat.util.guessMimeType +import com.librechat.android.feature.chat.util.reEncodeImageIfNeeded +import com.librechat.android.feature.chat.util.resolveFileName +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID + +class FileAttachmentDelegate( + private val stateHandle: ChatStateHandle, + private val appContext: Context, + private val fileRepository: FileRepository, +) { + + private val _attachedFiles = MutableStateFlow>(emptyList()) + val attachedFiles: StateFlow> = _attachedFiles.asStateFlow() + + /** Job that waits for pending uploads before sending a message. */ + var pendingUploadSendJob: Job? = null + + /** + * Called when the user selects files from Camera, Photos, or Files picker. + * Immediately starts uploading each file to the server and tracks progress. + */ + fun onFilesSelected(uris: List) { + uris.forEach { uri -> uploadFile(uri) } + } + + private fun uploadFile(uri: Uri) { + val context = appContext + val contentResolver = context.contentResolver + + // Resolve filename and initial MIME type from URI metadata. + // This is a preliminary type -- after reading bytes we'll verify it via magic bytes. + val filename = resolveFileName(context, uri) ?: "file_${System.currentTimeMillis()}" + val preliminaryMimeType = contentResolver.getType(uri) ?: guessMimeType(filename) + val isImage = preliminaryMimeType.startsWith("image/") + + Timber.d("uploadFile: uri=%s, filename=%s, preliminaryMimeType=%s, isImage=%s", uri, filename, preliminaryMimeType, isImage) + + // Add to the pending list with "uploading" state + val pendingFile = AttachedFile( + uri = uri, + name = filename, + isImage = isImage, + uploadProgress = 0f, + type = preliminaryMimeType, + ) + + _attachedFiles.update { currentList -> currentList + pendingFile } + + stateHandle.scope.launch { + try { + // Read file bytes + val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes == null) { + Timber.e("uploadFile: could not read bytes from URI: %s", uri) + markUploadFailed(uri) + stateHandle.update { copy(error = "Failed to upload $filename: Could not read file") } + return@launch + } + + Timber.d("uploadFile: read %d bytes from %s", bytes.size, filename) + + // Detect actual MIME type from file content magic bytes. + val detectedMimeType = detectMimeTypeFromBytes(bytes) + var mimeType = if (detectedMimeType != null && detectedMimeType != preliminaryMimeType) { + Timber.w( + "uploadFile: MIME type mismatch for %s -- ContentResolver reported '%s' but actual content is '%s'. Using detected type.", + filename, preliminaryMimeType, detectedMimeType, + ) + detectedMimeType + } else { + preliminaryMimeType + } + + // Re-encode images that are in formats the server may not handle well + var uploadBytes = bytes + val actualIsImage = mimeType.startsWith("image/") + if (actualIsImage) { + val reEncoded = reEncodeImageIfNeeded(bytes, mimeType) + if (reEncoded != null) { + Timber.d( + "uploadFile: re-encoded image from %s to %s (%d -> %d bytes)", + mimeType, reEncoded.mimeType, bytes.size, reEncoded.bytes.size, + ) + uploadBytes = reEncoded.bytes + mimeType = reEncoded.mimeType + } + } + + // Rename the file extension to match the detected/re-encoded MIME type. + val uploadFilename = fixFilenameExtension(filename, mimeType) + if (uploadFilename != filename) { + Timber.d( + "uploadFile: renamed file from '%s' to '%s' to match MIME type '%s'", + filename, uploadFilename, mimeType, + ) + } + + // Update the attached file's type and name if detection/re-encoding changed them. + val needsTypeUpdate = mimeType != preliminaryMimeType + val needsNameUpdate = uploadFilename != filename + if (needsTypeUpdate || needsNameUpdate) { + val isImageType = mimeType.startsWith("image/") + _attachedFiles.update { currentList -> + currentList.map { f -> + if (f.uri == uri) { + f.copy( + type = mimeType, + isImage = isImageType, + name = uploadFilename, + ) + } else { + f + } + } + } + } + + // For images, resolve width and height so the server can process them correctly + var imageWidth: Int? = null + var imageHeight: Int? = null + if (actualIsImage) { + try { + val options = android.graphics.BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + android.graphics.BitmapFactory.decodeByteArray(uploadBytes, 0, uploadBytes.size, options) + if (options.outWidth > 0 && options.outHeight > 0) { + imageWidth = options.outWidth + imageHeight = options.outHeight + } + Timber.d("uploadFile: image dimensions %dx%d for %s", imageWidth, imageHeight, uploadFilename) + } catch (e: Exception) { + Timber.w(e, "uploadFile: could not read image dimensions for %s", uploadFilename) + } + } + + // Update progress to 50% (upload starting) + updateFileProgress(uri, 0.5f) + + // Generate a UUID for the file_id -- the backend requires this field + val fileId = UUID.randomUUID().toString() + + // Upload to server with context about current endpoint/model + val state = stateHandle.state + val isAgent = state.selectedEndpoint == EndpointConstants.AGENTS + Timber.d( + "uploadFile: sending to server -- fileId=%s, endpoint=%s, model=%s, isAgent=%s, mimeType=%s, filename=%s", + fileId, state.selectedEndpoint, state.selectedModel, isAgent, mimeType, uploadFilename, + ) + val result = fileRepository.uploadFile( + bytes = uploadBytes, + filename = uploadFilename, + type = mimeType, + fileId = fileId, + endpoint = state.selectedEndpoint, + model = if (!isAgent) state.selectedModel else null, + agentId = if (isAgent) state.selectedModel else null, + messageFile = true, + width = imageWidth, + height = imageHeight, + ) + + when (result) { + is Result.Success -> { + val fileObject = result.data + Timber.d( + "uploadFile: success -- serverFileId=%s, filepath=%s, type=%s, %dx%d", + fileObject.fileId, fileObject.filepath, fileObject.type, fileObject.width, fileObject.height, + ) + // Atomically update the attached file with server data + _attachedFiles.update { currentList -> + currentList.map { f -> + if (f.uri == uri) { + f.copy( + uploadProgress = 1f, + fileId = fileObject.fileId, + filepath = fileObject.filepath, + type = fileObject.type, + width = fileObject.width, + height = fileObject.height, + ) + } else { + f + } + } + } + } + is Result.Error -> { + Timber.e(result.exception, "uploadFile: server error -- %s", result.message) + // Atomically mark failed and remove from list in one update + _attachedFiles.update { currentList -> + currentList.map { f -> + if (f.uri == uri) f.copy(uploadFailed = true, uploadProgress = null) + else f + } + } + stateHandle.update { + copy(error = "Failed to upload ${filename}: ${result.message ?: "Unknown error"}") + } + } + is Result.Loading -> { + Timber.w("uploadFile: unexpected Result.Loading received for %s", filename) + } + } + } catch (e: Exception) { + Timber.e(e, "uploadFile: unexpected exception for %s", filename) + markUploadFailed(uri) + stateHandle.update { copy(error = "Failed to upload $filename: ${e.message}") } + } + } + } + + private fun updateFileProgress(uri: Uri, progress: Float) { + _attachedFiles.update { currentList -> + currentList.map { f -> + if (f.uri == uri) f.copy(uploadProgress = progress) else f + } + } + } + + private fun markUploadFailed(uri: Uri) { + _attachedFiles.update { currentList -> + currentList.map { f -> + if (f.uri == uri) f.copy(uploadFailed = true, uploadProgress = null) else f + } + } + } + + fun removeFile(file: AttachedFile) { + _attachedFiles.update { currentList -> currentList.filter { it.uri != file.uri } } + } + + fun retryUpload(file: AttachedFile) { + _attachedFiles.update { currentList -> currentList.filter { it.uri != file.uri } } + uploadFile(file.uri) + } + + /** + * Builds a list of [FileReference] from successfully uploaded attached files. + * Only includes files that have a server-assigned fileId (upload completed). + * Files still uploading (fileId == null) are excluded. + */ + fun buildFileReferences(): List { + val allFiles = _attachedFiles.value + val uploadedFiles = allFiles.filter { it.fileId != null } + val pendingFiles = allFiles.filter { it.fileId == null && !it.uploadFailed } + if (pendingFiles.isNotEmpty()) { + Timber.w( + "buildFileReferences: %d file(s) still uploading and will NOT be included: %s", + pendingFiles.size, + pendingFiles.joinToString { it.name }, + ) + } + Timber.d( + "buildFileReferences: %d total, %d uploaded, %d pending, %d failed", + allFiles.size, uploadedFiles.size, pendingFiles.size, + allFiles.count { it.uploadFailed }, + ) + return uploadedFiles.map { file -> + FileReference( + fileId = file.fileId, + filename = file.name, + filepath = file.filepath, + type = file.type, + width = file.width, + height = file.height, + ) + } + } + + /** + * Waits for all pending file uploads to complete (up to 30 seconds), + * then proceeds with sending the message via the provided callback. + */ + suspend fun waitForUploadsAndSend(text: String, doSend: (String) -> Unit) { + // Poll _attachedFiles until all files have either completed or failed. + // Timeout after 30 seconds to avoid hanging forever. + val timeoutMs = 30_000L + val pollIntervalMs = 200L + var elapsed = 0L + while (elapsed < timeoutMs) { + val pending = _attachedFiles.value.any { it.fileId == null && !it.uploadFailed } + if (!pending) break + delay(pollIntervalMs) + elapsed += pollIntervalMs + } + val stillPending = _attachedFiles.value.count { it.fileId == null && !it.uploadFailed } + if (stillPending > 0) { + Timber.w("waitForUploadsAndSend: timed out with %d file(s) still uploading", stillPending) + } + doSend(text) + } + + fun hasPendingUploads(): Boolean = + _attachedFiles.value.any { it.fileId == null && !it.uploadFailed } + + fun clearAttachedFiles() { + _attachedFiles.update { emptyList() } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/InConversationSearchDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/InConversationSearchDelegate.kt new file mode 100644 index 0000000..d074541 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/InConversationSearchDelegate.kt @@ -0,0 +1,113 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import com.librechat.android.feature.chat.viewmodel.SearchMatch + +class InConversationSearchDelegate( + private val stateHandle: ChatStateHandle, +) { + + fun openSearch() { + stateHandle.update { + copy( + isSearchOpen = true, + searchQuery = "", + searchMatchIndices = emptyList(), + currentSearchMatchIndex = 0, + searchScrollToIndex = null, + ) + } + } + + fun closeSearch() { + stateHandle.update { + copy( + isSearchOpen = false, + searchQuery = "", + searchMatchIndices = emptyList(), + currentSearchMatchIndex = 0, + searchScrollToIndex = null, + ) + } + } + + fun onSearchQueryChanged(query: String) { + val state = stateHandle.state + if (query.isBlank()) { + stateHandle.update { + copy( + searchQuery = query, + searchMatchIndices = emptyList(), + currentSearchMatchIndex = 0, + searchScrollToIndex = null, + ) + } + return + } + + val lowerQuery = query.lowercase() + val allMatches = mutableListOf() + + state.displayMessages.forEachIndexed { index, node -> + val message = node.message + val contentParts = message.content + val text = if (!contentParts.isNullOrEmpty()) { + contentParts.mapNotNull { part -> part.text ?: part.think }.joinToString("") + } else { + message.text + } + // Count occurrences in this message + val lowerText = text.lowercase() + var startIndex = 0 + var occurrenceInMessage = 0 + while (true) { + val foundIndex = lowerText.indexOf(lowerQuery, startIndex) + if (foundIndex < 0) break + allMatches.add(SearchMatch(messageIndex = index, occurrenceInMessage = occurrenceInMessage)) + occurrenceInMessage++ + startIndex = foundIndex + query.length + } + } + + stateHandle.update { + copy( + searchQuery = query, + searchMatchIndices = allMatches, + currentSearchMatchIndex = 0, + searchScrollToIndex = allMatches.firstOrNull()?.messageIndex, + ) + } + } + + fun nextSearchMatch() { + val state = stateHandle.state + if (state.searchMatchIndices.isEmpty()) return + val nextIndex = (state.currentSearchMatchIndex + 1) % state.searchMatchIndices.size + stateHandle.update { + copy( + currentSearchMatchIndex = nextIndex, + searchScrollToIndex = searchMatchIndices[nextIndex].messageIndex, + ) + } + } + + fun previousSearchMatch() { + val state = stateHandle.state + if (state.searchMatchIndices.isEmpty()) return + val prevIndex = if (state.currentSearchMatchIndex > 0) { + state.currentSearchMatchIndex - 1 + } else { + state.searchMatchIndices.size - 1 + } + stateHandle.update { + copy( + currentSearchMatchIndex = prevIndex, + searchScrollToIndex = searchMatchIndices[prevIndex].messageIndex, + ) + } + } + + fun onSearchScrollHandled() { + stateHandle.update { copy(searchScrollToIndex = null) } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt new file mode 100644 index 0000000..6357727 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt @@ -0,0 +1,353 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import android.util.Log +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.common.ToolConstants +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.AgentRepository +import com.librechat.android.core.data.repository.ChatRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.request.AddedConversation +import com.librechat.android.core.ui.components.ModelParameters +import com.librechat.android.feature.chat.McpServerDisplayData +import com.librechat.android.feature.chat.viewmodel.ActiveToolCall +import com.librechat.android.feature.chat.viewmodel.ChatScreenState +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import com.librechat.android.feature.chat.viewmodel.ComparisonState +import kotlinx.coroutines.launch +import timber.log.Timber + +class ModelSelectionDelegate( + private val stateHandle: ChatStateHandle, + private val configRepository: ConfigRepository, + private val agentRepository: AgentRepository, + private val mcpRepository: McpRepository, + private val settingsDataStore: SettingsDataStore, + private val chatRepository: ChatRepository, +) { + + // --- Model Comparison --- + val primaryComparisonBuffer = StringBuilder() + val secondaryComparisonBuffer = StringBuilder() + + /** + * Cached last-used endpoint/model from DataStore. Loaded eagerly so + * [refilterModels] can use them as a fallback without waiting for a + * separate coroutine to complete. Null until the DataStore read finishes. + */ + var cachedLastUsedEndpoint: String? = null + var cachedLastUsedModel: String? = null + + /** True once the DataStore read for last-used model has completed. */ + var lastUsedModelLoaded = false + + /** + * True once the conversation's model has been loaded from the server. + * Used to prevent [refilterModels] from overwriting the conversation + * model with a fallback before loadConversationModel has had a chance + * to set it. + */ + var conversationModelLoaded = false + + /** + * Re-filters availableModels by endpointConfigs keys and validates the + * currently selected model against the filtered list. + */ + fun refilterModels(isNewConversation: Boolean) { + val rawModels = configRepository.availableModels.value + val endpointCfgs = configRepository.endpointConfigs.value + val filtered = if (endpointCfgs.isEmpty()) { + rawModels.filterValues { it.isNotEmpty() } + } else { + rawModels.filterKeys { it in endpointCfgs } + .filterValues { it.isNotEmpty() } + } + stateHandle.update { copy(availableModels = filtered) } + + // Don't validate against an empty models list — models haven't + // loaded yet. When they arrive this method will be called again. + if (filtered.isEmpty()) return + + // For existing conversations, don't apply fallbacks until the + // conversation model has been loaded. + if (!isNewConversation && !conversationModelLoaded) return + + // Validate current selection + val currentEndpoint = stateHandle.state.selectedEndpoint + val currentModel = stateHandle.state.selectedModel + val isAgentSelection = currentEndpoint == EModelEndpoint.AGENTS.toSerialName() + val modelsForEndpoint = filtered[currentEndpoint] + val selectionValid = isAgentSelection || (currentModel != null && + modelsForEndpoint != null && + currentModel in modelsForEndpoint) + + if (selectionValid) return + + // --- Fallback chain --- + + // Fallback 1: Try the last-used model from DataStore + val lastEndpoint = cachedLastUsedEndpoint + val lastModel = cachedLastUsedModel + if (lastEndpoint != null && lastModel != null) { + val lastIsAgent = lastEndpoint == EModelEndpoint.AGENTS.toSerialName() + val lastModelsForEndpoint = filtered[lastEndpoint] + if (lastIsAgent || (lastModelsForEndpoint != null && lastModel in lastModelsForEndpoint)) { + stateHandle.update { + copy( + selectedEndpoint = lastEndpoint, + selectedModel = lastModel, + ) + } + return + } + } + + // Fallback 2: First available model from any endpoint + val firstEndpoint = filtered.entries.firstOrNull() + if (firstEndpoint != null) { + val fallbackModel = firstEndpoint.value.firstOrNull() + stateHandle.update { + copy( + selectedEndpoint = firstEndpoint.key, + selectedModel = fallbackModel, + ) + } + // Persist fallback so the next new chat starts with a valid model + if (fallbackModel != null) { + stateHandle.scope.launch { + settingsDataStore.setLastUsedModel(firstEndpoint.key, fallbackModel) + } + } + } + } + + fun onModelSelected(endpoint: String, model: String) { + stateHandle.update { + copy( + selectedEndpoint = endpoint, + selectedModel = model, + ) + } + // Keep cached values in sync so refilterModels uses the latest choice + cachedLastUsedEndpoint = endpoint + cachedLastUsedModel = model + stateHandle.scope.launch { + settingsDataStore.setLastUsedModel(endpoint, model) + } + } + + // ── Model Comparison ───────────────────────────────────────────── + + /** + * Toggles comparison mode on/off. + */ + fun toggleComparison() { + val currentComparison = stateHandle.state.comparisonState + if (currentComparison.isEnabled) { + // Disable: clear all comparison state + primaryComparisonBuffer.clear() + secondaryComparisonBuffer.clear() + stateHandle.update { copy(comparisonState = ComparisonState()) } + } else { + // Enable: inherit primary endpoint/model + stateHandle.update { + copy( + comparisonState = ComparisonState( + isEnabled = true, + secondaryEndpoint = selectedEndpoint, + secondaryModel = selectedModel, + ), + // When enabling on LANDING, switch to ACTIVE so comparison tabs render + screenState = if (screenState == ChatScreenState.LANDING) ChatScreenState.ACTIVE else screenState, + ) + } + } + } + + /** + * Updates the secondary model selection for comparison mode. + */ + fun setSecondaryModel(endpoint: String, model: String) { + val comparison = stateHandle.state.comparisonState + if (!comparison.isEnabled) return + stateHandle.update { + copy( + comparisonState = comparison.copy( + secondaryEndpoint = endpoint, + secondaryModel = model, + ), + ) + } + } + + /** + * Resolves a display-friendly name for the secondary model. + */ + fun getSecondaryModelDisplayName(): String? { + val comparison = stateHandle.state.comparisonState + val endpoint = comparison.secondaryEndpoint ?: return null + val model = comparison.secondaryModel ?: return null + return if (endpoint == EndpointConstants.AGENTS) { + stateHandle.state.agents.find { it.id == model }?.name ?: model + } else { + model + } + } + + /** + * Builds an [AddedConversation] for the secondary agent/model in comparison mode. + * Returns null if comparison is not enabled or secondary selection is incomplete. + */ + fun buildAddedConvo(parentMessageId: String? = null): AddedConversation? { + val comparison = stateHandle.state.comparisonState + if (!comparison.isEnabled) return null + val endpoint = comparison.secondaryEndpoint ?: return null + val model = comparison.secondaryModel ?: return null + val isAgent = endpoint == EndpointConstants.AGENTS + val resolvedEndpoint = resolveEndpointEnum(endpoint) + val added = AddedConversation( + conversationId = stateHandle.state.conversationId, + parentMessageId = parentMessageId, + endpoint = resolvedEndpoint, + endpointType = resolvedEndpoint, + agentId = if (isAgent) model else null, + model = if (isAgent) null else model, + ) + Timber.d("[Comparison] buildAddedConvo: endpoint=%s, model=%s, agentId=%s, conversationId=%s, parentMessageId=%s", + added.endpoint, added.model, added.agentId, added.conversationId, added.parentMessageId) + return added + } + + /** + * Determines whether a stream event belongs to the secondary (added) agent. + * + * The server gives both agents the same `groupId` (they share a parallel + * execution group), so groupId alone cannot distinguish them. Instead, the + * server suffixes added-agent IDs with `"____N"` (e.g. `openAI__gpt-5.2____1`). + * The primary never has this suffix. + */ + fun isSecondaryEvent(agentId: String?): Boolean { + if (agentId == null) return false + val comparison = stateHandle.state.comparisonState + // If we've already resolved the secondary agentId from earlier SSE events, use that + if (comparison.secondaryAgentId != null) { + return agentId == comparison.secondaryAgentId + } + // The "____N" suffix identifies the addedConvo (added/secondary) agent. + return agentId.contains("____") + } + + /** + * Resolves an endpoint string (e.g. "openAI") to its [EModelEndpoint] enum value. + * Defaults to [EModelEndpoint.OPENAI] for unrecognized values. + */ + private fun resolveEndpointEnum(endpoint: String): EModelEndpoint { + return try { + EModelEndpoint.entries.firstOrNull { it.toSerialName() == endpoint } + ?: EModelEndpoint.valueOf(endpoint.uppercase()) + } catch (_: IllegalArgumentException) { + EModelEndpoint.OPENAI + } + } + + fun loadAgents() { + stateHandle.scope.launch { + when (val result = agentRepository.getAgents()) { + is Result.Success -> { + stateHandle.update { copy(agents = result.data) } + } + is Result.Error -> { + Timber.e(result.exception, "Failed to load models") + stateHandle.update { copy(error = "Could not load available models") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun loadMcpServers() { + stateHandle.scope.launch { + when (val serversResult = mcpRepository.listServers()) { + is Result.Success -> { + val servers = serversResult.data + // Enrich servers with connection status + val statusResult = mcpRepository.getConnectionStatus() + val statusMap = (statusResult as? Result.Success)?.data ?: emptyMap() + val enriched = servers.map { server -> + val status = statusMap[server.name] + server.copy(isConnected = status?.isConnected ?: false) + } + stateHandle.update { + copy(mcpServers = enriched.map { it.toDisplayData() }) + } + } + is Result.Error -> { + Log.d("ModelSelectionDelegate", "Failed to load MCP servers: ${serversResult.message}", serversResult.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun toggleMcpServer(serverName: String) { + val current = stateHandle.state.selectedMcpServerNames + val updated = if (serverName in current) current - serverName else current + serverName + stateHandle.update { copy(selectedMcpServerNames = updated) } + stateHandle.scope.launch { settingsDataStore.setSelectedMcpServers(updated) } + } + + fun toggleTool(toolName: String) { + if (toolName == ToolConstants.WEB_SEARCH) { + // Web search is backed by modelParameters.webSearch (single source of truth). + val current = stateHandle.state.modelParameters.webSearch + stateHandle.update { + copy(modelParameters = modelParameters.copy(webSearch = !current)) + } + } else if (toolName == ToolConstants.CODE_INTERPRETER && !stateHandle.state.isCodeInterpreterAvailable) { + // Code interpreter is not available on this server; ignore toggle attempt. + return + } else { + val current = stateHandle.state.enabledTools + val updated = if (toolName in current) current - toolName else current + toolName + stateHandle.update { copy(enabledTools = updated) } + stateHandle.scope.launch { settingsDataStore.setEnabledTools(updated) } + } + } + + fun showModelParameters() { + stateHandle.update { copy(showModelParameters = true) } + } + + fun hideModelParameters() { + stateHandle.update { copy(showModelParameters = false) } + } + + fun updateModelParameters(parameters: ModelParameters) { + stateHandle.update { copy(modelParameters = parameters) } + } +} + +// --- Display data mapping extensions --- + +internal fun McpServer.toDisplayData() = McpServerDisplayData( + name = name, + title = title, + description = description, + isConnected = isConnected, +) + +internal fun EModelEndpoint.toSerialName(): String = when (this) { + EModelEndpoint.AZURE_OPENAI -> "azureOpenAI" + EModelEndpoint.OPENAI -> "openAI" + EModelEndpoint.GOOGLE -> "google" + EModelEndpoint.ANTHROPIC -> "anthropic" + EModelEndpoint.ASSISTANTS -> "assistants" + EModelEndpoint.AZURE_ASSISTANTS -> "azureAssistants" + EModelEndpoint.AGENTS -> "agents" + EModelEndpoint.CUSTOM -> "custom" + EModelEndpoint.BEDROCK -> "bedrock" +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt new file mode 100644 index 0000000..588f165 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt @@ -0,0 +1,161 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import android.util.Log +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.PresetRepository +import com.librechat.android.core.data.repository.PromptRepository +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.model.Preset +import com.librechat.android.core.model.PromptGroup +import com.librechat.android.feature.chat.PresetDisplayData +import com.librechat.android.feature.chat.PromptMentionDisplayData +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import kotlinx.coroutines.launch + +class PresetPromptDelegate( + private val stateHandle: ChatStateHandle, + private val presetRepository: PresetRepository, + private val promptRepository: PromptRepository, +) { + + // Keep domain objects for internal operations (loadPreset, handlePromptMention, etc.) + private var cachedPresets: List = emptyList() + private var cachedPromptGroups: List = emptyList() + + fun loadPresets() { + stateHandle.scope.launch { + when (val result = presetRepository.getAll()) { + is Result.Success -> { + cachedPresets = result.data + stateHandle.update { + copy(presets = result.data.map { it.toDisplayData() }) + } + } + is Result.Error -> { + // Presets are non-critical; don't block the user + Log.d("PresetPromptDelegate", "Failed to load presets: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun loadAvailablePrompts() { + stateHandle.scope.launch { + when (val result = promptRepository.getGroups(pageSize = 100)) { + is Result.Success -> { + cachedPromptGroups = result.data.promptGroups + stateHandle.update { + copy(availablePrompts = result.data.promptGroups.map { it.toDisplayData() }) + } + } + is Result.Error -> { + // Prompts are non-critical; don't block the user + Log.d("PresetPromptDelegate", "Failed to load prompts: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun savePreset(name: String) { + val state = stateHandle.state + val preset = Preset( + title = name, + endpoint = try { + EModelEndpoint.valueOf(state.selectedEndpoint.uppercase()) + } catch (_: IllegalArgumentException) { + null + }, + model = state.selectedModel, + ) + stateHandle.scope.launch { + try { + presetRepository.create(preset) + loadPresets() + } catch (e: Exception) { + stateHandle.update { copy(error = "Could not save preset") } + } + } + } + + fun loadPreset(displayData: PresetDisplayData) { + val preset = cachedPresets.find { it.presetId == displayData.presetId } + stateHandle.update { + copy( + selectedEndpoint = preset?.endpoint?.name?.lowercase() ?: selectedEndpoint, + selectedModel = preset?.model ?: selectedModel, + ) + } + } + + fun deletePreset(presetId: String) { + stateHandle.scope.launch { + when (presetRepository.delete(presetId)) { + is Result.Success -> loadPresets() + is Result.Error -> { + stateHandle.update { copy(error = "Could not delete preset") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun editPreset(preset: Preset) { + stateHandle.scope.launch { + when (presetRepository.update(preset)) { + is Result.Success -> loadPresets() + is Result.Error -> { + stateHandle.update { copy(error = "Could not update preset") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun handlePromptMention(displayData: PromptMentionDisplayData) { + val currentInput = stateHandle.state.inputText + val atIndex = currentInput.lastIndexOf('@') + val newText = if (atIndex >= 0) { + currentInput.substring(0, atIndex) + (displayData.command ?: displayData.name) + " " + } else { + currentInput + (displayData.command ?: displayData.name) + " " + } + stateHandle.update { copy(inputText = newText) } + } + + fun handleSlashCommand(displayData: PromptMentionDisplayData) { + // Look up the full domain object to access the prompts list + val group = cachedPromptGroups.find { + it.name == displayData.name && it.command == displayData.command + } + val promptText = if (group != null) { + val productionId = group.productionId + if (productionId != null) { + group.prompts.find { it.id == productionId }?.prompt + } else { + group.prompts.firstOrNull()?.prompt + } + } else { + null + } + stateHandle.update { + copy(inputText = promptText ?: (displayData.command ?: displayData.name)) + } + } +} + +// --- Display data mapping extensions --- + +internal fun Preset.toDisplayData() = PresetDisplayData( + presetId = presetId, + title = title ?: "Untitled Preset", + endpointLabel = endpoint?.name?.lowercase(), + model = model, +) + +internal fun PromptGroup.toDisplayData() = PromptMentionDisplayData( + name = name, + command = command, + oneliner = oneliner, +) diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/TextToSpeechDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/TextToSpeechDelegate.kt new file mode 100644 index 0000000..65603f2 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/TextToSpeechDelegate.kt @@ -0,0 +1,212 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import android.content.Context +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class TextToSpeechDelegate( + private val stateHandle: ChatStateHandle, + private val appContext: Context, + private val speechRepository: SpeechRepository, + private val settingsDataStore: SettingsDataStore, + private val getMessageText: (String) -> String, +) { + + private var ttsEngine: TextToSpeech? = null + private var ttsReady = false + private var serverTtsPlayer: android.media.MediaPlayer? = null + + private fun getOrInitTts(onReady: () -> Unit) { + if (ttsReady && ttsEngine != null) { + onReady() + return + } + ttsEngine?.shutdown() + ttsEngine = TextToSpeech(appContext) { status -> + ttsReady = status == TextToSpeech.SUCCESS + if (ttsReady) { + ttsEngine?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) {} + override fun onDone(utteranceId: String?) { + stateHandle.update { copy(currentlyReadingMessageId = null) } + } + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + stateHandle.update { + copy( + currentlyReadingMessageId = null, + error = "Text-to-speech playback error", + ) + } + } + }) + onReady() + } else { + stateHandle.update { + copy( + currentlyReadingMessageId = null, + error = "Text-to-speech engine not available on this device", + ) + } + } + } + } + + fun readAloud(messageId: String) { + // If already reading this message, stop + if (stateHandle.state.currentlyReadingMessageId == messageId) { + stopReading() + return + } + + // Stop any current playback first + stopReading() + + val text = getMessageText(messageId) + if (text.isBlank()) return + + stateHandle.update { copy(currentlyReadingMessageId = messageId) } + + stateHandle.scope.launch { + val source = settingsDataStore.ttsSource.first() + if (source == "server") { + readAloudViaServer(messageId, text) + } else { + val rate = settingsDataStore.ttsSpeechRate.first() + val pitch = settingsDataStore.ttsPitch.first() + val voiceName = settingsDataStore.ttsVoiceName.first() + readAloudViaDevice(messageId, text, rate, pitch, voiceName) + } + } + } + + internal fun readAloudViaDevice( + messageId: String, + text: String, + speechRate: Float, + pitch: Float, + voiceName: String, + ) { + getOrInitTts { + val engine = ttsEngine ?: return@getOrInitTts + engine.setSpeechRate(speechRate) + engine.setPitch(pitch) + if (voiceName.isNotBlank()) { + engine.voices?.find { it.name == voiceName }?.let { voice -> + engine.setVoice(voice) + } + } + val params = android.os.Bundle() + engine.speak(text, TextToSpeech.QUEUE_FLUSH, params, messageId) + } + } + + internal suspend fun readAloudViaServer(messageId: String, text: String) { + when (val result = speechRepository.synthesizeSpeech(text)) { + is Result.Success -> { + try { + val audioBytes = result.data + val tempFile = java.io.File.createTempFile("tts_", ".mp3", appContext.cacheDir) + tempFile.deleteOnExit() + tempFile.writeBytes(audioBytes) + + serverTtsPlayer?.release() + serverTtsPlayer = android.media.MediaPlayer().apply { + setDataSource(tempFile.absolutePath) + setOnCompletionListener { + it.release() + serverTtsPlayer = null + tempFile.delete() + stateHandle.update { copy(currentlyReadingMessageId = null) } + } + setOnErrorListener { mp, _, _ -> + mp.release() + serverTtsPlayer = null + tempFile.delete() + stateHandle.update { + copy( + currentlyReadingMessageId = null, + error = "Server TTS playback failed", + ) + } + true + } + prepare() + start() + } + } catch (e: Exception) { + stateHandle.update { + copy( + currentlyReadingMessageId = null, + error = "Server TTS playback failed: ${e.message}", + ) + } + } + } + is Result.Error -> { + stateHandle.update { + copy( + currentlyReadingMessageId = null, + error = result.message ?: "Server TTS request failed", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + + fun stopReading() { + ttsEngine?.stop() + serverTtsPlayer?.let { + it.stop() + it.release() + } + serverTtsPlayer = null + stateHandle.update { copy(currentlyReadingMessageId = null) } + } + + /** + * Checks the auto-read preference and, if enabled, reads the completed AI + * response aloud via TTS. Skips auto-read when the user has already started + * typing a new message (inputText is not blank). + * + * Uses a synthetic message ID ("auto_read") because the real message ID + * may not be available yet (Room observer hasn't emitted the final message). + */ + fun maybeAutoReadResponse(responseText: String) { + // Don't auto-read if the user has already started typing + if (stateHandle.state.inputText.isNotBlank()) return + + stateHandle.scope.launch { + val autoRead = settingsDataStore.autoReadEnabled.first() + if (!autoRead) return@launch + + val syntheticId = "auto_read_${System.currentTimeMillis()}" + stateHandle.update { copy(currentlyReadingMessageId = syntheticId) } + + val source = settingsDataStore.ttsSource.first() + if (source == "server") { + readAloudViaServer(syntheticId, responseText) + } else { + val rate = settingsDataStore.ttsSpeechRate.first() + val pitch = settingsDataStore.ttsPitch.first() + val voiceName = settingsDataStore.ttsVoiceName.first() + readAloudViaDevice(syntheticId, responseText, rate, pitch, voiceName) + } + } + } + + fun release() { + ttsEngine?.shutdown() + ttsEngine = null + ttsReady = false + serverTtsPlayer?.release() + serverTtsPlayer = null + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/VoiceInputDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/VoiceInputDelegate.kt new file mode 100644 index 0000000..5023518 --- /dev/null +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/VoiceInputDelegate.kt @@ -0,0 +1,118 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import android.content.Context +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.feature.chat.audio.VoiceRecorder +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class VoiceInputDelegate( + private val stateHandle: ChatStateHandle, + private val appContext: Context, + private val speechRepository: SpeechRepository, + private val autoSendAfterStt: StateFlow, + private val onTranscriptionComplete: () -> Unit, +) { + + private var voiceRecorder: VoiceRecorder? = null + + fun startRecording() { + if (stateHandle.state.isRecording) return + try { + val recorder = VoiceRecorder(appContext) + recorder.start() + voiceRecorder = recorder + stateHandle.update { copy(isRecording = true) } + } catch (e: Exception) { + stateHandle.update { copy(error = "Could not start recording: ${e.message}") } + } + } + + fun stopRecording() { + val recorder = voiceRecorder ?: return + val mimeType = recorder.mimeType + val audioData = recorder.stop() + voiceRecorder = null + stateHandle.update { copy(isRecording = false) } + + if (audioData == null || audioData.isEmpty()) { + stateHandle.update { copy(error = "Recording was empty") } + return + } + + stateHandle.update { copy(isTranscribing = true) } + stateHandle.scope.launch { + when (val result = speechRepository.transcribeAudio(audioData, mimeType)) { + is Result.Success -> { + val transcribedText = result.data.text + val currentInput = stateHandle.state.inputText + val separator = if (currentInput.isNotBlank() && !currentInput.endsWith(" ")) " " else "" + stateHandle.update { + copy( + inputText = currentInput + separator + transcribedText, + isTranscribing = false, + ) + } + // Auto-send if enabled and transcribed text is non-empty and not already streaming + if (autoSendAfterStt.value && transcribedText.isNotBlank() && !stateHandle.state.isStreaming) { + onTranscriptionComplete() + } + } + is Result.Error -> { + stateHandle.update { + copy( + isTranscribing = false, + error = result.message ?: "Transcription failed", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun cancelRecording() { + voiceRecorder?.cancel() + voiceRecorder = null + stateHandle.update { copy(isRecording = false) } + } + + /** + * Called when Android's on-device speech recognizer returns a result. + * Appends the transcribed text to the input field, and auto-sends if enabled. + */ + fun onDeviceSpeechResult(transcribedText: String) { + if (transcribedText.isBlank()) return + val currentInput = stateHandle.state.inputText + val separator = if (currentInput.isNotBlank() && !currentInput.endsWith(" ")) " " else "" + stateHandle.update { + copy(inputText = currentInput + separator + transcribedText) + } + // Auto-send if enabled and not already streaming + if (autoSendAfterStt.value && !stateHandle.state.isStreaming) { + onTranscriptionComplete() + } + } + + fun loadSpeechConfig() { + stateHandle.scope.launch { + when (val result = speechRepository.getSpeechConfig()) { + is Result.Success -> { + stateHandle.update { copy(serverSttEnabled = result.data.sttExternal) } + } + is Result.Error -> { + // If we can't fetch the config, assume server STT is not available + stateHandle.update { copy(serverSttEnabled = false) } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun release() { + voiceRecorder?.cancel() + voiceRecorder = null + } +} diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml new file mode 100644 index 0000000..fcfc4a9 --- /dev/null +++ b/feature/chat/src/main/res/values/strings.xml @@ -0,0 +1,325 @@ + + + + Cancel + Save + Delete + Copy + Edit + Regenerate + Rename + Submit + Insert + Open + Close + Share + Search + Archive + Duplicate + Copy Link + + + New Chat + Send + Stop + Type a message\u2026 + Model Parameters + Load Preset + Save as Preset + Search models\u2026 + Save & Submit + Preset name + Code + Preview + Select model + Select a model + Compare Models + How can I help you today? + Recording\u2026 + Message %1$s + Message\u2026 + + + Open tools menu + Paste image from clipboard + Message input field + Stop voice recording + Start voice recording + Stop generation + Send message + More options + More tools + Scroll to bottom + Select model + LibreChat + Comparison enabled + + + Thumbs up + Thumbs down + Copy message + Edit message + Regenerate response + Stop reading + Read aloud + Fork conversation from here + Previous response + Next response + Generating response + + + Fullscreen image + Close + Save to device + Share image + Failed to load image + Failed to load generated image + Embedded image in message + + + Search in conversation + Previous match + Next match + Close search + Clear search + Find in conversation\u2026 + + + Copy code + Copied + Copy + Copied! + Code execution result + Exit code 0 (success) + Exit code %1$d (failure) + exit %1$d + Code + Output: + Error: + Input: + Collapse code + Expand code + + + Thinking indicator + Thinking + Collapse + Expand + Collapse thinking block + Expand thinking block + + + Tool call + Collapse tool call %1$s + Expand tool call %1$s + Complete + Enabled + Video content not supported + + + Camera + Photos + Files + Model + Model Parameters + Adjust temperature, tokens, and more + Web Search + Search the web for information + Code + Run and analyze code + File Search + Search through uploaded files + MCP + Model Context Protocol servers + Web Search enabled + Code interpreter enabled + File Search enabled + + + Files (%1$d) + Attached file: %1$s + Preview of %1$s + Remove %1$s + + + Agent handoff from %1$s to %2$s + handed off to + unknown + Previous Agent + Next Agent + + + Pause + Play + Play video + + + Citation %1$d + Open citation URL + + + Fork Conversation + Visible messages + Only the direct path of messages + Include branches + Direct path plus sibling messages + All to target level + All messages and branches up to this level + Start new conversation from target + Begin the fork from the selected message instead of the root + + + Failed to load image + Failed to save image + Image saved to gallery + Failed to save image: %1$s + Storage permission is required to save images + + + Generating image\u2026 + Image generated + Generated image + + + Happy late night + Happy weekend morning + Happy weekend afternoon + Happy weekend evening + Good early morning + Good morning + Good afternoon + Good evening + + + Log Output + Collapse %1$s + Expand %1$s + + + Expand table + Table + + + Select MCP servers + MCP resources: %1$d items + MCP resource: %1$s + + + Memory artifact: %1$s + Untitled + Memory + + + mermaid + Show diagram + View code + Diagram + Fullscreen + Close fullscreen + + + You + Assistant + + + File + + + Selected + Public agent + Collapse %1$s + Expand %1$s + + + Select Preset + No presets saved yet + Edit %1$s + Delete %1$s + Endpoint: %1$s + Model: %1$s + + + Provide Feedback + What was the issue with this response? + Optional comment\u2026 + + + Output: + + + Search result: %1$s + + + Rename conversation + Title + + + Delete conversation + This will permanently delete this conversation. This cannot be undone. + + + Search + Rename + + + Open artifact + Share artifact + Previous version + Next version + v%1$d/%2$d + Resource + + + Prompts Library + Use in Chat + Set Production + Name + Description + Command + Prompt text + Prompt Content + Share prompt + Edit prompt + Delete prompt + Back + Version history + Filter and sort + Create prompt + Remove filter + Reset sort + Share + by %1$s + Sort: %1$s + + + Version History + No versions available + Production + v%1$d + + + Variables + + + Category + All + Sort By + + + Share Prompt + Permission + + + Fill in Variables + + + Temporary chat + Temp + + + Continue with this response + Waiting for response\u2026 + + + /%1$s + diff --git a/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/MarkdownSegmentsTest.kt b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/MarkdownSegmentsTest.kt new file mode 100644 index 0000000..288d8c5 --- /dev/null +++ b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/MarkdownSegmentsTest.kt @@ -0,0 +1,153 @@ +package com.librechat.android.feature.chat.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkdownSegmentsTest { + + // --- CODE_BLOCK_REGEX resilience (Part 2 fix) --- + + @Test + fun `code block with trailing space after language is parsed`() { + val input = "```html \n
test
\n```" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals("html", code.language) + assertEquals("
test
", code.code) + } + + @Test + fun `code block with tab after language is parsed`() { + val input = "```html\t\n
test
\n```" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals("html", code.language) + assertEquals("
test
", code.code) + } + + @Test + fun `standard code block still works`() { + val input = "```python\nprint('hello')\n```" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals("python", code.language) + assertEquals("print('hello')", code.code) + } + + @Test + fun `code block without language still works`() { + val input = "```\nsome code\n```" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals(null, code.language) + assertEquals("some code", code.code) + } + + // --- HTML block detection (Part 1 fix) --- + + @Test + fun `raw HTML document is converted to code block`() { + val input = """ + +Pet Rock +

Rocky

+""" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals("html", code.language) + assertTrue(code.code.contains("")) + assertTrue(code.code.contains("")) + } + + @Test + fun `raw HTML div block is converted to code block`() { + val input = """
+

Hello

+

World

+
""" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals("html", code.language) + } + + @Test + fun `text before raw HTML is preserved as text block`() { + val input = """Here's your web page: + + +

Rocky

+""" + val segments = parseMarkdownSegments(input) + assertEquals(2, segments.size) + assertTrue(segments[0] is MarkdownSegment.TextBlock) + assertEquals("Here's your web page:", (segments[0] as MarkdownSegment.TextBlock).text) + val code = segments[1] as MarkdownSegment.CodeBlock + assertEquals("html", code.language) + assertTrue(code.code.contains("")) + } + + @Test + fun `HTML inside fenced code block is not double-wrapped`() { + val input = "```html\n
test
\n```" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals("html", code.language) + assertEquals("
test
", code.code) + } + + @Test + fun `plain text without HTML is not affected`() { + val input = "This is a normal markdown paragraph with **bold** text." + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + assertTrue(segments[0] is MarkdownSegment.TextBlock) + } + + @Test + fun `line starting with angle bracket but no closing tag is not HTML block`() { + // A comparison operator should not be treated as HTML + val input = "

\n
\n" + val segments = parseMarkdownSegments(input) + assertEquals(1, segments.size) + val code = segments[0] as MarkdownSegment.CodeBlock + assertEquals("html", code.language) + } + + @Test + fun `HTML with surrounding text splits correctly`() { + val input = """Here is your page: + + +Test +Hello + + +Enjoy!""" + // The fenced-code-block regex won't match this, so parseMarkdownSegments + // will first produce text blocks, then Pass 2 (HTML detection) splits them. + // "Enjoy!" ends up inside the HTML block text because it's part of the + // same TextBlock after code-block extraction. + val segments = parseMarkdownSegments(input) + // First segment: "Here is your page:" + assertTrue(segments[0] is MarkdownSegment.TextBlock) + // Second segment: the HTML block (which includes trailing "Enjoy!" since + // extractHtmlBlocks takes from htmlStart to end of the TextBlock) + assertTrue(segments[1] is MarkdownSegment.CodeBlock) + } +} diff --git a/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetectorTest.kt b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetectorTest.kt new file mode 100644 index 0000000..965c599 --- /dev/null +++ b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/components/artifact/ArtifactDetectorTest.kt @@ -0,0 +1,353 @@ +package com.librechat.android.feature.chat.components.artifact + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtifactDetectorTest { + + @Test + fun `detects HTML artifact`() { + val text = """ +:::artifact{identifier="my-page" type="text/html" title="My Page"} +```html +

Hello

+``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("my-page", ref.artifact.identifier) + assertEquals("text/html", ref.artifact.type) + assertEquals("My Page", ref.artifact.title) + assertEquals("html", ref.artifact.language) + assertEquals("

Hello

", ref.artifact.content) + } + + @Test + fun `detects SVG artifact`() { + val text = """ +:::artifact{identifier="house-svg" type="image/svg+xml" title="House SVG"} +```svg + +``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("house-svg", ref.artifact.identifier) + assertEquals("image/svg+xml", ref.artifact.type) + assertEquals("", ref.artifact.content) + } + + @Test + fun `detects React artifact`() { + val text = """ +:::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"} +``` +import { useState } from 'react'; + +export default function Counter() { + const [count, setCount] = useState(0); + return ; +} +``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("react-counter", ref.artifact.identifier) + assertEquals("application/vnd.react", ref.artifact.type) + assertTrue(ref.artifact.content.contains("useState")) + } + + @Test + fun `detects Mermaid artifact`() { + val text = """ +:::artifact{identifier="login-flow" type="application/vnd.mermaid" title="Login Flow"} +```mermaid +graph TD + A[Start] --> B{Has Account?} + B -->|Yes| C[Login] + B -->|No| D[Register] +``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("login-flow", ref.artifact.identifier) + assertEquals("application/vnd.mermaid", ref.artifact.type) + assertEquals("mermaid", ref.artifact.language) + assertTrue(ref.artifact.content.contains("graph TD")) + } + + @Test + fun `detects Markdown artifact`() { + val text = """ +:::artifact{identifier="report" type="text/markdown" title="Climate Report"} +```markdown +# Climate Change Report + +## Introduction +Global temperatures are rising. + +- Point 1 +- Point 2 +``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("report", ref.artifact.identifier) + assertEquals("text/markdown", ref.artifact.type) + assertTrue(ref.artifact.content.contains("# Climate Change Report")) + } + + @Test + fun `detects plain text artifact`() { + val text = """ +:::artifact{identifier="notes" type="text/plain" title="Notes"} +``` +Some plain text content +with multiple lines +``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("notes", ref.artifact.identifier) + assertEquals("text/plain", ref.artifact.type) + } + + @Test + fun `detects multiple artifacts in one message`() { + val text = """ +Here's an HTML page: + +:::artifact{identifier="page" type="text/html" title="Page"} +```html +

Hello

+``` +::: + +And here's a diagram: + +:::artifact{identifier="diagram" type="application/vnd.mermaid" title="Diagram"} +```mermaid +graph LR + A --> B +``` +::: + +Hope that helps! + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(5, segments.size) + assertTrue(segments[0] is ArtifactSegment.Text) + assertTrue(segments[1] is ArtifactSegment.ArtifactReference) + assertTrue(segments[2] is ArtifactSegment.Text) + assertTrue(segments[3] is ArtifactSegment.ArtifactReference) + assertTrue(segments[4] is ArtifactSegment.Text) + + val page = (segments[1] as ArtifactSegment.ArtifactReference).artifact + assertEquals("page", page.identifier) + assertEquals("text/html", page.type) + + val diagram = (segments[3] as ArtifactSegment.ArtifactReference).artifact + assertEquals("diagram", diagram.identifier) + assertEquals("application/vnd.mermaid", diagram.type) + } + + @Test + fun `artifact with surrounding text`() { + val text = """ +Here's what I made: + +:::artifact{identifier="test" type="text/html" title="Test"} +```html +
test
+``` +::: + +Let me know if you want changes. + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(3, segments.size) + assertEquals("Here's what I made:", (segments[0] as ArtifactSegment.Text).text) + assertEquals("test", (segments[1] as ArtifactSegment.ArtifactReference).artifact.identifier) + assertEquals("Let me know if you want changes.", (segments[2] as ArtifactSegment.Text).text) + } + + @Test + fun `version grouping with same identifier`() { + val text = """ +:::artifact{identifier="counter" type="text/html" title="Counter v1"} +```html +

Version 1

+``` +::: + +:::artifact{identifier="counter" type="text/html" title="Counter v2" version="2"} +```html +

Version 2

+``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + val versions = groupArtifactVersions(segments) + assertEquals(1, versions.size) + assertEquals(2, versions["counter"]?.size) + assertEquals(1, versions["counter"]?.get(0)?.version) + assertEquals(2, versions["counter"]?.get(1)?.version) + } + + @Test + fun `missing attributes use defaults`() { + val text = """ +:::artifact{type="text/html"} +``` +

No id or title

+``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertTrue(ref.artifact.identifier.startsWith("artifact-")) + assertEquals("Artifact", ref.artifact.title) + assertEquals(1, ref.artifact.version) + } + + @Test + fun `no artifacts returns single text segment`() { + val text = "This is just regular text with no artifacts." + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + assertTrue(segments[0] is ArtifactSegment.Text) + assertEquals(text, (segments[0] as ArtifactSegment.Text).text) + } + + @Test + fun `blank text returns empty list`() { + val segments = detectArtifacts(" ") + assertTrue(segments.isEmpty()) + } + + @Test + fun `artifact with text_md type`() { + val text = """ +:::artifact{identifier="doc" type="text/md" title="Doc"} +``` +# Hello World +``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("text/md", ref.artifact.type) + } + + @Test + fun `artifact content preserves internal newlines`() { + val text = """ +:::artifact{identifier="multi" type="text/html" title="Multi-line"} +```html +
+

Line 1

+

Line 2

+

Line 3

+
+``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + val content = (segments[0] as ArtifactSegment.ArtifactReference).artifact.content + assertTrue(content.contains("

Line 1

")) + assertTrue(content.contains("

Line 2

")) + assertTrue(content.contains("

Line 3

")) + assertEquals(5, content.lines().size) + } + + @Test + fun `language hint extracted from backtick line`() { + val text = """ +:::artifact{identifier="code" type="text/html" title="Code"} +```html +

test

+``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + val artifact = (segments[0] as ArtifactSegment.ArtifactReference).artifact + assertEquals("html", artifact.language) + } + + @Test + fun `no language hint when backticks are bare`() { + val text = """ +:::artifact{identifier="bare" type="application/vnd.react" title="React"} +``` +export default () =>
Hello
+``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + val artifact = (segments[0] as ArtifactSegment.ArtifactReference).artifact + assertEquals(null, artifact.language) + } + + @Test + fun `version grouping sorts by version`() { + val segments = listOf( + ArtifactSegment.ArtifactReference( + Artifact("a", "text/html", "A v3", null, "v3", version = 3), + ), + ArtifactSegment.ArtifactReference( + Artifact("a", "text/html", "A v1", null, "v1", version = 1), + ), + ArtifactSegment.ArtifactReference( + Artifact("a", "text/html", "A v2", null, "v2", version = 2), + ), + ) + + val grouped = groupArtifactVersions(segments) + assertEquals(listOf(1, 2, 3), grouped["a"]?.map { it.version }) + } + + @Test + fun `code-html type artifact detected`() { + val text = """ +:::artifact{identifier="app" type="application/vnd.code-html" title="App"} +```html +

Code HTML

+``` +::: + """.trimIndent() + + val segments = detectArtifacts(text) + assertEquals(1, segments.size) + val ref = segments[0] as ArtifactSegment.ArtifactReference + assertEquals("application/vnd.code-html", ref.artifact.type) + } +} diff --git a/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/util/MessageTreeTest.kt b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/util/MessageTreeTest.kt new file mode 100644 index 0000000..c1b8803 --- /dev/null +++ b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/util/MessageTreeTest.kt @@ -0,0 +1,116 @@ +package com.librechat.android.feature.chat.util + +import com.google.common.truth.Truth.assertThat +import com.librechat.android.core.model.Message +import org.junit.Test + +class MessageTreeTest { + + private fun message( + id: String, + parentId: String? = null, + text: String = "msg-$id", + isUser: Boolean = false, + ) = Message( + messageId = id, + conversationId = "conv-1", + parentMessageId = parentId, + text = text, + isCreatedByUser = isUser, + ) + + @Test + fun `empty list returns empty path`() { + val result = buildActiveMessagePath(emptyList()) + assertThat(result).isEmpty() + } + + @Test + fun `single message returns single node`() { + val messages = listOf(message("a")) + val result = buildActiveMessagePath(messages) + assertThat(result).hasSize(1) + assertThat(result[0].message.messageId).isEqualTo("a") + assertThat(result[0].siblingIndex).isEqualTo(0) + assertThat(result[0].siblingCount).isEqualTo(1) + } + + @Test + fun `linear conversation returns all messages in order`() { + val messages = listOf( + message("a"), + message("b", parentId = "a"), + message("c", parentId = "b"), + ) + val result = buildActiveMessagePath(messages) + assertThat(result).hasSize(3) + assertThat(result.map { it.message.messageId }).containsExactly("a", "b", "c").inOrder() + } + + @Test + fun `branching conversation follows last child by default`() { + // Root -> a, Root -> b (b is last, so default) + val messages = listOf( + message("a"), + message("b"), + ) + val result = buildActiveMessagePath(messages) + assertThat(result).hasSize(1) + // Both a and b are root-level; default selects last (b) + assertThat(result[0].message.messageId).isEqualTo("b") + assertThat(result[0].siblingCount).isEqualTo(2) + assertThat(result[0].siblingIndex).isEqualTo(1) + } + + @Test + fun `activeBranches overrides default path`() { + val rootParent = "00000000-0000-0000-0000-000000000000" + val messages = listOf( + message("a"), + message("b"), + ) + // Select the first sibling (index 0 = "a") instead of default last + val result = buildActiveMessagePath(messages, activeBranches = mapOf(rootParent to 0)) + assertThat(result).hasSize(1) + assertThat(result[0].message.messageId).isEqualTo("a") + } + + @Test + fun `sibling counts are correct`() { + // Root has 3 children + val messages = listOf( + message("a"), + message("b"), + message("c"), + ) + val result = buildActiveMessagePath(messages) + assertThat(result).hasSize(1) + assertThat(result[0].siblingCount).isEqualTo(3) + // Children should also reflect the correct count + assertThat(result[0].children).hasSize(3) + result[0].children.forEachIndexed { index, node -> + assertThat(node.siblingIndex).isEqualTo(index) + assertThat(node.siblingCount).isEqualTo(3) + } + } + + @Test + fun `deep branching with override follows correct path`() { + val rootParent = "00000000-0000-0000-0000-000000000000" + val messages = listOf( + message("root1"), + message("root2"), + message("child1-of-root1", parentId = "root1"), + message("child2-of-root1", parentId = "root1"), + message("grandchild", parentId = "child1-of-root1"), + ) + // Select root1 (index 0), then child1-of-root1 (index 0) + val result = buildActiveMessagePath( + messages, + activeBranches = mapOf(rootParent to 0, "root1" to 0), + ) + assertThat(result.map { it.message.messageId }) + .containsExactly("root1", "child1-of-root1", "grandchild") + .inOrder() + } +} diff --git a/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/viewmodel/DetectMimeTypeTest.kt b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/viewmodel/DetectMimeTypeTest.kt new file mode 100644 index 0000000..d5197af --- /dev/null +++ b/feature/chat/src/test/kotlin/com/librechat/android/feature/chat/viewmodel/DetectMimeTypeTest.kt @@ -0,0 +1,147 @@ +package com.librechat.android.feature.chat.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.librechat.android.feature.chat.util.detectMimeTypeFromBytes +import org.junit.Test + +class DetectMimeTypeTest { + + private fun detect(bytes: ByteArray): String? = + detectMimeTypeFromBytes(bytes) + + @Test + fun `detects JPEG from magic bytes`() { + val bytes = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + + ByteArray(8) + assertThat(detect(bytes)).isEqualTo("image/jpeg") + } + + @Test + fun `detects PNG from magic bytes`() { + val bytes = byteArrayOf( + 0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte(), + 0x0D.toByte(), 0x0A.toByte(), 0x1A.toByte(), 0x0A.toByte(), + ) + ByteArray(4) + assertThat(detect(bytes)).isEqualTo("image/png") + } + + @Test + fun `detects GIF87a from magic bytes`() { + // "GIF87a" + val bytes = byteArrayOf( + 0x47.toByte(), 0x49.toByte(), 0x46.toByte(), 0x38.toByte(), + 0x37.toByte(), 0x61.toByte(), + ) + ByteArray(6) + assertThat(detect(bytes)).isEqualTo("image/gif") + } + + @Test + fun `detects GIF89a from magic bytes`() { + // "GIF89a" + val bytes = byteArrayOf( + 0x47.toByte(), 0x49.toByte(), 0x46.toByte(), 0x38.toByte(), + 0x39.toByte(), 0x61.toByte(), + ) + ByteArray(6) + assertThat(detect(bytes)).isEqualTo("image/gif") + } + + @Test + fun `detects WebP from magic bytes`() { + // "RIFF" + 4 size bytes + "WEBP" + val bytes = byteArrayOf( + 0x52.toByte(), 0x49.toByte(), 0x46.toByte(), 0x46.toByte(), + 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), + 0x57.toByte(), 0x45.toByte(), 0x42.toByte(), 0x50.toByte(), + ) + assertThat(detect(bytes)).isEqualTo("image/webp") + } + + @Test + fun `detects BMP from magic bytes`() { + val bytes = byteArrayOf(0x42.toByte(), 0x4D.toByte()) + ByteArray(10) + assertThat(detect(bytes)).isEqualTo("image/bmp") + } + + @Test + fun `detects TIFF little-endian from magic bytes`() { + val bytes = byteArrayOf( + 0x49.toByte(), 0x49.toByte(), 0x2A.toByte(), 0x00.toByte(), + ) + ByteArray(8) + assertThat(detect(bytes)).isEqualTo("image/tiff") + } + + @Test + fun `detects TIFF big-endian from magic bytes`() { + val bytes = byteArrayOf( + 0x4D.toByte(), 0x4D.toByte(), 0x00.toByte(), 0x2A.toByte(), + ) + ByteArray(8) + assertThat(detect(bytes)).isEqualTo("image/tiff") + } + + @Test + fun `detects HEIC from ftyp box`() { + // ftyp box with "heic" brand + val bytes = byteArrayOf( + 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x18.toByte(), // box size + 0x66.toByte(), 0x74.toByte(), 0x79.toByte(), 0x70.toByte(), // "ftyp" + 0x68.toByte(), 0x65.toByte(), 0x69.toByte(), 0x63.toByte(), // "heic" + ) + assertThat(detect(bytes)).isEqualTo("image/heic") + } + + @Test + fun `detects AVIF from ftyp box`() { + val bytes = byteArrayOf( + 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x1C.toByte(), + 0x66.toByte(), 0x74.toByte(), 0x79.toByte(), 0x70.toByte(), // "ftyp" + 0x61.toByte(), 0x76.toByte(), 0x69.toByte(), 0x66.toByte(), // "avif" + ) + assertThat(detect(bytes)).isEqualTo("image/avif") + } + + @Test + fun `detects ICO from magic bytes`() { + val bytes = byteArrayOf( + 0x00.toByte(), 0x00.toByte(), 0x01.toByte(), 0x00.toByte(), + ) + ByteArray(8) + assertThat(detect(bytes)).isEqualTo("image/x-icon") + } + + @Test + fun `returns null for unknown format`() { + val bytes = ByteArray(20) { 0x00.toByte() } + assertThat(detect(bytes)).isNull() + } + + @Test + fun `returns null for bytes too short`() { + val bytes = ByteArray(5) { 0xFF.toByte() } + assertThat(detect(bytes)).isNull() + } + + @Test + fun `returns null for empty bytes`() { + assertThat(detect(ByteArray(0))).isNull() + } + + @Test + fun `detects HEIC with mif1 brand`() { + val bytes = byteArrayOf( + 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x18.toByte(), + 0x66.toByte(), 0x74.toByte(), 0x79.toByte(), 0x70.toByte(), // "ftyp" + 0x6D.toByte(), 0x69.toByte(), 0x66.toByte(), 0x31.toByte(), // "mif1" + ) + assertThat(detect(bytes)).isEqualTo("image/heic") + } + + @Test + fun `returns null for video ftyp brands`() { + // "isom" is a video brand, not an image + val bytes = byteArrayOf( + 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x18.toByte(), + 0x66.toByte(), 0x74.toByte(), 0x79.toByte(), 0x70.toByte(), // "ftyp" + 0x69.toByte(), 0x73.toByte(), 0x6F.toByte(), 0x6D.toByte(), // "isom" + ) + assertThat(detect(bytes)).isNull() + } +} diff --git a/feature/conversations/CLAUDE.md b/feature/conversations/CLAUDE.md new file mode 100644 index 0000000..48107f0 --- /dev/null +++ b/feature/conversations/CLAUDE.md @@ -0,0 +1,46 @@ +# feature:conversations + +## Screen +`ConversationListScreen` -- the drawer content showing all conversations. Single route: `CONVERSATIONS_ROUTE`. + +## Pagination +- Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)` +- `nextCursor: String?` in UI state; null = no more pages +- `loadMore()` called when LazyColumn nears the end +- Pull-to-refresh via `refresh()` which resets to first page + +## Date Grouping +- `DateGroupHeader` renders section labels: Today, Yesterday, Previous 7 Days, Previous 30 Days, month-year +- Conversations are flattened into a sealed `ConversationListItem` (DateHeader | ConvoItem) + +## Search +- `ConversationSearchBar` with 500ms debounce (via coroutine delay) +- `onSearchQueryChanged()` cancels previous search job, waits 500ms, calls `SearchRepository.search()` +- When search is active, normal pagination is disabled (`hasMore = false`) +- Clear search restores normal conversation list + +## Tags +- `TagFilterBar` displays `FilterChip` per tag (multi-select toggle) +- `TagPicker` for assigning tags to a conversation +- Tags fetched via `TagRepository.getTags()`, hidden if `count == 0` +- `toggleTag()` / `clearTagFilter()` reload conversations with new filter + +## CRUD Actions (via `ConversationActions` component) +| Action | API | Notes | +|--------|-----|-------| +| Rename | `POST /api/convos/update` | Inline rename, max 100 chars | +| Archive | `POST /api/convos/archive` | `{ conversationId, isArchived: true }` | +| Delete | `DELETE /api/convos` | Confirmation dialog required | +| Duplicate | `POST /api/convos/duplicate` | Navigates to new conversation | +| Fork | `POST /api/convos/fork` | From message context menu in chat, not here | +| Share | `POST /api/share` | Only if `sharedLinksEnabled` in server config | + +## Export / Import +- `ConversationExporter` supports `ExportFormat.JSON` and `ExportFormat.MARKDOWN` +- Export is client-side: fetches messages, serializes locally, emits `ExportReady` event +- `ExportFormatPicker` component for format selection +- `ConversationImporter` parses JSON (LibreChat/ChatGPT format) via `POST /api/convos/import` +- File I/O uses SAF (Storage Access Framework) + +## Events +`ConversationListEvent` sealed interface for one-shot UI events: `ShareLinkCopied`, `NavigateToConversation`, `ShowError`, `ExportReady`, `ImportSuccess` diff --git a/feature/conversations/build.gradle.kts b/feature/conversations/build.gradle.kts new file mode 100644 index 0000000..6e9a504 --- /dev/null +++ b/feature/conversations/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("librechat.android.feature") +} + +android { + namespace = "com.librechat.android.feature.conversations" +} + +dependencies { + implementation(libs.paging.runtime) + implementation(libs.paging.compose) + implementation(libs.kotlinx.serialization.json) +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt new file mode 100644 index 0000000..40eaf3d --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt @@ -0,0 +1,21 @@ +package com.librechat.android.feature.conversations + +import androidx.compose.runtime.Immutable +import com.librechat.android.core.model.Conversation + +@Immutable +data class ArchivedConversationDisplayData( + val id: String, + val title: String, + val endpoint: String, + val model: String?, + val archivedAt: String?, +) + +fun Conversation.toArchivedDisplayData() = ArchivedConversationDisplayData( + id = conversationId ?: "", + title = title ?: "New Chat", + endpoint = endpoint?.name?.lowercase() ?: "chat", + model = model, + archivedAt = updatedAt, +) diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt new file mode 100644 index 0000000..81d6065 --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt @@ -0,0 +1,283 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.Conversation +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationActions( + conversation: Conversation, + onDismiss: () -> Unit, + onRename: (String) -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, + onTags: () -> Unit = {}, + onShare: () -> Unit = {}, + onFork: () -> Unit = {}, + onDuplicate: () -> Unit = {}, + onExport: () -> Unit = {}, + onBookmarkToggle: () -> Unit = {}, + isBookmarked: Boolean = false, + showShareAction: Boolean = false, +) { + var showRenameDialog by remember { mutableStateOf(false) } + var showDeleteConfirmation by remember { mutableStateOf(false) } + + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier.padding(bottom = 32.dp), + ) { + Text( + text = conversation.title ?: stringResource(R.string.new_chat), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + maxLines = 1, + ) + + ActionRow( + icon = Icons.Default.Edit, + label = stringResource(R.string.rename), + onClick = { + onDismiss() + showRenameDialog = true + }, + ) + + ActionRow( + icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + label = if (isBookmarked) stringResource(R.string.remove_bookmark) else stringResource(R.string.bookmark), + iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + onClick = { + onDismiss() + onBookmarkToggle() + }, + ) + + ActionRow( + icon = Icons.Default.BookmarkBorder, + label = stringResource(R.string.tags), + onClick = { + onDismiss() + onTags() + }, + ) + + if (showShareAction) { + ActionRow( + icon = Icons.Default.Share, + label = stringResource(R.string.share), + onClick = { + onDismiss() + onShare() + }, + ) + } + + ActionRow( + icon = Icons.Default.ContentCopy, + label = stringResource(R.string.duplicate), + onClick = { + onDismiss() + onDuplicate() + }, + ) + + ActionRow( + icon = Icons.Default.FileDownload, + label = stringResource(R.string.export), + onClick = { + onDismiss() + onExport() + }, + ) + + ActionRow( + icon = Icons.Default.Archive, + label = stringResource(R.string.archive), + onClick = { + onDismiss() + onArchive() + }, + ) + + ActionRow( + icon = Icons.Default.Delete, + label = stringResource(R.string.delete), + iconTint = MaterialTheme.colorScheme.error, + labelColor = MaterialTheme.colorScheme.error, + onClick = { + onDismiss() + showDeleteConfirmation = true + }, + ) + } + } + + if (showRenameDialog) { + RenameDialog( + currentTitle = conversation.title ?: "", + onDismiss = { showRenameDialog = false }, + onConfirm = { newTitle -> + showRenameDialog = false + onRename(newTitle) + }, + ) + } + + if (showDeleteConfirmation) { + DeleteConfirmationDialog( + conversationTitle = conversation.title ?: stringResource(R.string.this_conversation), + onDismiss = { showDeleteConfirmation = false }, + onConfirm = { + showDeleteConfirmation = false + onDelete() + }, + ) + } +} + +@Composable +private fun ActionRow( + icon: ImageVector, + label: String, + onClick: () -> Unit, + iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + labelColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = iconTint, + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = labelColor, + ) + } +} + +@Composable +private fun RenameDialog( + currentTitle: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var title by remember { mutableStateOf(currentTitle) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.rename_conversation)) }, + text = { + Column { + Text( + text = stringResource(R.string.rename_dialog_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.title_label)) }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(title) }, + enabled = title.isNotBlank(), + ) { + Text(stringResource(R.string.rename)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun DeleteConfirmationDialog( + conversationTitle: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.delete_conversation)) }, + text = { + Text( + text = stringResource(R.string.delete_confirmation_message, conversationTitle), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt new file mode 100644 index 0000000..e4c5d5c --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt @@ -0,0 +1,210 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.core.common.extensions.toInstantOrNull +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.ui.components.isMonochromeIcon +import com.librechat.android.core.ui.components.toIconRes +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +/** + * Lightweight snapshot of the fields ConversationItem actually renders. + * Avoids passing the full 28-field Conversation data class through composition, + * letting Compose skip recomposition when only irrelevant fields change. + */ +@Immutable +data class ConversationDisplayData( + val conversationId: String, + val title: String, + val endpoint: EModelEndpoint?, + val model: String?, + val updatedAt: String?, + val isBookmarked: Boolean, +) + +fun Conversation.toDisplayData(bookmarkedIds: Set) = ConversationDisplayData( + conversationId = conversationId ?: "", + title = title ?: "New Chat", + endpoint = endpoint, + model = model, + updatedAt = updatedAt, + isBookmarked = conversationId in bookmarkedIds, +) + +@Composable +fun ConversationItem( + data: ConversationDisplayData, + onClick: () -> Unit, + onActionsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val relativeTime = remember(data.updatedAt) { + data.updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "" + } + + val endpointLabel = remember(data.endpoint) { + data.endpoint?.toDisplayLabel() ?: "Chat" + } + + val endpointIconRes = remember(data.endpoint) { + data.endpoint?.toIconRes() + } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (endpointIconRes != null) { + val isMonochrome = data.endpoint?.isMonochromeIcon() == true + Icon( + painter = painterResource(id = endpointIconRes), + contentDescription = endpointLabel, + modifier = Modifier.size(24.dp), + tint = if (isMonochrome) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + Color.Unspecified + }, + ) + } else { + Icon( + imageVector = Icons.Default.SmartToy, + contentDescription = endpointLabel, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = data.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = endpointLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val modelName = data.model + if (modelName != null) { + Text( + text = " \u00B7 ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + Text( + text = modelName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + + if (relativeTime.isNotEmpty()) { + Text( + text = " \u00B7 ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + Text( + text = relativeTime, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + + if (data.isBookmarked) { + Icon( + imageVector = Icons.Default.Bookmark, + contentDescription = stringResource(R.string.cd_bookmarked), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + IconButton(onClick = onActionsClick) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_conversation_actions), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun Instant.toRelativeTimeString(): String { + val now = Instant.now() + val minutes = ChronoUnit.MINUTES.between(this, now) + val hours = ChronoUnit.HOURS.between(this, now) + val days = ChronoUnit.DAYS.between(this, now) + + return when { + minutes < 1 -> "Just now" + minutes < 60 -> "${minutes}m ago" + hours < 24 -> "${hours}h ago" + days < 7 -> "${days}d ago" + else -> atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("MMM d")) + } +} + +private fun EModelEndpoint.toDisplayLabel(): String = when (this) { + EModelEndpoint.OPENAI -> "OpenAI" + EModelEndpoint.AZURE_OPENAI -> "Azure" + EModelEndpoint.GOOGLE -> "Google" + EModelEndpoint.ANTHROPIC -> "Anthropic" + EModelEndpoint.ASSISTANTS -> "Assistants" + EModelEndpoint.AZURE_ASSISTANTS -> "Azure Assistants" + EModelEndpoint.AGENTS -> "Agents" + EModelEndpoint.CUSTOM -> "Custom" + EModelEndpoint.BEDROCK -> "Bedrock" +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt new file mode 100644 index 0000000..aa1cc34 --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt @@ -0,0 +1,74 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +@Composable +fun ConversationSearchBar( + query: String, + onQueryChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + TextField( + value = query, + onValueChange = onQueryChanged, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.cd_search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChanged("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_clear_search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + placeholder = { + Text( + text = stringResource(R.string.search_conversations), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + textStyle = MaterialTheme.typography.bodyMedium, + singleLine = true, + shape = RoundedCornerShape(28.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(52.dp), + ) +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt new file mode 100644 index 0000000..269373e --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt @@ -0,0 +1,23 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DateGroupHeader( + label: String, + modifier: Modifier = Modifier, +) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(top = 8.dp), + ) +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt new file mode 100644 index 0000000..9edddb2 --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt @@ -0,0 +1,113 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.ConversationTag +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TagFilterBar( + tags: List, + selectedTags: Set, + onTagToggle: (String) -> Unit, + onClearFilter: () -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + if (tags.isEmpty()) return + + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = Icons.Default.BookmarkBorder, + contentDescription = if (expanded) stringResource(R.string.cd_hide_tags) else stringResource(R.string.cd_show_tags), + tint = if (selectedTags.isNotEmpty()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + if (selectedTags.isNotEmpty() && !expanded) { + Text( + text = stringResource(R.string.tags_active_count, selectedTags.size, if (selectedTags.size > 1) "s" else ""), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (selectedTags.isNotEmpty()) { + FilterChip( + selected = false, + onClick = onClearFilter, + label = { Text(stringResource(R.string.clear)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_clear_filter), + modifier = Modifier.size(16.dp), + ) + }, + ) + } + + tags.forEach { tag -> + val tagName = tag.tag ?: return@forEach + FilterChip( + selected = tagName in selectedTags, + onClick = { onTagToggle(tagName) }, + label = { + Text("$tagName (${tag.count})") + }, + ) + } + } + } + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt new file mode 100644 index 0000000..d351e66 --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt @@ -0,0 +1,132 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.core.model.ConversationTag +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun TagPicker( + availableTags: List, + currentTags: List, + onTagsChanged: (List) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + var selectedTags by remember { mutableStateOf(currentTags.toSet()) } + var newTagText by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = { + onTagsChanged(selectedTags.toList()) + onDismiss() + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.tags), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (availableTags.isEmpty() && selectedTags.isEmpty()) { + Text( + text = stringResource(R.string.no_tags_yet), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Show all unique tags (available + currently selected) + val allTags = buildSet { + availableTags.mapNotNull { it.tag }.forEach(::add) + addAll(selectedTags) + } + + allTags.forEach { tag -> + FilterChip( + selected = tag in selectedTags, + onClick = { + selectedTags = if (tag in selectedTags) { + selectedTags - tag + } else { + selectedTags + tag + } + }, + label = { Text(tag) }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newTagText, + onValueChange = { newTagText = it }, + placeholder = { Text(stringResource(R.string.add_new_tag)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + val trimmed = newTagText.trim() + if (trimmed.isNotEmpty()) { + selectedTags = selectedTags + trimmed + newTagText = "" + } + }, + enabled = newTagText.isNotBlank(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_add_tag), + ) + } + } + } + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt new file mode 100644 index 0000000..ea4149a --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt @@ -0,0 +1,83 @@ +package com.librechat.android.feature.conversations.export + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.MessageRepository +import com.librechat.android.core.model.ConversationExport +import com.librechat.android.core.model.Message +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +class ConversationExporter @Inject constructor( + private val conversationRepository: ConversationRepository, + private val messageRepository: MessageRepository, +) { + private val json = Json { + prettyPrint = true + encodeDefaults = true + } + + suspend fun exportAsJson(conversationId: String): Result { + val conversation = when (val result = conversationRepository.getConversation(conversationId)) { + is Result.Success -> result.data + is Result.Error -> return Result.Error(result.exception, result.message) + is Result.Loading -> return Result.Error(message = "Unexpected loading state") + } + val messages = when (val result = messageRepository.getMessages(conversationId)) { + is Result.Success -> result.data + is Result.Error -> return Result.Error(result.exception, result.message) + is Result.Loading -> return Result.Error(message = "Unexpected loading state") + } + val export = ConversationExport( + conversation = conversation, + messages = messages, + exportedAt = System.currentTimeMillis(), + version = 1, + ) + return Result.Success(json.encodeToString(ConversationExport.serializer(), export)) + } + + suspend fun exportAsMarkdown(conversationId: String): Result { + val conversation = when (val result = conversationRepository.getConversation(conversationId)) { + is Result.Success -> result.data + is Result.Error -> return Result.Error(result.exception, result.message) + is Result.Loading -> return Result.Error(message = "Unexpected loading state") + } + val messages = when (val result = messageRepository.getMessages(conversationId)) { + is Result.Success -> result.data + is Result.Error -> return Result.Error(result.exception, result.message) + is Result.Loading -> return Result.Error(message = "Unexpected loading state") + } + + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + val exportDate = dateFormat.format(Date()) + + val sb = StringBuilder() + sb.appendLine("# ${conversation.title ?: "Untitled Conversation"}") + sb.appendLine("Exported on $exportDate") + sb.appendLine() + + for (message in messages) { + val role = if (message.isCreatedByUser) "User" else "Assistant" + sb.appendLine("## $role") + sb.appendLine(extractMessageText(message)) + sb.appendLine() + } + + return Result.Success(sb.toString().trimEnd()) + } + + private fun extractMessageText(message: Message): String { + // Prefer content parts if available, otherwise fall back to text + val parts = message.content + if (!parts.isNullOrEmpty()) { + return parts.mapNotNull { part -> + part.text ?: part.think?.let { "[Thinking] $it" } + }.joinToString("\n") + } + return message.text + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt new file mode 100644 index 0000000..6e1aa3b --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt @@ -0,0 +1,28 @@ +package com.librechat.android.feature.conversations.export + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.model.ConversationExport +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class ConversationImporter @Inject constructor() { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parseJson(jsonString: String): Result { + return try { + val export = json.decodeFromString(ConversationExport.serializer(), jsonString) + if (export.conversation.conversationId == null) { + return Result.Error(message = "Invalid export: missing conversation ID") + } + if (export.messages.isEmpty()) { + return Result.Error(message = "Invalid export: no messages found") + } + Result.Success(export) + } catch (e: Exception) { + Result.Error(e, "Failed to parse conversation file: ${e.message}") + } + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt new file mode 100644 index 0000000..c279354 --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt @@ -0,0 +1,110 @@ +package com.librechat.android.feature.conversations.export + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Description +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +enum class ExportFormat { + JSON, + MARKDOWN, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExportFormatPicker( + onFormatSelected: (ExportFormat) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier.padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.export_format), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + + FormatRow( + icon = Icons.Default.Code, + label = stringResource(R.string.format_json), + description = stringResource(R.string.format_json_description), + onClick = { + onDismiss() + onFormatSelected(ExportFormat.JSON) + }, + ) + + FormatRow( + icon = Icons.Default.Description, + label = stringResource(R.string.format_markdown), + description = stringResource(R.string.format_markdown_description), + onClick = { + onDismiss() + onFormatSelected(ExportFormat.MARKDOWN) + }, + ) + } + } +} + +@Composable +private fun FormatRow( + icon: ImageVector, + label: String, + description: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt new file mode 100644 index 0000000..12a21fe --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt @@ -0,0 +1,32 @@ +package com.librechat.android.feature.conversations.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.conversations.screen.ArchivedConversationsScreen +import com.librechat.android.feature.conversations.screen.ConversationListScreen + +const val CONVERSATIONS_ROUTE = "conversations" +const val ARCHIVED_ROUTE = "conversations/archived" + +fun NavGraphBuilder.conversationsGraph( + onConversationClick: (String) -> Unit, + onNavigateToArchived: () -> Unit = {}, + onNavigateBackFromArchived: () -> Unit = {}, +) { + composable(CONVERSATIONS_ROUTE) { + ScreenTransitionWrapper(transition) { + ConversationListScreen( + onConversationClick = onConversationClick, + onNavigateToArchived = onNavigateToArchived, + ) + } + } + composable(ARCHIVED_ROUTE) { + ScreenTransitionWrapper(transition) { + ArchivedConversationsScreen( + onNavigateBack = onNavigateBackFromArchived, + ) + } + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt new file mode 100644 index 0000000..6222cc8 --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt @@ -0,0 +1,223 @@ +package com.librechat.android.feature.conversations.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Unarchive +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.conversations.ArchivedConversationDisplayData +import com.librechat.android.core.ui.components.EmptyState +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.conversations.viewmodel.ArchivedConversationsEvent +import com.librechat.android.feature.conversations.viewmodel.ArchivedConversationsViewModel +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArchivedConversationsScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ArchivedConversationsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var deleteConfirmation by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is ArchivedConversationsEvent.ShowError -> { + snackbarHostState.showSnackbar(event.message) + } + } + } + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.archived_conversations)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + uiState.isLoading && uiState.conversations.isEmpty() -> { + LoadingIndicator() + } + !uiState.isLoading && uiState.conversations.isEmpty() && uiState.error != null -> { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.could_not_load_archived), + onRetry = { + viewModel.dismissError() + viewModel.loadArchivedConversations() + }, + ) + } + !uiState.isLoading && uiState.conversations.isEmpty() -> { + EmptyState( + title = stringResource(R.string.no_archived_conversations), + description = stringResource(R.string.archived_will_appear_here), + icon = Icons.Default.Archive, + ) + } + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + items = uiState.conversations, + key = { it.id }, + contentType = { "archived_conversation" }, + ) { displayData -> + ArchivedConversationItem( + data = displayData, + onUnarchive = { + viewModel.unarchiveConversation(displayData.id) + }, + onDelete = { + deleteConfirmation = displayData + }, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } + } + } + } + } + + if (deleteConfirmation != null) { + AlertDialog( + onDismissRequest = { deleteConfirmation = null }, + title = { Text(stringResource(R.string.delete_conversation)) }, + text = { + Text( + text = stringResource(R.string.delete_confirmation_message, deleteConfirmation?.title ?: stringResource(R.string.this_conversation)), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = { + deleteConfirmation?.id?.let { id -> + viewModel.deleteConversation(id) + } + deleteConfirmation = null + }) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { deleteConfirmation = null }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun ArchivedConversationItem( + data: ArchivedConversationDisplayData, + onUnarchive: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = data.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (data.model != null) "${data.endpoint} \u00B7 ${data.model}" else data.endpoint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton(onClick = onUnarchive) { + Icon( + imageVector = Icons.Default.Unarchive, + contentDescription = stringResource(R.string.cd_unarchive), + tint = MaterialTheme.colorScheme.primary, + ) + } + + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt new file mode 100644 index 0000000..f6cb92f --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt @@ -0,0 +1,435 @@ +package com.librechat.android.feature.conversations.screen + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.ui.components.EmptyState +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.conversations.components.ConversationActions +import com.librechat.android.feature.conversations.components.ConversationItem +import com.librechat.android.feature.conversations.components.ConversationSearchBar +import com.librechat.android.feature.conversations.components.DateGroupHeader +import com.librechat.android.feature.conversations.components.TagFilterBar +import com.librechat.android.feature.conversations.components.TagPicker +import com.librechat.android.feature.conversations.export.ExportFormat +import com.librechat.android.feature.conversations.export.ExportFormatPicker +import com.librechat.android.feature.conversations.viewmodel.ConversationListEvent +import com.librechat.android.feature.conversations.viewmodel.ConversationListViewModel +import com.librechat.android.feature.conversations.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationListScreen( + onConversationClick: (String) -> Unit, + modifier: Modifier = Modifier, + onNewChatClick: () -> Unit = {}, + onNavigateToArchived: () -> Unit = {}, + viewModel: ConversationListViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + var selectedConversation by remember { mutableStateOf(null) } + var showTagPicker by remember { mutableStateOf(false) } + var tagPickerConversation by remember { mutableStateOf(null) } + var showExportFormatPicker by remember { mutableStateOf(false) } + var exportConversation by remember { mutableStateOf(null) } + var pendingExportContent by remember { mutableStateOf(null) } + + val snackbarHostState = remember { SnackbarHostState() } + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + val copyOfPrefix = stringResource(R.string.copy_of) + val newChatLabel = stringResource(R.string.new_chat) + + // SAF launcher for saving exported file + val exportFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("*/*"), + ) { uri -> + if (uri != null && pendingExportContent != null) { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(pendingExportContent!!.toByteArray()) + } + Toast.makeText(context, context.getString(R.string.conversation_exported), Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(context, context.getString(R.string.export_failed, e.message ?: ""), Toast.LENGTH_SHORT).show() + } + } + pendingExportContent = null + } + + // SAF launcher for importing a JSON file + val importFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + try { + val jsonContent = context.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.bufferedReader().readText() + } + if (jsonContent != null) { + viewModel.importConversation(jsonContent) + } + } catch (e: Exception) { + Toast.makeText(context, context.getString(R.string.import_failed, e.message ?: ""), Toast.LENGTH_SHORT).show() + } + } + } + + // Collect one-shot events + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is ConversationListEvent.ShareLinkCopied -> { + clipboardManager.setPrimaryClip(ClipData.newPlainText("Share Link", event.url)) + snackbarHostState.showSnackbar(context.getString(R.string.link_copied)) + } + is ConversationListEvent.NavigateToConversation -> { + onConversationClick(event.conversationId) + } + is ConversationListEvent.ShowError -> { + snackbarHostState.showSnackbar(event.message) + } + is ConversationListEvent.ExportReady -> { + pendingExportContent = event.content + val ext = when (event.format) { + ExportFormat.JSON -> "json" + ExportFormat.MARKDOWN -> "md" + } + val safeTitle = event.title.replace(Regex("[^a-zA-Z0-9._-]"), "_") + exportFileLauncher.launch("$safeTitle.$ext") + } + is ConversationListEvent.ImportSuccess -> { + snackbarHostState.showSnackbar(context.getString(R.string.imported_title, event.title)) + } + } + } + } + + val listState = rememberLazyListState() + + // Detect when we scroll near the end to trigger load more + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleIndex >= totalItems - 3 && uiState.hasMore && !uiState.isLoading + } + } + + LaunchedEffect(Unit) { + snapshotFlow { shouldLoadMore } + .collect { shouldLoad -> + if (shouldLoad) { + viewModel.loadMore() + } + } + } + + val groupedConversations = uiState.groupedConversations + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + FloatingActionButton( + onClick = onNewChatClick, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_new_chat), + ) + } + }, + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Search bar with archive button + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationSearchBar( + query = uiState.searchQuery, + onQueryChanged = viewModel::onSearchQueryChanged, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onNavigateToArchived) { + Icon( + imageVector = Icons.Default.Archive, + contentDescription = stringResource(R.string.cd_archived_conversations), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Tag filter bar + TagFilterBar( + tags = uiState.tags, + selectedTags = uiState.selectedTags, + onTagToggle = viewModel::toggleTag, + onClearFilter = viewModel::clearTagFilter, + ) + + when { + // Initial loading state + uiState.isLoading && uiState.conversationCount == 0 -> { + LoadingIndicator() + } + + // Searching state + uiState.isSearching -> { + LoadingIndicator() + } + + // Error state when no conversations loaded + !uiState.isLoading && uiState.conversationCount == 0 && uiState.error != null -> { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.could_not_load_conversations), + onRetry = { + viewModel.dismissError() + viewModel.loadConversations() + }, + ) + } + + // Empty state (not loading, no conversations) + !uiState.isLoading && uiState.conversationCount == 0 -> { + EmptyState( + title = if (uiState.searchQuery.isNotEmpty()) { + stringResource(R.string.no_results_found) + } else { + stringResource(R.string.no_conversations_yet) + }, + description = if (uiState.searchQuery.isNotEmpty()) { + stringResource(R.string.try_different_search) + } else { + stringResource(R.string.start_new_chat) + }, + icon = Icons.Default.Forum, + ) + } + + // Content state + else -> { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 80.dp), + ) { + // Error banner at top if present + if (uiState.error != null) { + item(key = "error") { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.unknown_error), + onRetry = { + viewModel.dismissError() + viewModel.loadConversations() + }, + ) + } + } + + groupedConversations.forEach { (dateGroup, displayItems) -> + // Date group header + item(key = "header_$dateGroup") { + DateGroupHeader(label = dateGroup) + } + + // Conversation items in this group + items( + items = displayItems, + key = { it.conversationId }, + contentType = { "conversation" }, + ) { displayData -> + ConversationItem( + data = displayData, + onClick = { + onConversationClick(displayData.conversationId) + }, + onActionsClick = { + selectedConversation = viewModel.getConversation( + displayData.conversationId, + ) + }, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 52.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } + + // Loading indicator at bottom when loading more + if (uiState.isLoading && uiState.conversationCount > 0) { + item(key = "loading_more") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } + + // Bottom sheet for conversation actions + if (selectedConversation != null) { + ConversationActions( + conversation = selectedConversation!!, + onDismiss = { selectedConversation = null }, + isBookmarked = selectedConversation?.conversationId?.let { + viewModel.isBookmarked(it) + } ?: false, + onBookmarkToggle = { + selectedConversation?.conversationId?.let { id -> + viewModel.toggleBookmark(id) + } + selectedConversation = null + }, + onRename = { newTitle -> + selectedConversation?.conversationId?.let { id -> + viewModel.renameConversation(id, newTitle) + } + selectedConversation = null + }, + onArchive = { + selectedConversation?.conversationId?.let { id -> + viewModel.archiveConversation(id) + } + selectedConversation = null + }, + onDelete = { + selectedConversation?.conversationId?.let { id -> + viewModel.deleteConversation(id) + } + selectedConversation = null + }, + onTags = { + tagPickerConversation = selectedConversation + showTagPicker = true + selectedConversation = null + }, + onShare = { + selectedConversation?.conversationId?.let { id -> + viewModel.shareConversation(id) + } + selectedConversation = null + }, + onFork = { + // Fork is a message-level action (requires a specific messageId). + // Use Duplicate for conversation-level copying. + selectedConversation = null + }, + onDuplicate = { + val convo = selectedConversation + convo?.conversationId?.let { id -> + viewModel.duplicateConversation(id, copyOfPrefix.format(convo.title ?: newChatLabel)) + } + selectedConversation = null + }, + onExport = { + exportConversation = selectedConversation + showExportFormatPicker = true + selectedConversation = null + }, + ) + } + + // Tag picker bottom sheet + if (showTagPicker && tagPickerConversation != null) { + TagPicker( + availableTags = uiState.tags, + currentTags = tagPickerConversation?.tags ?: emptyList(), + onTagsChanged = { newTags -> + tagPickerConversation?.conversationId?.let { id -> + viewModel.updateConversationTags(id, newTags) + } + }, + onDismiss = { + showTagPicker = false + tagPickerConversation = null + }, + ) + } + + // Export format picker + if (showExportFormatPicker && exportConversation != null) { + ExportFormatPicker( + onFormatSelected = { format -> + exportConversation?.conversationId?.let { id -> + viewModel.exportConversation(id, exportConversation?.title, format) + } + showExportFormatPicker = false + exportConversation = null + }, + onDismiss = { + showExportFormatPicker = false + exportConversation = null + }, + ) + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt new file mode 100644 index 0000000..d641e0d --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt @@ -0,0 +1,137 @@ +package com.librechat.android.feature.conversations.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.model.Conversation +import com.librechat.android.feature.conversations.ArchivedConversationDisplayData +import com.librechat.android.feature.conversations.toArchivedDisplayData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class ArchivedConversationsUiState( + val conversations: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, +) + +sealed interface ArchivedConversationsEvent { + data class ShowError(val message: String) : ArchivedConversationsEvent +} + +@HiltViewModel +class ArchivedConversationsViewModel @Inject constructor( + private val conversationRepository: ConversationRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ArchivedConversationsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + /** Raw conversations kept for delete confirmation dialog titles. */ + private var rawConversations: List = emptyList() + + init { + observeArchivedConversations() + loadArchivedConversations() + } + + fun getConversation(conversationId: String): Conversation? = + rawConversations.firstOrNull { it.conversationId == conversationId } + + private fun observeArchivedConversations() { + viewModelScope.launch { + conversationRepository.observeConversations(isArchived = true).collect { result -> + when (result) { + is Result.Success -> { + rawConversations = result.data + _uiState.value = _uiState.value.copy( + conversations = result.data.map { it.toArchivedDisplayData() }, + isLoading = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message, + isLoading = false, + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + } + + fun loadArchivedConversations() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + when (val result = conversationRepository.loadNextPage(cursor = null, isArchived = true)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isLoading = false) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message, + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true) + when (val result = conversationRepository.loadNextPage(cursor = null, isArchived = true)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isRefreshing = false) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = result.message, + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun unarchiveConversation(id: String) { + viewModelScope.launch { + try { + conversationRepository.archive(id, false) + } catch (e: Exception) { + _events.emit(ArchivedConversationsEvent.ShowError("Failed to unarchive conversation")) + } + } + } + + fun deleteConversation(id: String) { + viewModelScope.launch { + try { + conversationRepository.delete(id) + } catch (e: Exception) { + _events.emit(ArchivedConversationsEvent.ShowError("Failed to delete conversation")) + } + } + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt new file mode 100644 index 0000000..57aa95e --- /dev/null +++ b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt @@ -0,0 +1,452 @@ +package com.librechat.android.feature.conversations.viewmodel + +import android.util.Log +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.extensions.toInstantOrNull +import com.librechat.android.core.common.extensions.toRelativeDateGroup +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.SearchRepository +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.core.data.repository.TagRepository +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.ConversationTag +import com.librechat.android.feature.conversations.components.ConversationDisplayData +import com.librechat.android.feature.conversations.components.toDisplayData +import com.librechat.android.feature.conversations.export.ConversationExporter +import com.librechat.android.feature.conversations.export.ConversationImporter +import com.librechat.android.feature.conversations.export.ExportFormat +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class ConversationListUiState( + val groupedConversations: List>> = emptyList(), + val conversationCount: Int = 0, + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + val nextCursor: String? = null, + val hasMore: Boolean = true, + val tags: List = emptyList(), + val selectedTags: Set = emptySet(), + val searchQuery: String = "", + val isSearching: Boolean = false, +) + +sealed interface ConversationListEvent { + data class ShareLinkCopied(val url: String) : ConversationListEvent + data class NavigateToConversation(val conversationId: String) : ConversationListEvent + data class ShowError(val message: String) : ConversationListEvent + data class ExportReady(val content: String, val format: ExportFormat, val title: String) : ConversationListEvent + data class ImportSuccess(val title: String) : ConversationListEvent +} + +@HiltViewModel +class ConversationListViewModel @Inject constructor( + private val conversationRepository: ConversationRepository, + private val tagRepository: TagRepository, + private val searchRepository: SearchRepository, + private val shareRepository: ShareRepository, + private val conversationExporter: ConversationExporter, + private val conversationImporter: ConversationImporter, + private val settingsDataStore: SettingsDataStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConversationListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + /** Raw conversations kept for internal lookups (action sheets, etc.). */ + private var conversations: List = emptyList() + private var bookmarkedIds: Set = emptySet() + private var searchJob: Job? = null + + init { + loadConversations() + observeConversations() + observeBookmarks() + loadTags() + } + + private fun observeConversations() { + viewModelScope.launch { + conversationRepository.observeConversations().collect { result -> + when (result) { + is Result.Success -> { + // Only update from Room when not in search mode + if (_uiState.value.searchQuery.isEmpty()) { + conversations = result.data + recomputeGroupedConversations() + _uiState.value = _uiState.value.copy(isLoading = false) + } + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message, + isLoading = false, + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + } + + private fun observeBookmarks() { + viewModelScope.launch { + settingsDataStore.bookmarkedConversationIds.collect { ids -> + bookmarkedIds = ids + recomputeGroupedConversations() + } + } + } + + private fun recomputeGroupedConversations() { + val grouped = groupConversationsByDate(conversations, bookmarkedIds) + _uiState.value = _uiState.value.copy( + groupedConversations = grouped, + conversationCount = conversations.size, + ) + } + + fun getConversation(conversationId: String): Conversation? = + conversations.firstOrNull { it.conversationId == conversationId } + + fun isBookmarked(conversationId: String): Boolean = + conversationId in bookmarkedIds + + fun toggleBookmark(conversationId: String) { + viewModelScope.launch { + settingsDataStore.toggleBookmark(conversationId) + } + } + + fun loadConversations() { + val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null } + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + nextCursor = result.data, + hasMore = result.data != null, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message, + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun loadMore() { + val cursor = _uiState.value.nextCursor ?: return + if (_uiState.value.isLoading) return + + val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null } + viewModelScope.launch { + when (val result = conversationRepository.loadNextPage(cursor, tags = activeTags)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + nextCursor = result.data, + hasMore = result.data != null, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy(error = result.message) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true) + val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null } + when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + nextCursor = result.data, + hasMore = result.data != null, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = result.message, + ) + } + is Result.Loading -> { /* no-op */ } + } + loadTags() + } + } + + private fun loadTags() { + viewModelScope.launch { + when (val result = tagRepository.getTags()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + tags = result.data.filter { it.count > 0 }, + ) + } + is Result.Error -> { + // Tags are non-critical, silently fail + Log.d("ConversationListVM", "Failed to load tags: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun toggleTag(tag: String) { + val current = _uiState.value.selectedTags + val updated = if (tag in current) current - tag else current + tag + _uiState.value = _uiState.value.copy(selectedTags = updated) + // Reload conversations with new tag filter + loadConversations() + } + + fun clearTagFilter() { + _uiState.value = _uiState.value.copy(selectedTags = emptySet()) + loadConversations() + } + + fun onSearchQueryChanged(query: String) { + _uiState.value = _uiState.value.copy(searchQuery = query) + searchJob?.cancel() + + if (query.isBlank()) { + _uiState.value = _uiState.value.copy(isSearching = false) + // Reload normal conversation list + loadConversations() + return + } + + searchJob = viewModelScope.launch { + delay(500) // 500ms debounce + _uiState.value = _uiState.value.copy(isSearching = true) + when (val result = searchRepository.search(query)) { + is Result.Success -> { + conversations = result.data + recomputeGroupedConversations() + _uiState.value = _uiState.value.copy( + isSearching = false, + hasMore = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isSearching = false, + error = result.message, + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun updateConversationTags(conversationId: String, tags: List) { + viewModelScope.launch { + when (tagRepository.updateConversationTags(conversationId, tags)) { + is Result.Success -> { + loadTags() + refresh() + } + is Result.Error -> { + _events.emit(ConversationListEvent.ShowError("Failed to update tags")) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun renameConversation(id: String, newTitle: String) { + viewModelScope.launch { + try { + conversationRepository.updateTitle(id, newTitle) + } catch (e: Exception) { + _events.emit(ConversationListEvent.ShowError("Failed to rename conversation")) + } + } + } + + fun archiveConversation(id: String) { + viewModelScope.launch { + try { + conversationRepository.archive(id, true) + } catch (e: Exception) { + _events.emit(ConversationListEvent.ShowError("Failed to archive conversation")) + } + } + } + + fun deleteConversation(id: String) { + viewModelScope.launch { + try { + conversationRepository.delete(id) + } catch (e: Exception) { + _events.emit(ConversationListEvent.ShowError("Failed to delete conversation")) + } + } + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun shareConversation(conversationId: String) { + viewModelScope.launch { + when (val result = shareRepository.createShareLink(conversationId)) { + is Result.Success -> { + _events.emit(ConversationListEvent.ShareLinkCopied(result.data)) + } + is Result.Error -> { + _events.emit( + ConversationListEvent.ShowError(result.message ?: "Failed to create share link"), + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun forkConversation(conversationId: String, messageId: String) { + viewModelScope.launch { + when (val result = conversationRepository.forkConversation(conversationId, messageId)) { + is Result.Success -> { + result.data.conversationId?.let { newId -> + _events.emit(ConversationListEvent.NavigateToConversation(newId)) + } + } + is Result.Error -> { + _events.emit( + ConversationListEvent.ShowError(result.message ?: "Failed to fork conversation"), + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun duplicateConversation(conversationId: String, title: String?) { + viewModelScope.launch { + when (val result = conversationRepository.duplicateConversation(conversationId, title)) { + is Result.Success -> { + result.data.conversationId?.let { newId -> + _events.emit(ConversationListEvent.NavigateToConversation(newId)) + } + } + is Result.Error -> { + _events.emit( + ConversationListEvent.ShowError(result.message ?: "Failed to duplicate conversation"), + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun exportConversation(conversationId: String, title: String?, format: ExportFormat) { + viewModelScope.launch { + val result = when (format) { + ExportFormat.JSON -> conversationExporter.exportAsJson(conversationId) + ExportFormat.MARKDOWN -> conversationExporter.exportAsMarkdown(conversationId) + } + when (result) { + is Result.Success -> { + _events.emit( + ConversationListEvent.ExportReady( + content = result.data, + format = format, + title = title ?: "conversation", + ), + ) + } + is Result.Error -> { + _events.emit( + ConversationListEvent.ShowError(result.message ?: "Failed to export conversation"), + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun importConversation(jsonContent: String) { + viewModelScope.launch { + // First validate the JSON format + when (val parseResult = conversationImporter.parseJson(jsonContent)) { + is Result.Success -> { + val export = parseResult.data + // Now upload to server + when (val importResult = conversationRepository.importConversation(jsonContent)) { + is Result.Success -> { + _events.emit( + ConversationListEvent.ImportSuccess( + title = export.conversation.title ?: "Imported conversation", + ), + ) + refresh() + } + is Result.Error -> { + _events.emit( + ConversationListEvent.ShowError( + importResult.message ?: "Failed to upload conversation to server", + ), + ) + } + is Result.Loading -> { /* no-op */ } + } + } + is Result.Error -> { + _events.emit( + ConversationListEvent.ShowError(parseResult.message ?: "Failed to parse conversation file"), + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + companion object { + private fun groupConversationsByDate( + conversations: List, + bookmarkedIds: Set, + ): List>> { + if (conversations.isEmpty()) return emptyList() + return conversations + .groupBy { conversation -> + conversation.updatedAt + ?.toInstantOrNull() + ?.toRelativeDateGroup() + ?: "Unknown" + } + .map { (group, convos) -> + group to convos.map { it.toDisplayData(bookmarkedIds) } + } + } + } +} diff --git a/feature/conversations/src/main/res/values/strings.xml b/feature/conversations/src/main/res/values/strings.xml new file mode 100644 index 0000000..15e1555 --- /dev/null +++ b/feature/conversations/src/main/res/values/strings.xml @@ -0,0 +1,79 @@ + + + + Rename + Cancel + Delete + Archive + Unarchive + Export + Share + Duplicate + Clear + Back + New Chat + + + New chat + Archived conversations + Search + Clear search + Bookmarked + Conversation actions + Add tag + Hide tags + Show tags + Clear filter + Unarchive + Delete + + + Conversations + Search conversations\u2026 + Could not load conversations + No results found + No conversations yet + Try a different search term + Start a new chat to begin + An unknown error occurred + Conversation exported + Export failed: %1$s + Import failed: %1$s + Link copied! + Imported: %1$s + Copy of %1$s + + + Remove Bookmark + Bookmark + Tags + Rename conversation + Enter a new title for this conversation. + Title + Delete conversation + Are you sure you want to delete \"%1$s\"? This action cannot be undone. + this conversation + + + No tags yet. Add one below. + Add new tag + + + %1$d tag%2$s active + + + Export format + JSON + Full data export, can be imported back + Markdown + Readable text format + + + Archived Conversations + Could not load archived conversations + No archived conversations + Archived conversations will appear here + + + Chat + diff --git a/feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt b/feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt new file mode 100644 index 0000000..6186958 --- /dev/null +++ b/feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt @@ -0,0 +1,448 @@ +package com.librechat.android.feature.conversations.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.SearchRepository +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.core.data.repository.TagRepository +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.ConversationTag +import com.librechat.android.feature.conversations.export.ConversationExporter +import com.librechat.android.feature.conversations.export.ConversationImporter +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationListViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private val conversationRepository = mockk(relaxed = true) + private val tagRepository = mockk(relaxed = true) + private val searchRepository = mockk(relaxed = true) + private val shareRepository = mockk(relaxed = true) + private val conversationExporter = mockk(relaxed = true) + private val conversationImporter = mockk(relaxed = true) + private val settingsDataStore = mockk(relaxed = true) + + private lateinit var viewModel: ConversationListViewModel + + private val testConversations = listOf( + Conversation( + conversationId = "convo-1", + title = "First Conversation", + updatedAt = "2026-02-19T10:00:00.000Z", + ), + Conversation( + conversationId = "convo-2", + title = "Second Conversation", + updatedAt = "2026-02-19T09:00:00.000Z", + ), + ) + + private val testTags = listOf( + ConversationTag(tag = "work", count = 3), + ConversationTag(tag = "personal", count = 2), + ConversationTag(tag = "empty", count = 0), + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { conversationRepository.observeConversations(any()) } returns flowOf( + Result.Success(testConversations), + ) + every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet()) + coEvery { conversationRepository.loadNextPage(any(), tags = any()) } returns Result.Success(null) + coEvery { tagRepository.getTags() } returns Result.Success(testTags) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = ConversationListViewModel( + conversationRepository = conversationRepository, + tagRepository = tagRepository, + searchRepository = searchRepository, + shareRepository = shareRepository, + conversationExporter = conversationExporter, + conversationImporter = conversationImporter, + settingsDataStore = settingsDataStore, + ) + + @Test + fun `initial state loads conversations from Room observation`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.conversationCount).isEqualTo(2) + assertThat(state.isLoading).isFalse() + } + + @Test + fun `initial state loads tags with non-zero counts`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.tags).hasSize(2) + assertThat(state.tags.map { it.tag }).containsExactly("work", "personal") + } + + @Test + fun `loadConversations sets isLoading then clears on success`() = runTest { + coEvery { conversationRepository.loadNextPage(null, tags = any()) } returns Result.Success("next-cursor") + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.isLoading).isFalse() + assertThat(state.nextCursor).isEqualTo("next-cursor") + assertThat(state.hasMore).isTrue() + } + + @Test + fun `loadConversations with no more pages sets hasMore false`() = runTest { + coEvery { conversationRepository.loadNextPage(null, tags = any()) } returns Result.Success(null) + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.hasMore).isFalse() + } + + @Test + fun `loadConversations error surfaces error message`() = runTest { + coEvery { conversationRepository.loadNextPage(null, tags = any()) } returns + Result.Error(message = "Network error") + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.error).isEqualTo("Network error") + assertThat(state.isLoading).isFalse() + } + + @Test + fun `loadMore fetches next page using cursor`() = runTest { + coEvery { conversationRepository.loadNextPage(null, tags = any()) } returns Result.Success("cursor-1") + coEvery { conversationRepository.loadNextPage("cursor-1", tags = any()) } returns Result.Success("cursor-2") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.nextCursor).isEqualTo("cursor-2") + coVerify { conversationRepository.loadNextPage("cursor-1", tags = any()) } + } + + @Test + fun `loadMore is no-op when no cursor`() = runTest { + coEvery { conversationRepository.loadNextPage(null, tags = any()) } returns Result.Success(null) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + coVerify(exactly = 1) { conversationRepository.loadNextPage(any(), tags = any()) } + } + + @Test + fun `toggleTag adds tag to selection and reloads`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.toggleTag("work") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.selectedTags).containsExactly("work") + } + + @Test + fun `toggleTag removes already selected tag`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.toggleTag("work") + advanceUntilIdle() + viewModel.toggleTag("work") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.selectedTags).isEmpty() + } + + @Test + fun `clearTagFilter clears all selected tags`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.toggleTag("work") + viewModel.toggleTag("personal") + advanceUntilIdle() + + viewModel.clearTagFilter() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.selectedTags).isEmpty() + } + + @Test + fun `onSearchQueryChanged with blank query clears search mode`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.isSearching).isFalse() + assertThat(viewModel.uiState.value.searchQuery).isEmpty() + } + + @Test + fun `onSearchQueryChanged with query triggers search after debounce`() = runTest { + val searchResults = listOf( + Conversation(conversationId = "search-1", title = "Search Result"), + ) + coEvery { searchRepository.search("hello") } returns Result.Success(searchResults) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("hello") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.searchQuery).isEqualTo("hello") + assertThat(state.isSearching).isFalse() + assertThat(state.hasMore).isFalse() + assertThat(state.conversationCount).isEqualTo(1) + } + + @Test + fun `search error surfaces error message`() = runTest { + coEvery { searchRepository.search(any()) } returns Result.Error(message = "Search failed") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("test") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Search failed") + } + + @Test + fun `deleteConversation calls repository`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.deleteConversation("convo-1") + advanceUntilIdle() + + coVerify { conversationRepository.delete("convo-1") } + } + + @Test + fun `archiveConversation calls repository`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.archiveConversation("convo-1") + advanceUntilIdle() + + coVerify { conversationRepository.archive("convo-1", true) } + } + + @Test + fun `renameConversation calls repository`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.renameConversation("convo-1", "New Title") + advanceUntilIdle() + + coVerify { conversationRepository.updateTitle("convo-1", "New Title") } + } + + @Test + fun `shareConversation emits ShareLinkCopied event on success`() = runTest { + coEvery { shareRepository.createShareLink("convo-1") } returns + Result.Success("https://example.com/share/abc") + + viewModel = createViewModel() + advanceUntilIdle() + + val events = mutableListOf() + val job = launch { + viewModel.events.collect { events.add(it) } + } + + viewModel.shareConversation("convo-1") + advanceUntilIdle() + + assertThat(events).hasSize(1) + assertThat(events[0]).isInstanceOf(ConversationListEvent.ShareLinkCopied::class.java) + assertThat((events[0] as ConversationListEvent.ShareLinkCopied).url) + .isEqualTo("https://example.com/share/abc") + + job.cancel() + } + + @Test + fun `shareConversation emits ShowError on failure`() = runTest { + coEvery { shareRepository.createShareLink("convo-1") } returns + Result.Error(message = "Share failed") + + viewModel = createViewModel() + advanceUntilIdle() + + val events = mutableListOf() + val job = launch { + viewModel.events.collect { events.add(it) } + } + + viewModel.shareConversation("convo-1") + advanceUntilIdle() + + assertThat(events).hasSize(1) + assertThat(events[0]).isInstanceOf(ConversationListEvent.ShowError::class.java) + + job.cancel() + } + + @Test + fun `dismissError clears error state`() = runTest { + coEvery { conversationRepository.loadNextPage(null, tags = any()) } returns + Result.Error(message = "Error") + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isNotNull() + + viewModel.dismissError() + + assertThat(viewModel.uiState.value.error).isNull() + } + + @Test + fun `refresh resets and reloads conversations`() = runTest { + coEvery { conversationRepository.loadNextPage(null, tags = any()) } returns Result.Success(null) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.isRefreshing).isFalse() + } + + @Test + fun `getConversation returns matching conversation`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val convo = viewModel.getConversation("convo-1") + assertThat(convo).isNotNull() + assertThat(convo?.title).isEqualTo("First Conversation") + } + + @Test + fun `getConversation returns null for unknown id`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.getConversation("nonexistent")).isNull() + } + + @Test + fun `forkConversation emits NavigateToConversation on success`() = runTest { + val forkedConvo = Conversation(conversationId = "forked-1", title = "Forked") + coEvery { conversationRepository.forkConversation("convo-1", "msg-1") } returns + Result.Success(forkedConvo) + + viewModel = createViewModel() + advanceUntilIdle() + + val events = mutableListOf() + val job = launch { + viewModel.events.collect { events.add(it) } + } + + viewModel.forkConversation("convo-1", "msg-1") + advanceUntilIdle() + + assertThat(events).hasSize(1) + assertThat(events[0]).isInstanceOf(ConversationListEvent.NavigateToConversation::class.java) + assertThat((events[0] as ConversationListEvent.NavigateToConversation).conversationId) + .isEqualTo("forked-1") + + job.cancel() + } + + @Test + fun `duplicateConversation emits NavigateToConversation on success`() = runTest { + val duplicatedConvo = Conversation(conversationId = "dup-1", title = "Copy of First") + coEvery { conversationRepository.duplicateConversation("convo-1", "Copy") } returns + Result.Success(duplicatedConvo) + + viewModel = createViewModel() + advanceUntilIdle() + + val events = mutableListOf() + val job = launch { + viewModel.events.collect { events.add(it) } + } + + viewModel.duplicateConversation("convo-1", "Copy") + advanceUntilIdle() + + assertThat(events).hasSize(1) + assertThat(events[0]).isInstanceOf(ConversationListEvent.NavigateToConversation::class.java) + + job.cancel() + } + + @Test + fun `updateConversationTags reloads on success`() = runTest { + coEvery { tagRepository.updateConversationTags("convo-1", listOf("work")) } returns + Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.updateConversationTags("convo-1", listOf("work")) + advanceUntilIdle() + + coVerify { tagRepository.updateConversationTags("convo-1", listOf("work")) } + } +} diff --git a/feature/files/CLAUDE.md b/feature/files/CLAUDE.md new file mode 100644 index 0000000..07e4113 --- /dev/null +++ b/feature/files/CLAUDE.md @@ -0,0 +1,47 @@ +# feature:files + +## Screen +`FilesScreen` -- single route `FILES_ROUTE`. Lists uploaded files with upload and delete actions. + +## ViewModel +`FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver). + +### State +`FilesUiState`: `files: List`, `isLoading`, `isRefreshing`, `isUploading`, `error` + +### Operations +| Action | Method | Notes | +|--------|--------|-------| +| Load | `loadFiles()` | `FileRepository.getFiles()` | +| Refresh | `refresh()` | Same endpoint, sets `isRefreshing` | +| Upload | `uploadFile(uri)` | Reads bytes via ContentResolver, gets filename from `OpenableColumns.DISPLAY_NAME`, calls `FileRepository.uploadFile(bytes, filename, mimeType)` | +| Delete | `deleteFile(fileId)` | `FileRepository.deleteFiles(listOf(fileId))`, removes from local list | + +## Upload Flow +1. User picks file via SAF (`ActivityResultContracts.GetContent`) +2. `ContentResolver` reads bytes and resolves filename + MIME type +3. Multipart upload via `FilesApi` in `:core:network` +4. New file prepended to list on success + +## Image Viewing +- `FullscreenImageViewer` (in `feature:chat/components/`) provides pinch-to-zoom and pan +- Image URLs: `/api/files/download/:userId/:file_id` for server files +- Loaded via Coil `AsyncImage` with placeholder and error states + +## Data Layer +- `FileRepository` in `:core:data` wraps `FilesApi` from `:core:network` +- API endpoints: `GET /api/files` (list), `POST /api/files` (upload, multipart), `DELETE /api/files` (batch delete) + +## Spec Notes (not yet implemented) +- Multi-select mode for bulk deletion +- File preview on tap (non-image files) + +### File Type Filtering +- `FileTypeFilterBar` — horizontal FilterChip row: All, Images, Documents, Audio, Video +- Filters by MIME type prefix in `FilesViewModel` + +### Upload Progress +- `UploadProgressCard` — animated card with filename, LinearProgressIndicator, cancel button +- Replaces the old fullscreen CircularProgressIndicator overlay +- `FilesViewModel` tracks `uploadProgress` and `uploadFilename` in state +- Cancel via stored upload Job reference diff --git a/feature/files/build.gradle.kts b/feature/files/build.gradle.kts new file mode 100644 index 0000000..073105d --- /dev/null +++ b/feature/files/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("librechat.android.feature") +} + +android { + namespace = "com.librechat.android.feature.files" +} + +dependencies { + implementation(project(":core:network")) + implementation(libs.coil.compose) + implementation(libs.timber) +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/FileDisplayData.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/FileDisplayData.kt new file mode 100644 index 0000000..e64f7e8 --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/FileDisplayData.kt @@ -0,0 +1,26 @@ +package com.librechat.android.feature.files + +import androidx.compose.runtime.Immutable + +@Immutable +data class FileDisplayData( + val fileId: String, + val filename: String, + val type: String, + val formattedSize: String, + val createdAt: String?, + val previewUrl: String?, +) + +@Immutable +data class FilePreviewDisplayData( + val fileId: String, + val filename: String, + val type: String, + val formattedSize: String, + val createdAt: String?, + val source: String?, + val previewUrl: String?, + val userId: String?, + val downloadUrl: String?, +) diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt new file mode 100644 index 0000000..8f24040 --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt @@ -0,0 +1,36 @@ +package com.librechat.android.feature.files.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.files.viewmodel.FileTypeFilter + +/** Horizontal FilterChip row that filters files by MIME type prefix (All, Images, Documents, Audio, Video). */ +@Composable +fun FileTypeFilterBar( + selectedFilter: FileTypeFilter, + onFilterChange: (FileTypeFilter) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FileTypeFilter.entries.forEach { filter -> + FilterChip( + selected = filter == selectedFilter, + onClick = { onFilterChange(filter) }, + label = { Text(filter.label) }, + ) + } + } +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt new file mode 100644 index 0000000..de3ff9a --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt @@ -0,0 +1,106 @@ +package com.librechat.android.feature.files.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.files.R +import androidx.compose.ui.res.stringResource + +/** Animated card showing upload filename, progress bar, and cancel button with slide/fade visibility transitions. */ +@Composable +fun UploadProgressCard( + visible: Boolean, + filename: String, + progress: Float?, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = modifier, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp, end = 4.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.UploadFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = filename, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + if (progress != null) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = if (progress != null) { + "${(progress * 100).toInt()}%" + } else { + stringResource(R.string.uploading) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onCancel) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_cancel_upload), + ) + } + } + } + } +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt new file mode 100644 index 0000000..d346391 --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt @@ -0,0 +1,16 @@ +package com.librechat.android.feature.files.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.files.screen.FilesScreen + +const val FILES_ROUTE = "files" + +fun NavGraphBuilder.filesGraph() { + composable(FILES_ROUTE) { + ScreenTransitionWrapper(transition) { + FilesScreen() + } + } +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt new file mode 100644 index 0000000..15b6a11 --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt @@ -0,0 +1,726 @@ +package com.librechat.android.feature.files.screen + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material.icons.filled.VideoFile +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import timber.log.Timber +import com.librechat.android.feature.files.FilePreviewDisplayData +import java.io.File +import com.librechat.android.feature.files.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilePreviewDialog( + file: FilePreviewDisplayData, + onDismiss: () -> Unit, + onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)? = null, + modifier: Modifier = Modifier, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = file.filename, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close_preview), + ) + } + }, + ) + }, + ) { padding -> + when { + file.type.startsWith("image/") -> { + ImagePreview( + url = file.previewUrl, + filename = file.filename, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } + file.type == "application/pdf" -> { + PdfPreview( + file = file, + onDownloadFile = onDownloadFile, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } + file.type.startsWith("text/") || file.type == "application/json" || + file.type == "application/xml" || file.type == "application/javascript" -> { + TextContentPreview( + file = file, + onDownloadFile = onDownloadFile, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } + else -> { + FileInfoCard( + file = file, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } + } + } + } +} + +@Composable +private fun ImagePreview( + url: String?, + filename: String, + modifier: Modifier = Modifier, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val transformableState = rememberTransformableState { zoomChange, panChange, _ -> + scale = (scale * zoomChange).coerceIn(0.5f, 5f) + offset = Offset( + x = offset.x + panChange.x, + y = offset.y + panChange.y, + ) + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + if (url != null) { + Timber.d("ImagePreview loading URL: %s", url) + AsyncImage( + model = url, + contentDescription = stringResource(R.string.preview_of, filename), + onState = { state -> + when (state) { + is AsyncImagePainter.State.Loading -> + Timber.d("ImagePreview: loading %s", url) + is AsyncImagePainter.State.Success -> + Timber.d("ImagePreview: success %s", url) + is AsyncImagePainter.State.Error -> + Timber.e( + state.result.throwable, + "ImagePreview: error loading %s", + url, + ) + is AsyncImagePainter.State.Empty -> + Timber.d("ImagePreview: empty state for %s", url) + } + }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + } + .transformable(state = transformableState), + contentScale = ContentScale.Fit, + ) + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Image, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.image_preview_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +// -- PDF Preview -- + +private sealed interface PdfLoadState { + data object Loading : PdfLoadState + data class Success(val pages: List) : PdfLoadState + data class Error(val message: String) : PdfLoadState +} + +@Composable +private fun PdfPreview( + file: FilePreviewDisplayData, + onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var loadState by remember { mutableStateOf(PdfLoadState.Loading) } + var tempFile by remember { mutableStateOf(null) } + + // Clean up temp file when composable leaves composition + DisposableEffect(file.fileId) { + onDispose { + tempFile?.delete() + // Recycle bitmaps + val currentState = loadState + if (currentState is PdfLoadState.Success) { + currentState.pages.forEach { bitmap -> + if (!bitmap.isRecycled) { + bitmap.recycle() + } + } + } + } + } + + // Download and render PDF + LaunchedEffect(file.fileId) { + loadState = PdfLoadState.Loading + try { + val bytes = onDownloadFile?.invoke(file.fileId, file.userId) + if (bytes == null) { + loadState = PdfLoadState.Error(context.getString(R.string.failed_to_download_pdf)) + return@LaunchedEffect + } + + // Write to temp file + val pdfTempFile = File(context.cacheDir, "pdf_preview_${file.fileId}.pdf") + pdfTempFile.writeBytes(bytes) + tempFile = pdfTempFile + + // Render pages with PdfRenderer + val fd = ParcelFileDescriptor.open( + pdfTempFile, + ParcelFileDescriptor.MODE_READ_ONLY, + ) + val renderer = PdfRenderer(fd) + val pageCount = renderer.pageCount + Timber.d("PdfPreview: opened PDF with %d pages", pageCount) + + // Render up to 50 pages to avoid memory issues on very large PDFs + val maxPages = minOf(pageCount, 50) + val bitmaps = mutableListOf() + + // Calculate a reasonable width based on screen density + val displayMetrics = context.resources.displayMetrics + val targetWidth = displayMetrics.widthPixels + + for (i in 0 until maxPages) { + val page = renderer.openPage(i) + // Scale to fill screen width while maintaining aspect ratio + val scale = targetWidth.toFloat() / page.width + val bitmapWidth = targetWidth + val bitmapHeight = (page.height * scale).toInt() + + val bitmap = Bitmap.createBitmap( + bitmapWidth, + bitmapHeight, + Bitmap.Config.ARGB_8888, + ) + // Render with white background + bitmap.eraseColor(android.graphics.Color.WHITE) + page.render( + bitmap, + null, + null, + PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY, + ) + page.close() + bitmaps.add(bitmap) + } + + renderer.close() + fd.close() + + loadState = PdfLoadState.Success(bitmaps) + if (pageCount > maxPages) { + Timber.d("PdfPreview: showing first %d of %d pages", maxPages, pageCount) + } + } catch (e: Exception) { + Timber.e(e, "PdfPreview: failed to render PDF") + loadState = PdfLoadState.Error( + e.message ?: context.getString(R.string.failed_to_render_pdf), + ) + } + } + + when (val state = loadState) { + is PdfLoadState.Loading -> { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text( + text = stringResource(R.string.loading_pdf), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + is PdfLoadState.Error -> { + PdfErrorFallback( + file = file, + errorMessage = state.message, + modifier = modifier, + ) + } + is PdfLoadState.Success -> { + PdfPagesList( + pages = state.pages, + filename = file.filename, + modifier = modifier, + ) + } + } +} + +@Composable +private fun PdfPagesList( + pages: List, + filename: String, + modifier: Modifier = Modifier, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val transformableState = rememberTransformableState { zoomChange, panChange, _ -> + scale = (scale * zoomChange).coerceIn(0.5f, 5f) + offset = Offset( + x = offset.x + panChange.x, + y = offset.y + panChange.y, + ) + } + + LazyColumn( + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + } + .transformable(state = transformableState), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed( + items = pages, + key = { index, _ -> "page_$index" }, + ) { index, bitmap -> + Column { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.page_cd, index + 1, filename), + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + // Page number indicator + Text( + text = stringResource(R.string.page_of, index + 1, pages.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + } +} + +@Composable +private fun PdfErrorFallback( + file: FilePreviewDisplayData, + errorMessage: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.PictureAsPdf, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Text( + text = file.filename, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = stringResource(R.string.could_not_render_pdf), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + InfoRow(stringResource(R.string.info_type), file.type) + InfoRow(stringResource(R.string.info_size), file.formattedSize) + file.createdAt?.let { InfoRow(stringResource(R.string.info_created), it) } + file.source?.let { InfoRow(stringResource(R.string.info_source), it) } + } + } + } + } +} + +// -- Text Content Preview -- + +private sealed interface TextLoadState { + data object Loading : TextLoadState + data class Success(val content: String) : TextLoadState + data class Error(val message: String) : TextLoadState +} + +@Composable +private fun TextContentPreview( + file: FilePreviewDisplayData, + onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var loadState by remember { mutableStateOf(TextLoadState.Loading) } + + LaunchedEffect(file.fileId) { + loadState = TextLoadState.Loading + try { + val bytes = onDownloadFile?.invoke(file.fileId, file.userId) + if (bytes == null) { + loadState = TextLoadState.Error(context.getString(R.string.failed_to_download_file)) + return@LaunchedEffect + } + val content = bytes.decodeToString() + // Limit to ~500KB of text to avoid UI performance issues + val truncated = if (content.length > 500_000) { + content.take(500_000) + context.getString(R.string.content_truncated) + } else { + content + } + loadState = TextLoadState.Success(truncated) + } catch (e: Exception) { + Timber.e(e, "TextContentPreview: failed to load text content") + loadState = TextLoadState.Error( + e.message ?: context.getString(R.string.failed_to_load_content), + ) + } + } + + when (val state = loadState) { + is TextLoadState.Loading -> { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text( + text = stringResource(R.string.loading_file_content), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + is TextLoadState.Error -> { + TextErrorFallback( + file = file, + errorMessage = state.message, + modifier = modifier, + ) + } + is TextLoadState.Success -> { + val verticalScrollState = rememberScrollState() + val horizontalScrollState = rememberScrollState() + + Column( + modifier = modifier + .verticalScroll(verticalScrollState) + .padding(16.dp), + ) { + // File info header + Text( + text = file.filename, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "${file.type} - ${file.formattedSize}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + + // Scrollable text content with monospace font + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(horizontalScrollState), + ) { + Text( + text = state.content, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + modifier = Modifier.widthIn(min = 600.dp), + ) + } + } + } + } +} + +@Composable +private fun TextErrorFallback( + file: FilePreviewDisplayData, + errorMessage: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Text( + text = file.filename, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = stringResource(R.string.could_not_load_file_content), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + InfoRow(stringResource(R.string.info_type), file.type) + InfoRow(stringResource(R.string.info_size), file.formattedSize) + file.createdAt?.let { InfoRow(stringResource(R.string.info_created), it) } + file.source?.let { InfoRow(stringResource(R.string.info_source), it) } + } + } + } + } +} + +// -- Shared Components -- + +@Composable +private fun FileInfoCard( + file: FilePreviewDisplayData, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = fileTypeIconLarge(file.type), + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = file.filename, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + InfoRow(stringResource(R.string.info_type), file.type) + InfoRow(stringResource(R.string.info_size), file.formattedSize) + file.createdAt?.let { InfoRow(stringResource(R.string.info_created), it) } + file.source?.let { InfoRow(stringResource(R.string.info_source), it) } + } + } + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } +} + +private fun fileTypeIconLarge(type: String): ImageVector = when { + type.startsWith("image/") -> Icons.Default.Image + type.startsWith("video/") -> Icons.Default.VideoFile + type.startsWith("audio/") -> Icons.Default.AudioFile + type == "application/pdf" -> Icons.Default.PictureAsPdf + type.startsWith("text/") || type == "application/json" -> Icons.Default.Description + else -> Icons.Default.Description +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt new file mode 100644 index 0000000..7328f0e --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt @@ -0,0 +1,96 @@ +package com.librechat.android.feature.files.screen + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.files.viewmodel.FileSortField +import com.librechat.android.feature.files.viewmodel.FileSortOrder +import com.librechat.android.feature.files.R +import androidx.compose.ui.res.stringResource + +@Composable +fun FileSortMenu( + expanded: Boolean, + currentSortField: FileSortField, + currentSortOrder: FileSortOrder, + onSortSelected: (FileSortField, FileSortOrder) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = modifier, + ) { + // Sort field options + FileSortField.entries.forEach { field -> + val isSelected = field == currentSortField + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.label, + style = MaterialTheme.typography.bodyMedium, + ) + if (isSelected) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + onClick = { + if (isSelected) { + // Toggle sort order + val newOrder = if (currentSortOrder == FileSortOrder.ASCENDING) { + FileSortOrder.DESCENDING + } else { + FileSortOrder.ASCENDING + } + onSortSelected(field, newOrder) + } else { + // Select field with default descending + onSortSelected(field, FileSortOrder.DESCENDING) + } + onDismiss() + }, + trailingIcon = if (isSelected) { + { + Icon( + imageVector = if (currentSortOrder == FileSortOrder.ASCENDING) { + Icons.Default.ArrowUpward + } else { + Icons.Default.ArrowDownward + }, + contentDescription = if (currentSortOrder == FileSortOrder.ASCENDING) { + stringResource(R.string.cd_ascending) + } else { + stringResource(R.string.cd_descending) + }, + ) + } + } else { + null + }, + ) + } + } +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt new file mode 100644 index 0000000..af747c2 --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt @@ -0,0 +1,602 @@ +package com.librechat.android.feature.files.screen + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.VideoFile +import androidx.compose.material.icons.automirrored.filled.ViewList +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.librechat.android.core.ui.components.EmptyState +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.feature.files.FileDisplayData +import com.librechat.android.feature.files.components.UploadProgressCard +import com.librechat.android.feature.files.viewmodel.FileTypeFilter +import com.librechat.android.feature.files.viewmodel.FileViewMode +import com.librechat.android.feature.files.viewmodel.FilesViewModel +import com.librechat.android.feature.files.R +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilesScreen( + modifier: Modifier = Modifier, + viewModel: FilesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var showDeleteConfirmation by remember { mutableStateOf(false) } + var singleDeleteFileId by remember { mutableStateOf(null) } + var showSortMenu by remember { mutableStateOf(false) } + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri -> + if (uri != null) { + viewModel.uploadFile(uri) + } + } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(error) + viewModel.dismissError() + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + if (uiState.isSelectionMode) { + TopAppBar( + title = { + val count = uiState.selectedFileIds.size + Text( + if (count == 0) stringResource(R.string.select_files) + else stringResource(R.string.selected_count, count), + ) + }, + navigationIcon = { + IconButton(onClick = { viewModel.exitSelectionMode() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_exit_edit_mode), + ) + } + }, + actions = { + IconButton(onClick = { viewModel.selectAll() }) { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = stringResource(R.string.cd_select_all), + ) + } + IconButton( + onClick = { showDeleteConfirmation = true }, + enabled = uiState.selectedFileIds.isNotEmpty(), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_selected), + tint = if (uiState.selectedFileIds.isNotEmpty()) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + TextButton(onClick = { viewModel.exitSelectionMode() }) { + Text(stringResource(R.string.done)) + } + }, + ) + } else { + TopAppBar( + title = { Text(stringResource(R.string.files)) }, + actions = { + if (uiState.hasFiles) { + IconButton(onClick = { viewModel.enterEditMode() }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit_files), + ) + } + } + IconButton(onClick = { viewModel.toggleViewMode() }) { + Icon( + imageVector = when (uiState.viewMode) { + FileViewMode.LIST -> Icons.Default.GridView + FileViewMode.GRID -> Icons.AutoMirrored.Filled.ViewList + }, + contentDescription = when (uiState.viewMode) { + FileViewMode.LIST -> stringResource(R.string.cd_switch_to_grid) + FileViewMode.GRID -> stringResource(R.string.cd_switch_to_list) + }, + ) + } + Box { + IconButton(onClick = { showSortMenu = true }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Sort, + contentDescription = stringResource(R.string.cd_sort_files), + ) + } + FileSortMenu( + expanded = showSortMenu, + currentSortField = uiState.sortField, + currentSortOrder = uiState.sortOrder, + onSortSelected = { field, order -> + viewModel.setSort(field, order) + }, + onDismiss = { showSortMenu = false }, + ) + } + }, + ) + } + }, + floatingActionButton = { + if (!uiState.isSelectionMode) { + FloatingActionButton( + onClick = { filePickerLauncher.launch("*/*") }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_upload_file), + ) + } + } + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // File type filter tabs + val filters = FileTypeFilter.entries + val selectedIndex = filters.indexOf(uiState.selectedFilter) + ScrollableTabRow( + selectedTabIndex = selectedIndex, + edgePadding = 16.dp, + ) { + filters.forEach { filter -> + Tab( + selected = filter == uiState.selectedFilter, + onClick = { viewModel.setFilter(filter) }, + text = { Text(filter.label) }, + ) + } + } + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + uiState.error != null && !uiState.hasFiles -> { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.could_not_load_files), + onRetry = { + viewModel.dismissError() + viewModel.loadFiles() + }, + ) + } + uiState.displayFiles.isEmpty() && !uiState.isRefreshing -> { + EmptyState( + title = if (uiState.selectedFilter == FileTypeFilter.ALL) { + stringResource(R.string.no_files) + } else { + stringResource(R.string.no_filter_files, uiState.selectedFilter.label.lowercase()) + }, + description = if (uiState.selectedFilter == FileTypeFilter.ALL) { + stringResource(R.string.upload_to_get_started) + } else { + stringResource(R.string.no_filter_files_found, uiState.selectedFilter.label.lowercase()) + }, + icon = Icons.Default.Folder, + ) + } + else -> { + when (uiState.viewMode) { + FileViewMode.LIST -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + items = uiState.displayFiles, + key = { it.fileId }, + contentType = { "file" }, + ) { file -> + FileItem( + file = file, + isEditMode = uiState.isSelectionMode, + isSelected = file.fileId in uiState.selectedFileIds, + onDelete = { singleDeleteFileId = file.fileId }, + onLongClick = { + viewModel.enterSelectionMode(file.fileId) + }, + onClick = { + if (uiState.isSelectionMode) { + viewModel.toggleFileSelection(file.fileId) + } else { + viewModel.openFilePreview(file.fileId) + } + }, + ) + } + } + } + FileViewMode.GRID -> { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = uiState.displayFiles, + key = { it.fileId }, + contentType = { "file_grid" }, + ) { file -> + FileGridItem( + file = file, + isSelectionMode = uiState.isSelectionMode, + isSelected = file.fileId in uiState.selectedFileIds, + onLongClick = { + viewModel.enterSelectionMode(file.fileId) + }, + onClick = { + if (uiState.isSelectionMode) { + viewModel.toggleFileSelection(file.fileId) + } else { + viewModel.openFilePreview(file.fileId) + } + }, + ) + } + } + } + } + } + } + } + + // Upload progress overlay + UploadProgressCard( + visible = uiState.isUploading, + filename = uiState.uploadFilename, + progress = uiState.uploadProgress, + onCancel = viewModel::cancelUpload, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + + // Multi-file delete confirmation + if (showDeleteConfirmation) { + val count = uiState.selectedFileIds.size + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.delete_count_files, count, if (count > 1) "s" else "")) }, + text = { + Text( + text = stringResource(R.string.action_cannot_be_undone), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteSelected() + showDeleteConfirmation = false + }) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // Single-file delete confirmation + val pendingDeleteId = singleDeleteFileId + if (pendingDeleteId != null) { + val fileName = uiState.displayFiles + .find { it.fileId == pendingDeleteId }?.filename ?: stringResource(R.string.this_file) + AlertDialog( + onDismissRequest = { singleDeleteFileId = null }, + title = { Text(stringResource(R.string.delete_file_question)) }, + text = { + Text( + text = stringResource(R.string.delete_file_message, fileName), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteFile(pendingDeleteId) + singleDeleteFileId = null + }) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { singleDeleteFileId = null }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // File preview dialog + val previewFile = uiState.previewFile + if (previewFile != null) { + FilePreviewDialog( + file = previewFile, + onDismiss = viewModel::closeFilePreview, + onDownloadFile = viewModel::downloadFileBytes, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FileItem( + file: FileDisplayData, + isEditMode: Boolean, + isSelected: Boolean, + onDelete: () -> Unit, + onLongClick: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isEditMode) { + Checkbox( + checked = isSelected, + onCheckedChange = { onClick() }, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + Icon( + imageVector = fileTypeIcon(file.type), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.filename, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = buildString { + append(file.formattedSize) + file.createdAt?.let { append(" \u00B7 $it") } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (isEditMode) { + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_file, file.filename), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + HorizontalDivider() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FileGridItem( + file: FileDisplayData, + isSelectionMode: Boolean, + isSelected: Boolean, + onLongClick: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + shape = MaterialTheme.shapes.medium, + ) { + Box { + Column { + // Thumbnail area + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium), + contentAlignment = Alignment.Center, + ) { + if (file.type.startsWith("image/") && file.previewUrl != null) { + AsyncImage( + model = file.previewUrl, + contentDescription = stringResource(R.string.cd_thumbnail, file.filename), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = fileTypeIconLarge(file.type), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // File info + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Text( + text = file.filename, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = file.formattedSize, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + + // Selection checkbox overlay + if (isSelectionMode) { + Checkbox( + checked = isSelected, + onCheckedChange = { onClick() }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(4.dp), + ) + } + } + } +} + +private fun fileTypeIcon(type: String): ImageVector = when { + type.startsWith("image/") -> Icons.Default.Image + type.startsWith("video/") -> Icons.Default.VideoFile + type.startsWith("audio/") -> Icons.Default.AudioFile + else -> Icons.Default.Description +} + +/** + * Returns a more specific icon for grid view where the icon is the primary visual element. + */ +private fun fileTypeIconLarge(type: String): ImageVector = when { + type.startsWith("image/") -> Icons.Default.Image + type.startsWith("video/") -> Icons.Default.VideoFile + type.startsWith("audio/") -> Icons.Default.AudioFile + type == "application/pdf" -> Icons.Default.PictureAsPdf + type.startsWith("text/") -> Icons.Default.Description + type == "application/json" || type == "application/xml" -> Icons.Default.Code + else -> Icons.AutoMirrored.Filled.InsertDriveFile +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt new file mode 100644 index 0000000..b32b62f --- /dev/null +++ b/feature/files/src/main/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt @@ -0,0 +1,524 @@ +package com.librechat.android.feature.files.viewmodel + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.text.format.Formatter +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.model.FileObject +import com.librechat.android.core.model.request.DeleteFileEntry +import com.librechat.android.feature.files.FileDisplayData +import com.librechat.android.feature.files.FilePreviewDisplayData +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +enum class FileTypeFilter(val label: String) { + ALL("All"), + IMAGES("Images"), + DOCUMENTS("Documents"), + AUDIO("Audio"), + VIDEO("Video"), +} + +enum class FileSortField(val label: String) { + NAME("Name"), + DATE("Date"), + SIZE("Size"), + TYPE("Type"), +} + +enum class FileSortOrder { + ASCENDING, + DESCENDING, +} + +enum class FileViewMode { + LIST, + GRID, +} + +@Immutable +data class FilesUiState( + val displayFiles: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isUploading: Boolean = false, + val uploadFilename: String = "", + val uploadProgress: Float? = null, + val error: String? = null, + val selectedFilter: FileTypeFilter = FileTypeFilter.ALL, + val isSelectionMode: Boolean = false, + val selectedFileIds: Set = emptySet(), + val sortField: FileSortField = FileSortField.DATE, + val sortOrder: FileSortOrder = FileSortOrder.DESCENDING, + val previewFile: FilePreviewDisplayData? = null, + val hasFiles: Boolean = false, + val viewMode: FileViewMode = FileViewMode.LIST, +) + +@HiltViewModel +class FilesViewModel @Inject constructor( + private val fileRepository: FileRepository, + @ApplicationContext private val context: Context, + private val serverDataStore: ServerDataStore, +) : ViewModel() { + + private val _files = MutableStateFlow>(emptyList()) + private val _selectedFilter = MutableStateFlow(FileTypeFilter.ALL) + private val _sortField = MutableStateFlow(FileSortField.DATE) + private val _sortOrder = MutableStateFlow(FileSortOrder.DESCENDING) + + private val _transientState = MutableStateFlow(TransientState()) + + /** Cache display data by fileId to avoid re-running Formatter.formatShortFileSize on every emission. */ + private val displayDataCache = mutableMapOf() + + val uiState: StateFlow = combine( + _files, + _selectedFilter, + _sortField, + _sortOrder, + _transientState, + ) { files, filter, sortField, sortOrder, transient -> + val filtered = filterFiles(files, filter) + val sorted = sortFiles(filtered, sortField, sortOrder) + val displayFiles = sorted.map { file -> + displayDataCache.getOrPut(file.fileId) { file.toDisplayData() } + } + + FilesUiState( + displayFiles = displayFiles, + isLoading = transient.isLoading, + isRefreshing = transient.isRefreshing, + isUploading = transient.isUploading, + uploadFilename = transient.uploadFilename, + uploadProgress = transient.uploadProgress, + error = transient.error, + selectedFilter = filter, + isSelectionMode = transient.isSelectionMode, + selectedFileIds = transient.selectedFileIds, + sortField = sortField, + sortOrder = sortOrder, + previewFile = transient.previewFile, + hasFiles = files.isNotEmpty(), + viewMode = transient.viewMode, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = FilesUiState(), + ) + + init { + loadFiles() + } + + fun loadFiles() { + viewModelScope.launch { + updateTransient { copy(isLoading = true, error = null) } + when (val result = fileRepository.getFiles()) { + is Result.Success -> { + displayDataCache.clear() + _files.value = result.data + updateTransient { copy(isLoading = false) } + } + is Result.Error -> { + updateTransient { + copy( + isLoading = false, + error = result.message ?: "Failed to load files", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + viewModelScope.launch { + updateTransient { copy(isRefreshing = true) } + when (val result = fileRepository.getFiles()) { + is Result.Success -> { + displayDataCache.clear() + _files.value = result.data + updateTransient { copy(isRefreshing = false) } + } + is Result.Error -> { + updateTransient { + copy( + isRefreshing = false, + error = result.message ?: "Failed to refresh files", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + private var uploadJob: kotlinx.coroutines.Job? = null + + fun uploadFile(uri: Uri) { + uploadJob = viewModelScope.launch { + val filename = getFileName(uri) ?: "upload" + updateTransient { + copy( + isUploading = true, + uploadFilename = filename, + uploadProgress = null, + error = null, + ) + } + val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes == null) { + Timber.e("uploadFile: could not read bytes from URI: %s", uri) + updateTransient { + copy( + isUploading = false, + uploadFilename = "", + uploadProgress = null, + error = "Could not read file", + ) + } + return@launch + } + + val fileId = UUID.randomUUID().toString() + Timber.d("uploadFile: filename=%s, mimeType=%s, size=%d, fileId=%s", filename, mimeType, bytes.size, fileId) + + when (val result = fileRepository.uploadFile( + bytes = bytes, + filename = filename, + type = mimeType, + fileId = fileId, + endpoint = "agents", + )) { + is Result.Success -> { + Timber.d("uploadFile: success -- serverFileId=%s", result.data.fileId) + _files.value = listOf(result.data) + _files.value + updateTransient { + copy( + isUploading = false, + uploadFilename = "", + uploadProgress = null, + ) + } + } + is Result.Error -> { + Timber.e(result.exception, "uploadFile: server error -- %s", result.message) + updateTransient { + copy( + isUploading = false, + uploadFilename = "", + uploadProgress = null, + error = result.message ?: "Upload failed", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun cancelUpload() { + uploadJob?.cancel() + updateTransient { + copy( + isUploading = false, + uploadFilename = "", + uploadProgress = null, + ) + } + } + + fun deleteFile(fileId: String) { + viewModelScope.launch { + val file = _files.value.find { it.fileId == fileId } + val entry = DeleteFileEntry( + fileId = fileId, + filepath = file?.filepath ?: "", + ) + when (val result = fileRepository.deleteFiles(listOf(entry))) { + is Result.Success -> { + _files.value = _files.value.filter { it.fileId != fileId } + } + is Result.Error -> { + updateTransient { + copy(error = result.message ?: "Failed to delete file") + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun setFilter(filter: FileTypeFilter) { + _selectedFilter.value = filter + } + + fun setSort(field: FileSortField, order: FileSortOrder) { + _sortField.value = field + _sortOrder.value = order + } + + fun toggleViewMode() { + updateTransient { + copy( + viewMode = when (viewMode) { + FileViewMode.LIST -> FileViewMode.GRID + FileViewMode.GRID -> FileViewMode.LIST + }, + ) + } + } + + // File preview + + fun openFilePreview(fileId: String) { + val file = _files.value.find { it.fileId == fileId } ?: return + updateTransient { copy(previewFile = file.toPreviewDisplayData()) } + } + + fun closeFilePreview() { + updateTransient { copy(previewFile = null) } + } + + /** + * Downloads the raw bytes of a file from the server. + * + * Uses [FileRepository.downloadFile] which calls `GET /api/files/download/:userId/:fileId`. + * Returns null if the user ID is missing or the download fails. + */ + suspend fun downloadFileBytes(fileId: String, userId: String?): ByteArray? { + if (userId.isNullOrBlank()) { + Timber.w("downloadFileBytes: userId is null/blank for fileId=%s", fileId) + return null + } + return when (val result = fileRepository.downloadFile(userId, fileId)) { + is Result.Success -> { + Timber.d("downloadFileBytes: success, %d bytes for fileId=%s", result.data.size, fileId) + result.data + } + is Result.Error -> { + Timber.e(result.exception, "downloadFileBytes: error for fileId=%s: %s", fileId, result.message) + null + } + is Result.Loading -> null + } + } + + // Multi-select methods + + fun enterEditMode() { + updateTransient { + copy( + isSelectionMode = true, + selectedFileIds = emptySet(), + ) + } + } + + fun enterSelectionMode(fileId: String) { + updateTransient { + copy( + isSelectionMode = true, + selectedFileIds = setOf(fileId), + ) + } + } + + fun exitSelectionMode() { + updateTransient { + copy( + isSelectionMode = false, + selectedFileIds = emptySet(), + ) + } + } + + fun toggleFileSelection(fileId: String) { + val current = _transientState.value.selectedFileIds + val updated = if (fileId in current) current - fileId else current + fileId + if (updated.isEmpty()) { + updateTransient { + copy( + isSelectionMode = false, + selectedFileIds = emptySet(), + ) + } + } else { + updateTransient { copy(selectedFileIds = updated) } + } + } + + fun selectAll() { + val filtered = filterFiles(_files.value, _selectedFilter.value) + val allIds = filtered.map { it.fileId }.toSet() + updateTransient { copy(selectedFileIds = allIds) } + } + + fun deleteSelected() { + val selectedIds = _transientState.value.selectedFileIds + if (selectedIds.isEmpty()) return + + viewModelScope.launch { + val entries = selectedIds.mapNotNull { id -> + val file = _files.value.find { it.fileId == id } + file?.let { DeleteFileEntry(fileId = it.fileId, filepath = it.filepath) } + } + when (val result = fileRepository.deleteFiles(entries)) { + is Result.Success -> { + _files.value = _files.value.filter { it.fileId !in selectedIds } + updateTransient { + copy( + isSelectionMode = false, + selectedFileIds = emptySet(), + ) + } + } + is Result.Error -> { + updateTransient { + copy(error = result.message ?: "Failed to delete files") + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissError() { + updateTransient { copy(error = null) } + } + + // Private helpers + + private fun filterFiles(files: List, filter: FileTypeFilter): List = + when (filter) { + FileTypeFilter.ALL -> files + FileTypeFilter.IMAGES -> files.filter { it.type.startsWith("image/") } + FileTypeFilter.DOCUMENTS -> files.filter { + it.type.startsWith("application/") || it.type.startsWith("text/") + } + FileTypeFilter.AUDIO -> files.filter { it.type.startsWith("audio/") } + FileTypeFilter.VIDEO -> files.filter { it.type.startsWith("video/") } + } + + private fun sortFiles( + files: List, + field: FileSortField, + order: FileSortOrder, + ): List { + val sorted = when (field) { + FileSortField.NAME -> files.sortedBy { it.filename.lowercase() } + FileSortField.DATE -> files.sortedBy { it.createdAt ?: "" } + FileSortField.SIZE -> files.sortedBy { it.bytes } + FileSortField.TYPE -> files.sortedBy { it.type } + } + return if (order == FileSortOrder.DESCENDING) sorted.reversed() else sorted + } + + /** + * Builds the image preview URL for a file. + * + * The web frontend uses the `filepath` field directly (e.g. `/images/userId/file.webp`) + * prepended with the server base URL. For images stored locally, this path is served + * statically by the server. For non-local storage (S3, Firebase), `filepath` may already + * be an absolute URL. + * + * As a fallback when `filepath` is blank, we use the download endpoint + * (`/api/files/download/:userId/:fileId`) which streams the file with auth. + */ + private fun FileObject.buildImagePreviewUrl(): String? { + if (!type.startsWith("image/")) return null + return buildFileUrl() + } + + /** + * Builds the download/preview URL for any file type. + * + * Uses the same logic as [buildImagePreviewUrl] but works for all MIME types. + */ + private fun FileObject.buildFileUrl(): String { + val baseUrl = serverDataStore.getBaseUrl().trimEnd('/') + val url = if (filepath.startsWith("http://") || filepath.startsWith("https://")) { + // Already an absolute URL (e.g. S3/CDN) + filepath + } else if (filepath.isNotBlank()) { + // Relative path from server (e.g. /images/userId/file.webp) + "$baseUrl${filepath}" + } else { + // Fallback: use the download endpoint + "$baseUrl/api/files/download/${user ?: ""}/$fileId" + } + Timber.d("File URL for %s (%s): %s", filename, fileId, url) + return url + } + + private fun FileObject.toDisplayData(): FileDisplayData { + return FileDisplayData( + fileId = fileId, + filename = filename, + type = type, + formattedSize = Formatter.formatShortFileSize(context, bytes), + createdAt = createdAt, + previewUrl = buildImagePreviewUrl(), + ) + } + + private fun FileObject.toPreviewDisplayData(): FilePreviewDisplayData { + return FilePreviewDisplayData( + fileId = fileId, + filename = filename, + type = type, + formattedSize = Formatter.formatShortFileSize(context, bytes), + createdAt = createdAt, + source = source, + previewUrl = buildImagePreviewUrl(), + userId = user, + downloadUrl = buildFileUrl(), + ) + } + + private inline fun updateTransient(update: TransientState.() -> TransientState) { + _transientState.value = _transientState.value.update() + } + + private fun getFileName(uri: Uri): String? { + val cursor = context.contentResolver.query(uri, null, null, null, null) ?: return null + return cursor.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) it.getString(nameIndex) else null + } else null + } + } +} + +private data class TransientState( + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isUploading: Boolean = false, + val uploadFilename: String = "", + val uploadProgress: Float? = null, + val error: String? = null, + val isSelectionMode: Boolean = false, + val selectedFileIds: Set = emptySet(), + val previewFile: FilePreviewDisplayData? = null, + val viewMode: FileViewMode = FileViewMode.LIST, +) diff --git a/feature/files/src/main/res/values/strings.xml b/feature/files/src/main/res/values/strings.xml new file mode 100644 index 0000000..ac59488 --- /dev/null +++ b/feature/files/src/main/res/values/strings.xml @@ -0,0 +1,67 @@ + + + + Files + Upload + Delete + Cancel + Done + + + Exit edit mode + Select all + Delete selected + Edit files + Switch to grid view + Switch to list view + Sort files + Upload file + Delete %1$s + Thumbnail of %1$s + Close preview + Cancel upload + Ascending + Descending + + + Select files + %1$d selected + + + No files + Upload files to get started + No %1$s + No %1$s files found + Could not load files + + + Delete %1$d file%2$s? + This action cannot be undone. + Delete file? + Delete \"%1$s\"? This action cannot be undone. + this file + + + Image preview unavailable + Preview of %1$s + Loading PDF\u2026 + Page %1$d of %2$d + Page %1$d of %2$s + Could not render PDF preview + Failed to download PDF + Failed to render PDF + Loading file content\u2026 + Could not load file content + Failed to download file + Failed to load file content + \n\n--- Content truncated (file too large) --- + + + Type + Size + Created + Source + + + Uploading\u2026 + diff --git a/feature/files/src/test/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt b/feature/files/src/test/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt new file mode 100644 index 0000000..6f1a4e9 --- /dev/null +++ b/feature/files/src/test/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt @@ -0,0 +1,386 @@ +package com.librechat.android.feature.files.viewmodel + +import android.content.Context +import android.text.format.Formatter +import com.google.common.truth.Truth.assertThat +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.model.FileObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FilesViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private val fileRepository = mockk(relaxed = true) + private val context = mockk(relaxed = true) + private val serverDataStore = mockk(relaxed = true) + + private lateinit var viewModel: FilesViewModel + + private val testFiles = listOf( + FileObject( + fileId = "file-1", + filename = "document.pdf", + filepath = "/files/user1/document.pdf", + type = "application/pdf", + bytes = 1024L, + createdAt = "2026-02-19T10:00:00.000Z", + ), + FileObject( + fileId = "file-2", + filename = "photo.jpg", + filepath = "/images/user1/photo.jpg", + type = "image/jpeg", + bytes = 2048L, + createdAt = "2026-02-18T10:00:00.000Z", + ), + FileObject( + fileId = "file-3", + filename = "song.mp3", + filepath = "/audio/user1/song.mp3", + type = "audio/mpeg", + bytes = 4096L, + createdAt = "2026-02-17T10:00:00.000Z", + ), + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockkStatic(Formatter::class) + every { Formatter.formatShortFileSize(any(), any()) } returns "1 KB" + coEvery { fileRepository.getFiles() } returns Result.Success(testFiles) + every { serverDataStore.getBaseUrl() } returns "https://chat.example.com" + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkStatic(Formatter::class) + } + + private fun createViewModel() = FilesViewModel( + fileRepository = fileRepository, + context = context, + serverDataStore = serverDataStore, + ) + + @Test + fun `initial state loads files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.displayFiles).hasSize(3) + assertThat(state.isLoading).isFalse() + assertThat(state.hasFiles).isTrue() + } + + @Test + fun `loadFiles error shows error message`() = runTest { + coEvery { fileRepository.getFiles() } returns Result.Error(message = "Network error") + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.error).isEqualTo("Network error") + assertThat(state.isLoading).isFalse() + } + + @Test + fun `empty file list shows hasFiles false`() = runTest { + coEvery { fileRepository.getFiles() } returns Result.Success(emptyList()) + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.hasFiles).isFalse() + assertThat(viewModel.uiState.value.displayFiles).isEmpty() + } + + @Test + fun `setFilter with IMAGES shows only image files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.setFilter(FileTypeFilter.IMAGES) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.displayFiles).hasSize(1) + assertThat(state.displayFiles[0].fileId).isEqualTo("file-2") + assertThat(state.selectedFilter).isEqualTo(FileTypeFilter.IMAGES) + } + + @Test + fun `setFilter with DOCUMENTS shows only document files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.setFilter(FileTypeFilter.DOCUMENTS) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.displayFiles).hasSize(1) + assertThat(state.displayFiles[0].fileId).isEqualTo("file-1") + } + + @Test + fun `setFilter with AUDIO shows only audio files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.setFilter(FileTypeFilter.AUDIO) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.displayFiles).hasSize(1) + assertThat(viewModel.uiState.value.displayFiles[0].fileId).isEqualTo("file-3") + } + + @Test + fun `setFilter with ALL shows all files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.setFilter(FileTypeFilter.IMAGES) + advanceUntilIdle() + viewModel.setFilter(FileTypeFilter.ALL) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.displayFiles).hasSize(3) + } + + @Test + fun `deleteFile removes file from list on success`() = runTest { + coEvery { fileRepository.deleteFiles(any()) } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.deleteFile("file-1") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.displayFiles).hasSize(2) + assertThat(state.displayFiles.any { it.fileId == "file-1" }).isFalse() + } + + @Test + fun `deleteFile error shows error message`() = runTest { + coEvery { fileRepository.deleteFiles(any()) } returns + Result.Error(message = "Delete failed") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.deleteFile("file-1") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Delete failed") + assertThat(viewModel.uiState.value.displayFiles).hasSize(3) + } + + @Test + fun `refresh reloads files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.isRefreshing).isFalse() + coVerify(exactly = 2) { fileRepository.getFiles() } + } + + @Test + fun `dismissError clears error state`() = runTest { + coEvery { fileRepository.getFiles() } returns Result.Error(message = "Error") + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isNotNull() + + viewModel.dismissError() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isNull() + } + + @Test + fun `enterSelectionMode enables selection with initial file`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.enterSelectionMode("file-1") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.isSelectionMode).isTrue() + assertThat(state.selectedFileIds).containsExactly("file-1") + } + + @Test + fun `toggleFileSelection adds and removes files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.enterSelectionMode("file-1") + advanceUntilIdle() + + viewModel.toggleFileSelection("file-2") + advanceUntilIdle() + assertThat(viewModel.uiState.value.selectedFileIds).containsExactly("file-1", "file-2") + + viewModel.toggleFileSelection("file-1") + advanceUntilIdle() + assertThat(viewModel.uiState.value.selectedFileIds).containsExactly("file-2") + } + + @Test + fun `toggleFileSelection exits selection mode when last file deselected`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.enterSelectionMode("file-1") + advanceUntilIdle() + + viewModel.toggleFileSelection("file-1") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.isSelectionMode).isFalse() + assertThat(viewModel.uiState.value.selectedFileIds).isEmpty() + } + + @Test + fun `exitSelectionMode clears selection`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.enterSelectionMode("file-1") + advanceUntilIdle() + + viewModel.exitSelectionMode() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.isSelectionMode).isFalse() + assertThat(viewModel.uiState.value.selectedFileIds).isEmpty() + } + + @Test + fun `deleteSelected removes selected files on success`() = runTest { + coEvery { fileRepository.deleteFiles(any()) } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.enterSelectionMode("file-1") + advanceUntilIdle() + viewModel.toggleFileSelection("file-2") + advanceUntilIdle() + + viewModel.deleteSelected() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.displayFiles).hasSize(1) + assertThat(state.displayFiles[0].fileId).isEqualTo("file-3") + assertThat(state.isSelectionMode).isFalse() + } + + @Test + fun `toggleViewMode switches between list and grid`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.viewMode).isEqualTo(FileViewMode.LIST) + + viewModel.toggleViewMode() + advanceUntilIdle() + assertThat(viewModel.uiState.value.viewMode).isEqualTo(FileViewMode.GRID) + + viewModel.toggleViewMode() + advanceUntilIdle() + assertThat(viewModel.uiState.value.viewMode).isEqualTo(FileViewMode.LIST) + } + + @Test + fun `setSort updates sort field and order`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.setSort(FileSortField.NAME, FileSortOrder.ASCENDING) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.sortField).isEqualTo(FileSortField.NAME) + assertThat(state.sortOrder).isEqualTo(FileSortOrder.ASCENDING) + } + + @Test + fun `downloadFileBytes returns null for blank userId`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val result = viewModel.downloadFileBytes("file-1", null) + assertThat(result).isNull() + } + + @Test + fun `downloadFileBytes returns bytes on success`() = runTest { + val bytes = byteArrayOf(1, 2, 3) + coEvery { fileRepository.downloadFile("user-1", "file-1") } returns Result.Success(bytes) + + viewModel = createViewModel() + advanceUntilIdle() + + val result = viewModel.downloadFileBytes("file-1", "user-1") + assertThat(result).isEqualTo(bytes) + } + + @Test + fun `downloadFileBytes returns null on error`() = runTest { + coEvery { fileRepository.downloadFile("user-1", "file-1") } returns + Result.Error(message = "Not found") + + viewModel = createViewModel() + advanceUntilIdle() + + val result = viewModel.downloadFileBytes("file-1", "user-1") + assertThat(result).isNull() + } + + @Test + fun `selectAll selects all visible files`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.enterEditMode() + advanceUntilIdle() + viewModel.selectAll() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.selectedFileIds) + .containsExactly("file-1", "file-2", "file-3") + } +} diff --git a/feature/settings/CLAUDE.md b/feature/settings/CLAUDE.md new file mode 100644 index 0000000..1d5ab38 --- /dev/null +++ b/feature/settings/CLAUDE.md @@ -0,0 +1,63 @@ +# feature:settings + +## Screen +`SettingsScreen` -- single route `SETTINGS_ROUTE`. Receives `onLogout` and `onNavigateBack` callbacks. + +## Sections +- **Account**: displays user profile from `UserApi.getUser()` +- **Appearance**: theme toggle (System / Light / Dark) via `ThemeDataStore` +- **Server**: shows current server URL from `ServerDataStore` +- **About**: app version info +- **Danger Zone**: delete account with confirmation dialog + +## Theme +- `ThemeMode` enum: `SYSTEM`, `LIGHT`, `DARK` +- Persisted in `ThemeDataStore` (DataStore) +- `setThemeMode()` writes to DataStore; the app's root composable observes `themeDataStore.themeMode` flow +- Applied via Compose `MaterialTheme` with `isSystemInDarkTheme()` for SYSTEM mode + +## Logout +- `SettingsViewModel.logout()` calls `AuthRepository.logout()` which clears tokens from `EncryptedSharedPreferences` +- Sets `isLoggedOut = true` in UI state; screen calls `onLogout` callback to navigate to auth graph + +## Delete Account +- `SettingsViewModel.deleteAccount()` calls `UserApi.deleteUser()` then `AuthRepository.logout()` +- Sets `isAccountDeleted = true` in UI state; triggers navigation to auth graph + +## ViewModel Dependencies +- `UserApi` -- fetch/delete user profile +- `AuthRepository` -- logout (clear tokens) +- `ThemeDataStore` -- observe and set theme preference +- `ServerDataStore` -- observe current server URL + +## Future Sections (from spec, not yet implemented) +- Chat tab (font size, message layout, auto-scroll toggle) +- Speech tab (STT/TTS engine config) +- Data tab (import/export, clear chats, shared links management) +- Balance tab (conditional, token credits display) +- 2FA setup/disable in Account section + +### New Settings Sections +- `SettingsScreen` now organized into: Account, Appearance, General (Language, Personalization), Chat (Presets), Advanced (Fork Behavior, Commands), Server, About, Danger Zone +- Each new setting opens a dialog or navigates to a dedicated screen + +### Settings Sub-screens (via SettingsNavigation) +- `MemoriesScreen` — full memory CRUD, enable/disable toggle, own ViewModel (`MemoriesViewModel`) +- `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`) +- `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`) +- `CommandsConfigScreen` — enable/disable slash commands +- Routes: `MEMORIES_ROUTE`, `MCP_SERVERS_ROUTE`, `PRESET_MANAGER_ROUTE`, `COMMANDS_CONFIG_ROUTE` +- **Gotcha**: Memories and MCP navigation routes defined in their own files (`MemoriesNavigation.kt`, `McpNavigation.kt`) but wired through `SettingsNavigation.kt` +- **Gotcha**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations +- **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field) + +### API Keys +- `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`) +- Route: `API_KEYS_ROUTE` ("settings/api_keys"), accessible from Settings → Security section +- `ApiKeyCreateDialog` is two-phase: name input → show created key value with copy button +- **Gotcha**: The key value is only available in the creation response — it cannot be retrieved again from the server. The dialog must show it immediately after creation. + +### Dialogs +- `LanguageSelectorDialog` — 37+ locales with search, single-select radio +- `ForkSettingsDialog` — 3 fork modes (TARGET, BRANCHES, ALL) +- `PersonalizationDialog` — "About you" + "Response style" text areas with enable toggle diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts new file mode 100644 index 0000000..e270a6a --- /dev/null +++ b/feature/settings/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("librechat.android.feature") +} + +android { + namespace = "com.librechat.android.feature.settings" +} + +dependencies { + implementation(project(":core:network")) + implementation(libs.coil.compose) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt new file mode 100644 index 0000000..2d4a9b9 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt @@ -0,0 +1,11 @@ +package com.librechat.android.feature.settings + +import androidx.compose.runtime.Immutable + +@Immutable +data class PresetManagerDisplayData( + val presetId: String?, + val title: String, + val endpoint: String?, + val model: String?, +) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt new file mode 100644 index 0000000..926f1b6 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt @@ -0,0 +1,19 @@ +package com.librechat.android.feature.settings + +import androidx.compose.runtime.Immutable + +@Immutable +data class UserDisplayData( + val name: String, + val email: String, + val username: String, + val avatar: String?, +) + +@Immutable +data class SharedLinkDisplayData( + val shareId: String, + val title: String, + val createdAt: String?, + val isPublic: Boolean, +) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt new file mode 100644 index 0000000..6055316 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt @@ -0,0 +1,20 @@ +package com.librechat.android.feature.settings.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.settings.screen.McpServersScreen + +const val MCP_SERVERS_ROUTE = "settings/mcp" + +fun NavGraphBuilder.mcpServersScreen( + onNavigateBack: () -> Unit, +) { + composable(MCP_SERVERS_ROUTE) { + ScreenTransitionWrapper(transition) { + McpServersScreen( + onNavigateBack = onNavigateBack, + ) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt new file mode 100644 index 0000000..06ad732 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt @@ -0,0 +1,20 @@ +package com.librechat.android.feature.settings.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.settings.screen.MemoriesScreen + +const val MEMORIES_ROUTE = "settings/memories" + +fun NavGraphBuilder.memoriesScreen( + onNavigateBack: () -> Unit, +) { + composable(MEMORIES_ROUTE) { + ScreenTransitionWrapper(transition) { + MemoriesScreen( + onNavigateBack = onNavigateBack, + ) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt new file mode 100644 index 0000000..1129a54 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt @@ -0,0 +1,122 @@ +package com.librechat.android.feature.settings.navigation + +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.librechat.android.core.ui.components.ScreenTransitionWrapper +import com.librechat.android.feature.settings.screen.AccountSettingsScreen +import com.librechat.android.feature.settings.screen.ApiKeysScreen +import com.librechat.android.feature.settings.screen.ChatSettingsScreen +import com.librechat.android.feature.settings.screen.DataSettingsScreen +import com.librechat.android.feature.settings.screen.GeneralSettingsScreen +import com.librechat.android.feature.settings.screen.PresetManagerScreen +import com.librechat.android.feature.settings.screen.SharedLinksScreen +import com.librechat.android.feature.settings.screen.TabbedSettingsScreen +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel + +const val SETTINGS_TABBED_ROUTE = "settings" +const val SETTINGS_GENERAL_ROUTE = "settings/general" +const val SETTINGS_CHAT_ROUTE = "settings/chat" +const val SETTINGS_ACCOUNT_ROUTE = "settings/account" +const val SETTINGS_DATA_ROUTE = "settings/data" +const val SHARED_LINKS_ROUTE = "settings/shared-links" +const val PRESET_MANAGER_ROUTE = "settings/presets" +const val API_KEYS_ROUTE = "settings/api_keys" + +fun NavGraphBuilder.settingsGraph( + onLogout: () -> Unit, + onNavigateBack: () -> Unit, + onNavigateToArchived: () -> Unit = {}, + onNavigateToSharedLinks: () -> Unit = {}, + onNavigateBackFromSharedLinks: () -> Unit = {}, + onNavigateToPresets: () -> Unit = {}, + onNavigateBackFromPresets: () -> Unit = {}, + onNavigateToApiKeys: () -> Unit = {}, + onNavigateBackFromApiKeys: () -> Unit = {}, +) { + // Primary tabbed settings screen + composable(SETTINGS_TABBED_ROUTE) { + ScreenTransitionWrapper(transition) { + TabbedSettingsScreen( + onNavigateBack = onNavigateBack, + onLogout = onLogout, + onNavigateToArchived = onNavigateToArchived, + onNavigateToSharedLinks = onNavigateToSharedLinks, + onNavigateToPresets = onNavigateToPresets, + onNavigateToApiKeys = onNavigateToApiKeys, + ) + } + } + + // Individual category routes kept for backward compatibility / deep linking + composable(SETTINGS_GENERAL_ROUTE) { + ScreenTransitionWrapper(transition) { + GeneralSettingsScreen( + onNavigateBack = onNavigateBack, + ) + } + } + composable(SETTINGS_CHAT_ROUTE) { + ScreenTransitionWrapper(transition) { + ChatSettingsScreen( + onNavigateBack = onNavigateBack, + onNavigateToPresets = onNavigateToPresets, + ) + } + } + composable(SETTINGS_ACCOUNT_ROUTE) { + ScreenTransitionWrapper(transition) { + AccountSettingsScreen( + onLogout = onLogout, + onNavigateBack = onNavigateBack, + onNavigateToApiKeys = onNavigateToApiKeys, + ) + } + } + composable(SETTINGS_DATA_ROUTE) { + ScreenTransitionWrapper(transition) { + DataSettingsScreen( + onNavigateBack = onNavigateBack, + onNavigateToArchived = onNavigateToArchived, + onNavigateToSharedLinks = onNavigateToSharedLinks, + ) + } + } + composable(SHARED_LINKS_ROUTE) { + val viewModel: SettingsViewModel = hiltViewModel() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + // Load shared links when this screen is first shown + androidx.compose.runtime.LaunchedEffect(Unit) { + viewModel.loadSharedLinks() + } + + ScreenTransitionWrapper(transition) { + SharedLinksScreen( + links = uiState.value.sharedLinks, + isLoading = uiState.value.isSharedLinksLoading, + hasNextPage = uiState.value.sharedLinksHasNextPage, + serverUrl = uiState.value.serverUrl, + onLoadMore = viewModel::loadMoreSharedLinks, + onToggleVisibility = viewModel::toggleSharedLinkVisibility, + onDelete = viewModel::deleteSharedLink, + onNavigateBack = onNavigateBackFromSharedLinks, + ) + } + } + composable(PRESET_MANAGER_ROUTE) { + ScreenTransitionWrapper(transition) { + PresetManagerScreen( + onNavigateBack = onNavigateBackFromPresets, + ) + } + } + composable(API_KEYS_ROUTE) { + ScreenTransitionWrapper(transition) { + ApiKeysScreen( + onNavigateBack = onNavigateBackFromApiKeys, + ) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt new file mode 100644 index 0000000..8f8c3c4 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt @@ -0,0 +1,688 @@ +package com.librechat.android.feature.settings.screen + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.librechat.android.feature.settings.R +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSettingsScreen( + onLogout: () -> Unit, + onNavigateBack: () -> Unit, + onNavigateToApiKeys: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_account)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + AccountSettingsContent( + onLogout = onLogout, + onNavigateToApiKeys = onNavigateToApiKeys, + snackbarHostState = snackbarHostState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + viewModel = viewModel, + ) + } +} + +/** + * Reusable Account settings content (without Scaffold/TopAppBar). + * Used by both the standalone screen and the tabbed settings screen. + */ +@Composable +fun AccountSettingsContent( + onLogout: () -> Unit, + onNavigateToApiKeys: () -> Unit, + modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var showDeleteDialog by remember { mutableStateOf(false) } + var showLogoutDialog by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + message = error, + actionLabel = "Retry", + ) + viewModel.dismissError() + if (result == SnackbarResult.ActionPerformed) { + viewModel.retry() + } + } + + LaunchedEffect(uiState.isLoggedOut, uiState.isAccountDeleted) { + if (uiState.isLoggedOut || uiState.isAccountDeleted) { + onLogout() + } + } + + if (uiState.isLoading && uiState.user == null) { + LoadingIndicator() + } else if (uiState.error != null && uiState.user == null) { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.error_could_not_load_settings), + modifier = modifier, + onRetry = { viewModel.retry() }, + ) + } else { + LazyColumn( + modifier = modifier.fillMaxSize(), + ) { + // Account section + item(key = "account_header") { + SectionHeader(stringResource(R.string.section_profile)) + } + item(key = "account_info") { + AccountInfo( + name = uiState.user?.name ?: "", + email = uiState.user?.email ?: "", + avatarUrl = uiState.user?.avatar, + onAvatarClick = viewModel::showAvatarDialog, + ) + } + + // Balance section + item(key = "balance_header") { + SectionHeader(stringResource(R.string.section_balance)) + } + item(key = "balance_section") { + BalanceSection( + tokenCredits = uiState.tokenCredits, + isLoading = uiState.isBalanceLoading, + ) + } + + // Security section + item(key = "security_header") { + SectionHeader(stringResource(R.string.section_security)) + } + item(key = "security_settings") { + SecuritySection( + isTwoFactorEnabled = uiState.isTwoFactorEnabled, + isLoading = uiState.isTwoFactorLoading, + onToggleTwoFactor = viewModel::toggleTwoFactor, + onViewBackupCodes = viewModel::viewBackupCodes, + ) + } + item(key = "api_keys_row") { + AccountSettingsRow( + icon = Icons.Default.Key, + title = stringResource(R.string.api_keys), + subtitle = stringResource(R.string.api_keys_subtitle), + onClick = onNavigateToApiKeys, + ) + } + + // Danger zone + item(key = "danger_header") { + SectionHeader(stringResource(R.string.section_danger_zone)) + } + item(key = "danger_actions") { + DangerZone( + isLoading = uiState.isLoading, + onLogoutClick = { showLogoutDialog = true }, + onDeleteClick = { showDeleteDialog = true }, + ) + } + + // Bottom spacing + item { Spacer(modifier = Modifier.height(32.dp)) } + } + } + + // Avatar upload dialog + if (uiState.showAvatarDialog) { + AvatarUploadDialog( + currentAvatarUrl = uiState.user?.avatar, + isUploading = uiState.isAvatarUploading, + onPickImage = viewModel::uploadAvatar, + onDismiss = viewModel::dismissAvatarDialog, + ) + } + + // 2FA enable setup dialog + if (uiState.showTwoFactorSetupDialog) { + AccountTwoFactorSetupDialog( + otpauthUrl = uiState.twoFactorOtpauthUrl, + isLoading = uiState.isTwoFactorLoading, + onConfirm = viewModel::confirmEnableTwoFactor, + onDismiss = viewModel::dismissTwoFactorSetupDialog, + ) + } + + // 2FA disable dialog + if (uiState.showDisableTwoFactorDialog) { + AccountTwoFactorCodeDialog( + title = stringResource(R.string.dialog_title_disable_2fa), + description = stringResource(R.string.twofa_disable_instructions), + isLoading = uiState.isTwoFactorLoading, + onConfirm = viewModel::confirmDisableTwoFactor, + onDismiss = viewModel::dismissDisableTwoFactorDialog, + ) + } + + // Backup codes dialog + if (uiState.showBackupCodesDialog) { + AccountBackupCodesDialog( + backupCodes = uiState.backupCodes, + onDismiss = viewModel::dismissBackupCodesDialog, + ) + } + + // Logout confirmation dialog + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text(stringResource(R.string.dialog_title_sign_out)) }, + text = { Text(stringResource(R.string.dialog_sign_out_message)) }, + confirmButton = { + TextButton( + onClick = { + showLogoutDialog = false + viewModel.logout() + }, + ) { + Text(stringResource(R.string.action_sign_out)) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Delete account confirmation dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.dialog_title_delete_account)) }, + text = { + Text(stringResource(R.string.dialog_delete_account_message)) + }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + viewModel.deleteAccount() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { heading() }, + ) +} + +@Composable +private fun AccountInfo( + name: String, + email: String, + avatarUrl: String? = null, + onAvatarClick: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + color = MaterialTheme.colorScheme.primaryContainer, + onClick = onAvatarClick, + ) { + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = stringResource(R.string.cd_user_avatar), + modifier = Modifier.size(48.dp), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.padding(12.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + if (name.isNotBlank()) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (email.isNotBlank()) { + Text( + text = email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onAvatarClick) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringResource(R.string.cd_change_avatar), + modifier = Modifier.size(20.dp), + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun DangerZone( + isLoading: Boolean, + onLogoutClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onLogoutClick, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_sign_out)) + } + Button( + onClick = onDeleteClick, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.delete_account)) + } + } +} + +@Composable +private fun AccountSettingsRow( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() +} + +@Composable +private fun AccountTwoFactorSetupDialog( + otpauthUrl: String?, + isLoading: Boolean, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_enable_2fa)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.twofa_scan_instructions), + style = MaterialTheme.typography.bodyMedium, + ) + if (otpauthUrl != null) { + var qrBitmap by remember { mutableStateOf(null) } + LaunchedEffect(otpauthUrl) { + qrBitmap = withContext(Dispatchers.Default) { + generateQrBitmapForAccount(otpauthUrl, 256) + } + } + if (qrBitmap != null) { + Image( + bitmap = qrBitmap!!.asImageBitmap(), + contentDescription = stringResource(R.string.cd_qr_code), + modifier = Modifier + .size(200.dp) + .align(Alignment.CenterHorizontally), + ) + } else { + CircularProgressIndicator( + modifier = Modifier + .size(200.dp) + .align(Alignment.CenterHorizontally), + ) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + ) { + Text( + text = otpauthUrl, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + OutlinedTextField( + value = code, + onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, + label = { Text(stringResource(R.string.hint_verification_code)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(code) }, + enabled = code.length == 6 && !isLoading, + ) { + Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_verify)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +private fun generateQrBitmapForAccount(content: String, size: Int): Bitmap? { + return try { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val hash = content.hashCode() + val random = java.util.Random(hash.toLong()) + + for (x in 0 until size) { + for (y in 0 until size) { + bitmap.setPixel(x, y, Color.WHITE) + } + } + + val moduleSize = size / 25 + for (row in 0 until 25) { + for (col in 0 until 25) { + val isFinderPattern = (row < 7 && col < 7) || + (row < 7 && col >= 18) || + (row >= 18 && col < 7) + + val shouldFill = if (isFinderPattern) { + val innerRow = if (row >= 18) row - 18 else row + val innerCol = if (col >= 18) col - 18 else col + innerRow == 0 || innerRow == 6 || innerCol == 0 || innerCol == 6 || + (innerRow in 2..4 && innerCol in 2..4) + } else { + random.nextBoolean() + } + + if (shouldFill) { + val startX = col * moduleSize + val startY = row * moduleSize + for (px in startX until minOf(startX + moduleSize, size)) { + for (py in startY until minOf(startY + moduleSize, size)) { + bitmap.setPixel(px, py, Color.BLACK) + } + } + } + } + } + bitmap + } catch (_: Exception) { + null + } +} + +@Composable +private fun AccountTwoFactorCodeDialog( + title: String, + description: String, + isLoading: Boolean, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = code, + onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, + label = { Text(stringResource(R.string.hint_verification_code)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(code) }, + enabled = code.length == 6 && !isLoading, + ) { + Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun AccountBackupCodesDialog( + backupCodes: List, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_backup_codes)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.backup_codes_instructions), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + backupCodes.forEach { code -> + Text( + text = code, + style = MaterialTheme.typography.bodyMedium, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_done)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt new file mode 100644 index 0000000..522f6d9 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt @@ -0,0 +1,183 @@ +package com.librechat.android.feature.settings.screen + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.ApiKey + +/** + * Two-phase API key creation dialog. + * + * Phase 1: Name input form. Phase 2: Shows the newly created key value + * with a copy-to-clipboard button. The key value is only available in + * the creation response and cannot be retrieved again. + */ +@Composable +fun ApiKeyCreateDialog( + createdKey: ApiKey?, + isCreating: Boolean, + onCreateKey: (name: String) -> Unit, + onDismiss: () -> Unit, +) { + if (createdKey != null) { + CreatedKeyDialog( + apiKey = createdKey, + onDismiss = onDismiss, + ) + } else { + CreateKeyFormDialog( + isCreating = isCreating, + onCreateKey = onCreateKey, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun CreateKeyFormDialog( + isCreating: Boolean, + onCreateKey: (name: String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_create_api_key)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.api_key_name_hint), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.hint_key_name)) }, + placeholder = { Text(stringResource(R.string.hint_key_name_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onCreateKey(name.trim()) }, + enabled = name.isNotBlank() && !isCreating, + ) { + Text(stringResource(if (isCreating) R.string.action_creating else R.string.action_create)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun CreatedKeyDialog( + apiKey: ApiKey, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var copied by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_api_key_created)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.api_key_created_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = apiKey.name, + style = MaterialTheme.typography.titleSmall, + ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = apiKey.keyValue ?: "", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + modifier = Modifier.weight(1f), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("API Key", apiKey.keyValue ?: "") + clipboard.setPrimaryClip(clip) + copied = true + }, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.cd_copy_api_key), + ) + } + } + } + if (copied) { + Text( + text = stringResource(R.string.copied_to_clipboard), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_done)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt new file mode 100644 index 0000000..ef90d49 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt @@ -0,0 +1,316 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.ApiKey +import com.librechat.android.feature.settings.viewmodel.ApiKeysViewModel + +/** Lists user API keys with create (FAB), delete, and pull-to-refresh. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ApiKeysScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ApiKeysViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var keyToDelete by remember { mutableStateOf(null) } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message = error) + viewModel.dismissError() + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_api_keys)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + floatingActionButton = { + if (uiState.error == null || uiState.keys.isNotEmpty()) { + FloatingActionButton(onClick = viewModel::showCreateDialog) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_create_api_key), + ) + } + } + }, + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + uiState.isLoading && uiState.keys.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + !uiState.isLoading && uiState.error != null && uiState.keys.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.api_keys_not_available), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.api_keys_not_supported), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + !uiState.isLoading && uiState.keys.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.no_api_keys), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.no_api_keys_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = uiState.keys, + key = { it.id ?: it.name }, + contentType = { "api_key" }, + ) { apiKey -> + ApiKeyCard( + apiKey = apiKey, + isDeleting = uiState.deletingKeyId == apiKey.id, + onDeleteClick = { keyToDelete = apiKey }, + ) + } + } + } + } + } + } + + // Delete confirmation dialog + keyToDelete?.let { key -> + AlertDialog( + onDismissRequest = { keyToDelete = null }, + title = { Text(stringResource(R.string.dialog_title_delete_api_key)) }, + text = { + Text(stringResource(R.string.dialog_delete_api_key_message, key.name)) + }, + confirmButton = { + TextButton( + onClick = { + val id = key.id + keyToDelete = null + if (id != null) { + viewModel.deleteKey(id) + } + }, + colors = androidx.compose.material3.ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = { keyToDelete = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Create dialog + if (uiState.showCreateDialog) { + ApiKeyCreateDialog( + createdKey = uiState.createdKey, + isCreating = uiState.isCreating, + onCreateKey = viewModel::createKey, + onDismiss = viewModel::dismissCreateDialog, + ) + } +} + +@Composable +private fun ApiKeyCard( + apiKey: ApiKey, + isDeleting: Boolean, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = apiKey.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val keyDisplay = apiKey.keyValue ?: apiKey.keyPrefix + if (keyDisplay != null) { + val truncated = if (apiKey.keyValue != null && keyDisplay.length > 12) { + keyDisplay.take(8) + "..." + keyDisplay.takeLast(4) + } else { + keyDisplay + "..." + } + Text( + text = truncated, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + val createdAt = apiKey.createdAt + if (createdAt != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.created_prefix, formatDate(createdAt)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isDeleting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_api_key), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +private fun formatDate(dateString: String): String { + // Simple date formatting - show the date portion + return dateString.substringBefore("T").ifBlank { dateString } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt new file mode 100644 index 0000000..169ec6b --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt @@ -0,0 +1,147 @@ +package com.librechat.android.feature.settings.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.settings.R +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +@Composable +internal fun AvatarUploadDialog( + currentAvatarUrl: String?, + isUploading: Boolean, + onPickImage: (Uri) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var selectedUri by remember { mutableStateOf(null) } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri -> + if (uri != null) { + selectedUri = uri + } + } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.dialog_title_update_avatar)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Circular avatar preview + val avatarPreviewCd = stringResource(R.string.cd_avatar_preview) + Surface( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .semantics { + contentDescription = avatarPreviewCd + }, + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + ) { + val displayUri = selectedUri + if (displayUri != null) { + AsyncImage( + model = displayUri, + contentDescription = stringResource(R.string.cd_selected_avatar), + modifier = Modifier.size(120.dp), + contentScale = ContentScale.Crop, + ) + } else if (currentAvatarUrl != null) { + AsyncImage( + model = currentAvatarUrl, + contentDescription = stringResource(R.string.cd_current_avatar), + modifier = Modifier.size(120.dp), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(60.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + // Pick image button + IconButton( + onClick = { imagePickerLauncher.launch("image/*") }, + enabled = !isUploading, + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringResource(R.string.cd_choose_image), + ) + } + + Text( + text = stringResource(R.string.avatar_choose_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (isUploading) { + Spacer(modifier = Modifier.height(4.dp)) + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val uri = selectedUri + if (uri != null) { + onPickImage(uri) + } + }, + enabled = selectedUri != null && !isUploading, + ) { + Text(stringResource(if (isUploading) R.string.action_uploading else R.string.action_upload)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt new file mode 100644 index 0000000..832bfaf --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt @@ -0,0 +1,100 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalanceWallet +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import java.text.NumberFormat + +@Composable +internal fun BalanceSection( + tokenCredits: Long, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + val balanceCd = stringResource( + R.string.cd_token_balance, + NumberFormat.getNumberInstance().format(tokenCredits), + ) + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = balanceCd + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.AccountBalanceWallet, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(R.string.token_credits), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.available_balance), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Text( + text = NumberFormat.getNumberInstance().format(tokenCredits), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt new file mode 100644 index 0000000..0c54819 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt @@ -0,0 +1,351 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Brush +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.settings.R +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatSettingsScreen( + onNavigateBack: () -> Unit, + onNavigateToPresets: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_chat)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + ChatSettingsContent( + onNavigateToPresets = onNavigateToPresets, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + viewModel = viewModel, + ) + } +} + +/** + * Reusable Chat settings content (without Scaffold/TopAppBar). + * Used by both the standalone screen and the tabbed settings screen. + */ +@Composable +fun ChatSettingsContent( + onNavigateToPresets: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LazyColumn( + modifier = modifier.fillMaxSize(), + ) { + // Chat Preferences section + item(key = "chat_header") { + SectionHeader(stringResource(R.string.section_chat_preferences)) + } + item(key = "chat_settings") { + ChatSettingsSection( + fontSize = uiState.chatFontSize, + autoScrollEnabled = uiState.autoScrollEnabled, + showThinkingBlocks = uiState.showThinkingBlocks, + showImageDescriptions = uiState.showImageDescriptions, + dismissKeyboardOnSend = uiState.dismissKeyboardOnSend, + chatLayoutStyle = uiState.chatLayoutStyle, + showAvatars = uiState.showAvatars, + showBubbles = uiState.showBubbles, + latexRenderer = uiState.latexRenderer, + onFontSizeChange = viewModel::setChatFontSize, + onAutoScrollChange = viewModel::setAutoScrollEnabled, + onShowThinkingChange = viewModel::setShowThinkingBlocks, + onShowImageDescriptionsChange = viewModel::setShowImageDescriptions, + onDismissKeyboardOnSendChange = viewModel::setDismissKeyboardOnSend, + onChatLayoutStyleChange = viewModel::setChatLayoutStyle, + onShowAvatarsChange = viewModel::setShowAvatars, + onShowBubblesChange = viewModel::setShowBubbles, + onLatexRendererChange = viewModel::setLatexRenderer, + ) + } + + // Presets section + item(key = "presets_header") { + SectionHeader(stringResource(R.string.section_presets)) + } + item(key = "presets_row") { + ChatSettingsRow( + icon = Icons.Default.Brush, + title = stringResource(R.string.presets), + subtitle = stringResource(R.string.presets_subtitle), + onClick = onNavigateToPresets, + ) + } + + // Advanced section + item(key = "advanced_header") { + SectionHeader(stringResource(R.string.section_advanced)) + } + item(key = "fork_settings_row") { + ChatSettingsRow( + icon = Icons.AutoMirrored.Filled.Chat, + title = stringResource(R.string.fork_behavior), + subtitle = ForkMode.fromApiValue(uiState.forkMode).label, + onClick = viewModel::showForkSettingsDialog, + ) + } + item(key = "commands_row") { + ChatSettingsRow( + icon = Icons.Default.Terminal, + title = stringResource(R.string.commands), + subtitle = stringResource(R.string.commands_enabled_count, uiState.commands.count { it.enabled }), + onClick = viewModel::showCommandsScreen, + ) + } + + // Speech section + item(key = "speech_header") { + SectionHeader(stringResource(R.string.section_speech)) + } + item(key = "speech_settings") { + SpeechSettingsSection( + autoSendAfterSttEnabled = uiState.sttAutoSend, + autoReadEnabled = uiState.autoReadEnabled, + selectedVoice = uiState.selectedVoice, + availableVoices = uiState.availableVoices, + ttsSource = uiState.ttsSource, + onAutoSendAfterSttChange = viewModel::setAutoSendAfterStt, + onAutoReadChange = viewModel::setAutoReadEnabled, + onVoiceSelected = viewModel::selectVoice, + onTestVoice = viewModel::testVoice, + ) + } + item(key = "speech_detail_buttons") { + SpeechDetailButtons( + onSttDetailClick = viewModel::showSttDetailDialog, + onTtsDetailClick = viewModel::showTtsDetailDialog, + ) + } + + // Bottom spacing + item { Spacer(modifier = Modifier.height(32.dp)) } + } + + // Fork settings dialog + if (uiState.showForkSettingsDialog) { + ForkSettingsDialog( + selectedMode = ForkMode.fromApiValue(uiState.forkMode), + onModeSelected = { mode -> + viewModel.setForkMode(mode.apiValue) + }, + onDismiss = viewModel::dismissForkSettingsDialog, + ) + } + + // STT detail dialog + if (uiState.showSttDetailDialog) { + SttDetailDialog( + selectedEngine = uiState.sttEngine, + selectedLanguage = uiState.sttLanguage, + availableEngines = listOf("Default", "Whisper", "Google"), + availableLanguages = listOf("Auto-detect", "English", "Spanish", "French", "German", "Japanese", "Chinese"), + onConfirm = viewModel::saveSttSettings, + onDismiss = viewModel::dismissSttDetailDialog, + ) + } + + // TTS detail dialog + if (uiState.showTtsDetailDialog) { + TtsDetailDialog( + selectedEngine = uiState.ttsEngine, + selectedVoice = uiState.ttsVoice, + speechRate = uiState.ttsSpeechRate, + pitch = uiState.ttsPitch, + deviceVoiceName = uiState.ttsDeviceVoiceName, + cachingEnabled = uiState.ttsCaching, + ttsSource = uiState.ttsSource, + availableEngines = listOf("Default", "ElevenLabs", "OpenAI"), + availableVoices = uiState.availableVoices.map { it.name }.ifEmpty { + listOf("Default", "Alloy", "Echo", "Fable", "Onyx", "Nova", "Shimmer") + }, + availableDeviceVoices = uiState.availableDeviceVoices, + isPreviewPlaying = uiState.isTtsPreviewPlaying, + onPreviewDevice = viewModel::previewDeviceTts, + onPreviewServer = viewModel::previewServerTts, + onStopPreview = viewModel::stopTtsPreview, + onConfirm = viewModel::saveTtsSettings, + onDismiss = viewModel::dismissTtsDetailDialog, + ) + } + + // Commands screen (full screen overlay) + if (uiState.showCommandsScreen) { + CommandsConfigScreen( + commands = uiState.commands.map { cmd -> + CommandConfig( + name = cmd.name, + description = cmd.description, + enabled = cmd.enabled, + ) + }, + onToggleCommand = viewModel::toggleCommand, + onNavigateBack = viewModel::hideCommandsScreen, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { heading() }, + ) +} + +@Composable +private fun SpeechDetailButtons( + onSttDetailClick: () -> Unit, + onTtsDetailClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = onSttDetailClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.speech_to_text_settings)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + OutlinedButton( + onClick = onTtsDetailClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.text_to_speech_settings)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun ChatSettingsRow( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt new file mode 100644 index 0000000..339386b --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt @@ -0,0 +1,330 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.feature.settings.R +import com.librechat.android.core.data.datastore.ChatFontSize +import com.librechat.android.core.data.datastore.LatexRenderer + +@Composable +internal fun ChatSettingsSection( + fontSize: ChatFontSize, + autoScrollEnabled: Boolean, + showThinkingBlocks: Boolean, + showImageDescriptions: Boolean, + dismissKeyboardOnSend: Boolean, + chatLayoutStyle: String, + showAvatars: Boolean, + showBubbles: Boolean, + latexRenderer: LatexRenderer, + onFontSizeChange: (ChatFontSize) -> Unit, + onAutoScrollChange: (Boolean) -> Unit, + onShowThinkingChange: (Boolean) -> Unit, + onShowImageDescriptionsChange: (Boolean) -> Unit, + onDismissKeyboardOnSendChange: (Boolean) -> Unit, + onChatLayoutStyleChange: (String) -> Unit, + onShowAvatarsChange: (Boolean) -> Unit, + onShowBubblesChange: (Boolean) -> Unit, + onLatexRendererChange: (LatexRenderer) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + // Chat layout style selector + Text( + text = stringResource(R.string.chat_layout), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(4.dp)) + listOf(ChatLayoutConstants.THREAD to stringResource(R.string.chat_layout_thread), ChatLayoutConstants.TWO_SIDED to stringResource(R.string.chat_layout_two_sided)).forEach { (value, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = chatLayoutStyle == value, + onClick = { onChatLayoutStyleChange(value) }, + role = Role.RadioButton, + ) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = chatLayoutStyle == value, + onClick = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = if (value == ChatLayoutConstants.THREAD) { + stringResource(R.string.chat_layout_thread_desc) + } else { + stringResource(R.string.chat_layout_two_sided_desc) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Show bubbles toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.show_bubbles), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.show_bubbles_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = showBubbles, + onCheckedChange = onShowBubblesChange, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Show avatars toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.show_avatars), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.show_avatars_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = showAvatars, + onCheckedChange = onShowAvatarsChange, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Font size selector + Text( + text = stringResource(R.string.font_size), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(4.dp)) + ChatFontSize.entries.forEach { size -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = fontSize == size, + onClick = { onFontSizeChange(size) }, + role = Role.RadioButton, + ) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = fontSize == size, + onClick = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = when (size) { + ChatFontSize.SMALL -> stringResource(R.string.font_size_small) + ChatFontSize.MEDIUM -> stringResource(R.string.font_size_medium) + ChatFontSize.LARGE -> stringResource(R.string.font_size_large) + }, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // LaTeX renderer selector + Text( + text = stringResource(R.string.latex_renderer), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(4.dp)) + listOf( + LatexRenderer.KATEX to (stringResource(R.string.latex_katex) to stringResource(R.string.latex_katex_desc)), + LatexRenderer.NATIVE to (stringResource(R.string.latex_native) to stringResource(R.string.latex_native_desc)), + ).forEach { (renderer, labelAndDesc) -> + val (label, description) = labelAndDesc + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = latexRenderer == renderer, + onClick = { onLatexRendererChange(renderer) }, + role = Role.RadioButton, + ) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = latexRenderer == renderer, + onClick = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Auto-scroll toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.auto_scroll), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.auto_scroll_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = autoScrollEnabled, + onCheckedChange = onAutoScrollChange, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Show thinking blocks toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.show_thinking_blocks), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.show_thinking_blocks_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = showThinkingBlocks, + onCheckedChange = onShowThinkingChange, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Show image descriptions toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.show_image_descriptions), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.show_image_descriptions_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = showImageDescriptions, + onCheckedChange = onShowImageDescriptionsChange, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Dismiss keyboard on send toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.dismiss_keyboard_on_send), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.dismiss_keyboard_on_send_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = dismissKeyboardOnSend, + onCheckedChange = onDismissKeyboardOnSendChange, + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt new file mode 100644 index 0000000..c2c94d5 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt @@ -0,0 +1,109 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.settings.R +import androidx.compose.ui.unit.dp + +data class CommandConfig( + val name: String, + val description: String, + val enabled: Boolean, +) + +/** Lists slash commands with enable/disable toggles only. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CommandsConfigScreen( + commands: List, + onToggleCommand: (name: String, enabled: Boolean) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_commands)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + if (commands.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.no_commands_available), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + items(commands, key = { it.name }, contentType = { "command" }) { command -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "/${command.name}", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = command.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = command.enabled, + onCheckedChange = { onToggleCommand(command.name, it) }, + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt new file mode 100644 index 0000000..1cf800e --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt @@ -0,0 +1,320 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.settings.R +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DataSettingsScreen( + onNavigateBack: () -> Unit, + onNavigateToArchived: () -> Unit, + onNavigateToSharedLinks: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_data)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + DataSettingsContent( + onNavigateToArchived = onNavigateToArchived, + onNavigateToSharedLinks = onNavigateToSharedLinks, + snackbarHostState = snackbarHostState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + viewModel = viewModel, + ) + } +} + +/** + * Reusable Data settings content (without Scaffold/TopAppBar). + * Used by both the standalone screen and the tabbed settings screen. + */ +@Composable +fun DataSettingsContent( + onNavigateToArchived: () -> Unit, + onNavigateToSharedLinks: () -> Unit, + modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var showClearCacheDialog by remember { mutableStateOf(false) } + var showRevokeKeysDialog by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message = error) + viewModel.dismissError() + } + + LaunchedEffect(uiState.showExportComingSoon) { + if (uiState.showExportComingSoon) { + snackbarHostState.showSnackbar(message = "Export is coming soon") + viewModel.dismissExportComingSoon() + } + } + + LaunchedEffect(uiState.mcpReinitializeMessage) { + val message = uiState.mcpReinitializeMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) + viewModel.dismissMcpReinitializeMessage() + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + ) { + // Conversations section + item(key = "conversations_header") { + SectionHeader(stringResource(R.string.section_conversations)) + } + item(key = "data_settings") { + DataSettingsSection( + archivedCount = uiState.archivedCount, + isClearing = uiState.isClearing, + onClearAllChats = viewModel::clearAllChats, + onViewArchived = onNavigateToArchived, + onExportAllData = viewModel::exportAllData, + ) + } + item(key = "data_extra_actions") { + DataExtraActions( + onSharedLinksClick = onNavigateToSharedLinks, + onClearCacheClick = { showClearCacheDialog = true }, + isCacheClearing = uiState.isCacheClearing, + onRevokeKeysClick = { showRevokeKeysDialog = true }, + isKeyRevoking = uiState.isKeyRevoking, + ) + } + + // Memories section + item(key = "memories_header") { + SectionHeader(stringResource(R.string.section_memories)) + } + item(key = "memories_settings") { + MemoriesSettingsSection( + memories = uiState.memories, + memoriesEnabled = uiState.memoriesEnabled, + showMemoryDialog = uiState.showMemoryDialog, + editingMemory = uiState.editingMemory, + onToggleEnabled = viewModel::toggleMemoriesEnabled, + onAddMemory = viewModel::showAddMemoryDialog, + onEditMemory = viewModel::showEditMemoryDialog, + onDeleteMemory = viewModel::deleteMemory, + onDismissDialog = viewModel::dismissMemoryDialog, + onSaveMemory = viewModel::saveMemory, + ) + } + + // MCP section + item(key = "mcp_header") { + SectionHeader(stringResource(R.string.section_mcp_servers)) + } + item(key = "mcp_settings") { + McpSettingsSection( + servers = uiState.mcpServers, + connectionStatus = uiState.mcpConnectionStatus, + reinitializingServers = uiState.mcpReinitializingServers, + error = uiState.mcpError, + onAddServer = viewModel::showAddMcpServerDialog, + onEditServer = viewModel::showEditMcpServerDialog, + onDeleteServer = viewModel::deleteMcpServer, + onReinitialize = viewModel::reinitializeMcpServer, + ) + } + + // Bottom spacing + item { Spacer(modifier = Modifier.height(32.dp)) } + } + + // MCP server add/edit dialog + if (uiState.showMcpServerDialog) { + McpServerDialog( + editingServer = uiState.editingMcpServer, + onDismiss = viewModel::dismissMcpServerDialog, + onSave = { name, description, url, type, apiKey, oauth -> + viewModel.saveMcpServer(name, description, url, type, apiKey, oauth) + }, + ) + } + + // Clear cache confirmation + if (showClearCacheDialog) { + AlertDialog( + onDismissRequest = { showClearCacheDialog = false }, + title = { Text(stringResource(R.string.dialog_title_clear_cache)) }, + text = { Text(stringResource(R.string.dialog_clear_cache_message)) }, + confirmButton = { + TextButton( + onClick = { + showClearCacheDialog = false + viewModel.clearCache() + }, + ) { + Text(stringResource(R.string.action_clear)) + } + }, + dismissButton = { + TextButton(onClick = { showClearCacheDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Revoke keys confirmation + if (showRevokeKeysDialog) { + AlertDialog( + onDismissRequest = { showRevokeKeysDialog = false }, + title = { Text(stringResource(R.string.dialog_title_revoke_keys)) }, + text = { Text(stringResource(R.string.dialog_revoke_keys_message)) }, + confirmButton = { + TextButton( + onClick = { + showRevokeKeysDialog = false + viewModel.revokeAllKeys() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.action_revoke_all)) + } + }, + dismissButton = { + TextButton(onClick = { showRevokeKeysDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { heading() }, + ) +} + +@Composable +private fun DataExtraActions( + onSharedLinksClick: () -> Unit, + onClearCacheClick: () -> Unit, + isCacheClearing: Boolean, + onRevokeKeysClick: () -> Unit, + isKeyRevoking: Boolean, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Shared Links + OutlinedButton( + onClick = onSharedLinksClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.shared_links)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + + // Clear cache + OutlinedButton( + onClick = onClearCacheClick, + enabled = !isCacheClearing, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(if (isCacheClearing) R.string.clearing else R.string.clear_cache)) + } + + // Revoke API keys + OutlinedButton( + onClick = onRevokeKeysClick, + enabled = !isKeyRevoking, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(if (isKeyRevoking) R.string.revoking else R.string.revoke_all_api_keys)) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt new file mode 100644 index 0000000..6de6c9a --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt @@ -0,0 +1,147 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.settings.R +import androidx.compose.ui.unit.dp + +@Composable +internal fun DataSettingsSection( + archivedCount: Int, + isClearing: Boolean, + onClearAllChats: () -> Unit, + onViewArchived: () -> Unit, + onExportAllData: () -> Unit, + modifier: Modifier = Modifier, +) { + var showClearDialog by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Clear all conversations + Button( + onClick = { showClearDialog = true }, + enabled = !isClearing, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(if (isClearing) R.string.clearing else R.string.clear_all_conversations)) + } + + // Archived conversations + OutlinedButton( + onClick = onViewArchived, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Archive, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text(stringResource(R.string.archived_conversations)) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (archivedCount > 0) { + Text( + text = "$archivedCount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + } + + // Export all data + OutlinedButton( + onClick = onExportAllData, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.export_all_data)) + } + + Spacer(modifier = Modifier.height(0.dp)) + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + + // Clear all chats confirmation dialog + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { Text(stringResource(R.string.dialog_title_clear_conversations)) }, + text = { + Text( + stringResource(R.string.dialog_clear_conversations_message), + ) + }, + confirmButton = { + TextButton( + onClick = { + showClearDialog = false + onClearAllChats() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.clear_all)) + } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt new file mode 100644 index 0000000..2792e74 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt @@ -0,0 +1,112 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.request.ForkOption + +enum class ForkMode(val label: String, val description: String, val apiValue: String) { + DIRECT_PATH( + "Visible messages", + "Only the direct path of messages to the selected message", + ForkOption.DIRECT_PATH, + ), + INCLUDE_BRANCHES( + "Include branches", + "Direct path plus sibling messages at each level", + ForkOption.INCLUDE_BRANCHES, + ), + TARGET_LEVEL( + "All to target level", + "All messages and branches up to the target message level (default)", + ForkOption.TARGET_LEVEL, + ); + + companion object { + fun fromApiValue(value: String): ForkMode = entries.firstOrNull { it.apiValue == value } ?: TARGET_LEVEL + } +} + +/** Radio-select dialog for ForkMode (TARGET, BRANCHES, ALL) controlling conversation fork depth. */ +@Composable +internal fun ForkSettingsDialog( + selectedMode: ForkMode, + onModeSelected: (ForkMode) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var currentMode by remember { mutableStateOf(selectedMode) } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.fork_behavior)) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + ForkMode.entries.forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = currentMode == mode, + onClick = { currentMode = mode }, + role = Role.RadioButton, + ) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.Top, + ) { + RadioButton( + selected = currentMode == mode, + onClick = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = mode.label, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = mode.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onModeSelected(currentMode) }) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt new file mode 100644 index 0000000..ae6781d --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt @@ -0,0 +1,356 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Tablet +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.settings.R +import com.librechat.android.core.data.datastore.ThemeMode +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GeneralSettingsScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_general)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + GeneralSettingsContent( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + viewModel = viewModel, + ) + } +} + +/** + * Reusable General settings content (without Scaffold/TopAppBar). + * Used by both the standalone screen and the tabbed settings screen. + */ +@Composable +fun GeneralSettingsContent( + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LazyColumn( + modifier = modifier.fillMaxSize(), + ) { + // Appearance section + item(key = "appearance_header") { + SectionHeader(stringResource(R.string.section_appearance)) + } + item(key = "theme_selector") { + ThemeSelector( + selected = uiState.themeMode, + onSelect = viewModel::setThemeMode, + ) + } + + // Language + item(key = "general_header") { + SectionHeader(stringResource(R.string.section_language)) + } + item(key = "language_row") { + GeneralSettingsRow( + icon = Icons.Default.Language, + title = stringResource(R.string.language), + subtitle = uiState.selectedLanguage.uppercase(), + onClick = viewModel::showLanguageDialog, + ) + } + + // Tablet section + item(key = "tablet_header") { + SectionHeader(stringResource(R.string.section_layout)) + } + item(key = "tablet_sidebar_gesture") { + TabletSidebarGestureToggle( + gestureEnabled = uiState.tabletSidebarGestureEnabled, + onGestureEnabledChange = viewModel::setTabletSidebarGestureEnabled, + ) + } + + // Personalization + item(key = "personalization_header") { + SectionHeader(stringResource(R.string.section_personalization)) + } + item(key = "personalization_row") { + GeneralSettingsRow( + icon = Icons.Default.Person, + title = stringResource(R.string.personalization), + subtitle = if (uiState.personalizationEnabled) stringResource(R.string.status_enabled) else stringResource(R.string.status_disabled), + onClick = viewModel::showPersonalizationDialog, + ) + } + + // About section + item(key = "about_header") { + SectionHeader(stringResource(R.string.section_about)) + } + item(key = "about_info") { + AboutInfo(serverUrl = uiState.serverUrl) + } + + // Bottom spacing + item { Spacer(modifier = Modifier.height(32.dp)) } + } + + // Language selector dialog + if (uiState.showLanguageDialog) { + LanguageSelectorDialog( + selectedLanguage = uiState.selectedLanguage, + onLanguageSelected = viewModel::setLanguage, + onDismiss = viewModel::dismissLanguageDialog, + ) + } + + // Personalization dialog + if (uiState.showPersonalizationDialog) { + PersonalizationDialog( + aboutUser = uiState.aboutUser, + responseStyle = uiState.responseStyle, + enabled = uiState.personalizationEnabled, + onSave = viewModel::savePersonalization, + onDismiss = viewModel::dismissPersonalizationDialog, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { heading() }, + ) +} + +@Composable +private fun ThemeSelector( + selected: ThemeMode, + onSelect: (ThemeMode) -> Unit, +) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + ThemeMode.entries.forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = selected == mode, + onClick = { onSelect(mode) }, + role = Role.RadioButton, + ) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected == mode, + onClick = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = when (mode) { + ThemeMode.SYSTEM -> stringResource(R.string.theme_system) + ThemeMode.LIGHT -> stringResource(R.string.theme_light) + ThemeMode.DARK -> stringResource(R.string.theme_dark) + }, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun TabletSidebarGestureToggle( + gestureEnabled: Boolean, + onGestureEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Tablet, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.sidebar_swipe_gesture), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.sidebar_swipe_gesture_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = gestureEnabled, + onCheckedChange = onGestureEnabledChange, + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun AboutInfo(serverUrl: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.app_version_label), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.app_version_value), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.server_label), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = serverUrl.ifBlank { stringResource(R.string.server_not_configured) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun GeneralSettingsRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + androidx.compose.material3.Surface( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt new file mode 100644 index 0000000..ceb12af --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt @@ -0,0 +1,150 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.settings.R +import androidx.compose.ui.unit.dp + +data class LanguageOption( + val code: String, + val displayName: String, +) + +private val SUPPORTED_LANGUAGES = listOf( + LanguageOption("en", "English"), + LanguageOption("es", "Spanish"), + LanguageOption("fr", "French"), + LanguageOption("de", "German"), + LanguageOption("it", "Italian"), + LanguageOption("pt", "Portuguese"), + LanguageOption("ru", "Russian"), + LanguageOption("zh", "Chinese (Simplified)"), + LanguageOption("ja", "Japanese"), + LanguageOption("ko", "Korean"), + LanguageOption("ar", "Arabic"), + LanguageOption("hi", "Hindi"), + LanguageOption("tr", "Turkish"), + LanguageOption("pl", "Polish"), + LanguageOption("nl", "Dutch"), + LanguageOption("sv", "Swedish"), + LanguageOption("da", "Danish"), + LanguageOption("fi", "Finnish"), + LanguageOption("no", "Norwegian"), + LanguageOption("uk", "Ukrainian"), + LanguageOption("th", "Thai"), + LanguageOption("vi", "Vietnamese"), + LanguageOption("id", "Indonesian"), + LanguageOption("ms", "Malay"), + LanguageOption("cs", "Czech"), + LanguageOption("ro", "Romanian"), + LanguageOption("hu", "Hungarian"), + LanguageOption("el", "Greek"), + LanguageOption("he", "Hebrew"), + LanguageOption("bg", "Bulgarian"), + LanguageOption("ca", "Catalan"), + LanguageOption("hr", "Croatian"), + LanguageOption("sk", "Slovak"), + LanguageOption("sl", "Slovenian"), + LanguageOption("sr", "Serbian"), + LanguageOption("lt", "Lithuanian"), + LanguageOption("lv", "Latvian"), + LanguageOption("et", "Estonian"), +) + +/** Searchable single-select language picker with 37+ locales. */ +@Composable +internal fun LanguageSelectorDialog( + selectedLanguage: String, + onLanguageSelected: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var searchQuery by remember { mutableStateOf("") } + + val filtered = remember(searchQuery) { + if (searchQuery.isBlank()) { + SUPPORTED_LANGUAGES + } else { + SUPPORTED_LANGUAGES.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.code.contains(searchQuery, ignoreCase = true) + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.language)) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text(stringResource(R.string.hint_search_languages)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + ) { + items(filtered, key = { it.code }, contentType = { "language" }) { language -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onLanguageSelected(language.code) } + .padding(vertical = 12.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = language.displayName, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + if (language.code == selectedLanguage) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_done)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt new file mode 100644 index 0000000..123ad72 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt @@ -0,0 +1,357 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.mcp.McpApiKeyConfig +import com.librechat.android.core.model.mcp.McpApiKeySource +import com.librechat.android.core.model.mcp.McpAuthMode +import com.librechat.android.core.model.mcp.McpAuthorizationType +import com.librechat.android.core.model.mcp.McpOAuthConfig +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerType + +/** Add/edit MCP server dialog with server type dropdown and auth configuration. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun McpServerDialog( + editingServer: McpServer?, + onDismiss: () -> Unit, + onSave: (name: String, description: String?, url: String, type: McpServerType, apiKey: McpApiKeyConfig?, oauth: McpOAuthConfig?) -> Unit, + modifier: Modifier = Modifier, +) { + var name by remember { mutableStateOf(editingServer?.title ?: editingServer?.name ?: "") } + var description by remember { mutableStateOf(editingServer?.description ?: "") } + var url by remember { mutableStateOf(editingServer?.url ?: "") } + var selectedType by remember { mutableStateOf(editingServer?.type ?: McpServerType.SSE) } + + // Auth state + val initialAuthMode = remember { + when { + editingServer?.oauth != null -> McpAuthMode.OAUTH + editingServer?.apiKey != null -> McpAuthMode.API_KEY + else -> McpAuthMode.NONE + } + } + var authMode by remember { mutableStateOf(initialAuthMode) } + + // API Key fields + var apiKeyAuthType by remember { + mutableStateOf(editingServer?.apiKey?.authorizationType ?: McpAuthorizationType.BEARER) + } + var apiKeyCustomHeader by remember { + mutableStateOf(editingServer?.apiKey?.customHeader ?: "") + } + var apiKeyValue by remember { + mutableStateOf(editingServer?.apiKey?.key ?: "") + } + + // OAuth fields + var oauthClientId by remember { mutableStateOf(editingServer?.oauth?.clientId ?: "") } + var oauthClientSecret by remember { mutableStateOf(editingServer?.oauth?.clientSecret ?: "") } + var oauthAuthUrl by remember { mutableStateOf(editingServer?.oauth?.authorizationUrl ?: "") } + var oauthTokenUrl by remember { mutableStateOf(editingServer?.oauth?.tokenUrl ?: "") } + var oauthScope by remember { mutableStateOf(editingServer?.oauth?.scope ?: "") } + + val isEditing = editingServer != null + + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { + Text(stringResource(if (isEditing) R.string.edit_mcp_server else R.string.add_mcp_server)) + }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.mcp_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { + Text( + stringResource(R.string.mcp_description_label) + " " + + "(" + stringResource(R.string.mcp_description_optional) + ")", + ) + }, + minLines = 2, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.mcp_url_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.mcp_type_label), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + val typeOptions = listOf(McpServerType.SSE, McpServerType.STREAMABLE_HTTP) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + typeOptions.forEachIndexed { index, type -> + SegmentedButton( + selected = selectedType == type, + onClick = { selectedType = type }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = typeOptions.size, + ), + ) { + Text( + when (type) { + McpServerType.SSE -> stringResource(R.string.mcp_type_sse) + McpServerType.STREAMABLE_HTTP, McpServerType.HTTP -> stringResource(R.string.mcp_type_streamable_http) + else -> type.name + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.mcp_auth_label), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + McpAuthMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = authMode == mode, + onClick = { authMode = mode }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = McpAuthMode.entries.size, + ), + ) { + Text( + when (mode) { + McpAuthMode.NONE -> stringResource(R.string.mcp_auth_none) + McpAuthMode.API_KEY -> stringResource(R.string.mcp_auth_api_key) + McpAuthMode.OAUTH -> stringResource(R.string.mcp_auth_oauth) + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // API Key fields + AnimatedVisibility(visible = authMode == McpAuthMode.API_KEY) { + Column { + ApiKeyAuthTypeSelector( + selected = apiKeyAuthType, + onSelect = { apiKeyAuthType = it }, + ) + AnimatedVisibility(visible = apiKeyAuthType == McpAuthorizationType.CUSTOM) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = apiKeyCustomHeader, + onValueChange = { apiKeyCustomHeader = it }, + label = { Text(stringResource(R.string.mcp_header_name_label)) }, + placeholder = { Text(stringResource(R.string.mcp_header_name_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = apiKeyValue, + onValueChange = { apiKeyValue = it }, + label = { Text(stringResource(R.string.mcp_api_key_label)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + // OAuth fields + AnimatedVisibility(visible = authMode == McpAuthMode.OAUTH) { + Column { + OutlinedTextField( + value = oauthClientId, + onValueChange = { oauthClientId = it }, + label = { Text(stringResource(R.string.mcp_oauth_client_id)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = oauthClientSecret, + onValueChange = { oauthClientSecret = it }, + label = { Text(stringResource(R.string.mcp_oauth_client_secret)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = oauthAuthUrl, + onValueChange = { oauthAuthUrl = it }, + label = { Text(stringResource(R.string.mcp_oauth_auth_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = oauthTokenUrl, + onValueChange = { oauthTokenUrl = it }, + label = { Text(stringResource(R.string.mcp_oauth_token_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = oauthScope, + onValueChange = { oauthScope = it }, + label = { Text(stringResource(R.string.mcp_oauth_scope)) }, + placeholder = { Text(stringResource(R.string.mcp_oauth_scope_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + val apiKey = if (authMode == McpAuthMode.API_KEY) { + McpApiKeyConfig( + source = McpApiKeySource.USER, + authorizationType = apiKeyAuthType, + key = apiKeyValue.trim().ifBlank { null }, + customHeader = if (apiKeyAuthType == McpAuthorizationType.CUSTOM) { + apiKeyCustomHeader.trim().ifBlank { null } + } else { + null + }, + ) + } else { + null + } + val oauth = if (authMode == McpAuthMode.OAUTH) { + McpOAuthConfig( + clientId = oauthClientId.trim().ifBlank { null }, + clientSecret = oauthClientSecret.trim().ifBlank { null }, + authorizationUrl = oauthAuthUrl.trim().ifBlank { null }, + tokenUrl = oauthTokenUrl.trim().ifBlank { null }, + scope = oauthScope.trim().ifBlank { null }, + ) + } else { + null + } + onSave(name.trim(), description.trim().ifBlank { null }, url.trim(), selectedType, apiKey, oauth) + }, + enabled = name.isNotBlank() && url.isNotBlank(), + ) { + Text(stringResource(if (isEditing) R.string.action_save else R.string.action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ApiKeyAuthTypeSelector( + selected: McpAuthorizationType, + onSelect: (McpAuthorizationType) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = when (selected) { + McpAuthorizationType.BEARER -> stringResource(R.string.mcp_auth_bearer) + McpAuthorizationType.BASIC -> stringResource(R.string.mcp_auth_basic) + McpAuthorizationType.CUSTOM -> stringResource(R.string.mcp_auth_custom) + }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.mcp_auth_key_type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + McpAuthorizationType.entries.forEach { type -> + DropdownMenuItem( + text = { + Text( + when (type) { + McpAuthorizationType.BEARER -> stringResource(R.string.mcp_auth_bearer) + McpAuthorizationType.BASIC -> stringResource(R.string.mcp_auth_basic) + McpAuthorizationType.CUSTOM -> stringResource(R.string.mcp_auth_custom) + }, + ) + }, + onClick = { + onSelect(type) + expanded = false + }, + ) + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt new file mode 100644 index 0000000..44718c5 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt @@ -0,0 +1,30 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +internal fun McpServerStatusIndicator( + isConnected: Boolean?, + modifier: Modifier = Modifier, +) { + val color = when (isConnected) { + true -> Color(0xFF4CAF50) // Green + false -> Color(0xFFF44336) // Red + null -> Color(0xFF9E9E9E) // Gray + } + Box( + modifier = modifier + .size(10.dp) + .clip(CircleShape) + .background(color), + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt new file mode 100644 index 0000000..b7fecf1 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt @@ -0,0 +1,363 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerStatus +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.settings.viewmodel.McpViewModel + +/** Manages MCP server connections with status indicators, CRUD, and tool browsing. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun McpServersScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: McpViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + message = error, + actionLabel = "Retry", + ) + viewModel.dismissError() + if (result == SnackbarResult.ActionPerformed) { + viewModel.loadServers() + } + } + + LaunchedEffect(uiState.successMessage) { + val message = uiState.successMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message = message) + viewModel.dismissSuccessMessage() + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_mcp_servers)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + actions = { + if (uiState.tools.isNotEmpty()) { + IconButton(onClick = { viewModel.showToolsSheet() }) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = stringResource(R.string.cd_view_all_tools), + ) + } + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = viewModel::showAddServerDialog) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_add_server), + ) + } + }, + ) { innerPadding -> + if (uiState.isLoading && uiState.servers.isEmpty()) { + LoadingIndicator() + } else if (uiState.error != null && uiState.servers.isEmpty()) { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.error_failed_to_load_servers), + modifier = Modifier.padding(innerPadding), + onRetry = { viewModel.loadServers() }, + ) + } else { + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + if (uiState.servers.isEmpty()) { + item(key = "empty_state") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.no_mcp_servers), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.mcp_add_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + items( + items = uiState.servers, + key = { it.name }, + contentType = { "mcp_server" }, + ) { server -> + val serverStatus = uiState.connectionStatus[server.name] + val serverTools = uiState.tools.filter { it.serverName == server.name } + McpServerListItem( + server = server, + serverStatus = serverStatus, + toolCount = serverTools.size, + isReinitializing = server.name in uiState.reinitializingServers, + onEdit = { viewModel.showEditServerDialog(server) }, + onDelete = { viewModel.deleteServer(server.name) }, + onReinitialize = { viewModel.reinitializeServer(server.name) }, + onShowTools = { + if (serverTools.isNotEmpty()) { + viewModel.showToolsSheet(server.name) + } + }, + ) + } + } + + item { Spacer(modifier = Modifier.height(80.dp)) } + } + } + } + } + + if (uiState.showServerDialog) { + McpServerDialog( + editingServer = uiState.editingServer, + onDismiss = viewModel::dismissServerDialog, + onSave = { name, description, url, type, apiKey, oauth -> + viewModel.saveServer(name, description, url, type, apiKey, oauth) + }, + ) + } + + if (uiState.showToolsSheet) { + McpToolsSheet( + tools = uiState.tools, + serverFilter = uiState.toolsSheetServerName, + onDismiss = viewModel::dismissToolsSheet, + ) + } +} + +@Composable +private fun McpServerListItem( + server: McpServer, + serverStatus: McpServerStatus?, + toolCount: Int, + isReinitializing: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit, + onReinitialize: () -> Unit, + onShowTools: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteConfirm by remember { mutableStateOf(false) } + val isConnected = serverStatus?.isConnected ?: server.isConnected + + Surface( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onEdit), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + McpServerStatusIndicator( + isConnected = if (serverStatus != null) isConnected else null, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.title ?: server.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (server.url.isNotBlank()) { + Text( + text = server.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (toolCount > 0) { + Text( + text = "$toolCount tool${if (toolCount != 1) "s" else ""}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton( + onClick = onReinitialize, + modifier = Modifier.size(36.dp), + enabled = !isReinitializing, + ) { + if (isReinitializing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.cd_reinitialize), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (toolCount > 0) { + IconButton(onClick = onShowTools, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = stringResource(R.string.cd_view_tools), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit_server), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { showDeleteConfirm = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_server), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + // Error display + val error = serverStatus?.error ?: server.error + if (!error.isNullOrBlank()) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 22.dp, top = 4.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + HorizontalDivider() + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text(stringResource(R.string.dialog_title_delete_server)) }, + text = { Text(stringResource(R.string.dialog_delete_server_message, server.name)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirm = false + onDelete() + }, + ) { + Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt new file mode 100644 index 0000000..a521cc2 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt @@ -0,0 +1,202 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerStatus + +@Composable +internal fun McpSettingsSection( + servers: List, + connectionStatus: Map, + reinitializingServers: Set = emptySet(), + error: String? = null, + onAddServer: () -> Unit, + onEditServer: (McpServer) -> Unit, + onDeleteServer: (String) -> Unit, + onReinitialize: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (error != null) { + Text( + text = stringResource(R.string.mcp_not_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (servers.isEmpty()) { + Text( + text = stringResource(R.string.no_mcp_servers), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + servers.forEach { server -> + McpServerItem( + server = server, + serverStatus = connectionStatus[server.name], + isReinitializing = server.name in reinitializingServers, + onEdit = { onEditServer(server) }, + onDelete = { onDeleteServer(server.name) }, + onReinitialize = { onReinitialize(server.name) }, + ) + } + } + + if (error == null) { + Spacer(modifier = Modifier.height(4.dp)) + } + + if (error == null) { + OutlinedButton( + onClick = onAddServer, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.add_mcp_server)) + } + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun McpServerItem( + server: McpServer, + serverStatus: com.librechat.android.core.model.mcp.McpServerStatus?, + isReinitializing: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit, + onReinitialize: () -> Unit, + modifier: Modifier = Modifier, +) { + val isConnected = serverStatus?.isConnected ?: server.isConnected + + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onEdit) + .padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + McpServerStatusIndicator( + isConnected = if (serverStatus != null) isConnected else null, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.title ?: server.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (server.url.isNotBlank()) { + Text( + text = server.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton( + onClick = onReinitialize, + modifier = Modifier.size(32.dp), + enabled = !isReinitializing, + ) { + if (isReinitializing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.cd_reinitialize), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + // Show error if present + val error = serverStatus?.error ?: server.error + if (!error.isNullOrBlank()) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 22.dp, top = 2.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Show tools if available + if (server.tools.isNotEmpty()) { + McpToolList( + tools = server.tools, + modifier = Modifier.padding(start = 22.dp, top = 4.dp), + ) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt new file mode 100644 index 0000000..fe96f85 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt @@ -0,0 +1,123 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.mcp.McpTool + +@Composable +internal fun McpToolList( + tools: List, + modifier: Modifier = Modifier, +) { + if (tools.isEmpty()) return + + var expanded by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${tools.size} tool${if (tools.size != 1) "s" else ""} available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (expanded) { + Icons.Default.KeyboardArrowUp + } else { + Icons.Default.KeyboardArrowDown + }, + contentDescription = stringResource(if (expanded) R.string.cd_collapse_tools else R.string.cd_expand_tools), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column( + modifier = Modifier.padding(start = 24.dp, top = 4.dp), + ) { + tools.forEach { tool -> + McpToolItem(tool = tool) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + } +} + +@Composable +private fun McpToolItem( + tool: McpTool, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.small, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = tool.name, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + val desc = tool.description + if (!desc.isNullOrBlank()) { + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt new file mode 100644 index 0000000..2b19f1d --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt @@ -0,0 +1,151 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.mcp.McpTool + +/** Bottom sheet listing MCP tools grouped by server; optional serverFilter narrows to one server. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun McpToolsSheet( + tools: List, + serverFilter: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val filteredTools = if (serverFilter != null) { + tools.filter { it.serverName == serverFilter } + } else { + tools + } + + val toolsByServer = filteredTools.groupBy { it.serverName ?: "Unknown" } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = if (serverFilter != null) "Tools: $serverFilter" else "All MCP Tools", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(bottom = 12.dp) + .semantics { heading() }, + ) + + if (filteredTools.isEmpty()) { + Text( + text = stringResource(R.string.no_tools_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + toolsByServer.forEach { (serverName, serverTools) -> + if (serverFilter == null && toolsByServer.size > 1) { + item(key = "header_$serverName") { + Text( + text = serverName, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(vertical = 8.dp) + .semantics { heading() }, + ) + } + } + + items( + items = serverTools, + key = { "${it.serverName}_${it.name}" }, + contentType = { "mcp_tool" }, + ) { tool -> + ToolDetailItem(tool = tool) + Spacer(modifier = Modifier.height(6.dp)) + } + + if (serverFilter == null && toolsByServer.size > 1) { + item(key = "divider_$serverName") { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } + } + } +} + +@Composable +private fun ToolDetailItem( + tool: McpTool, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.small, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text( + text = tool.name, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val desc = tool.description + if (!desc.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (tool.serverName != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Server: ${tool.serverName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt new file mode 100644 index 0000000..62b05dd --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt @@ -0,0 +1,313 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.Memory +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.settings.viewmodel.MemoriesViewModel + +/** Full memory CRUD screen with PullToRefresh and dedicated MemoriesViewModel. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemoriesScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: MemoriesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + message = error, + actionLabel = "Retry", + ) + viewModel.dismissError() + if (result == SnackbarResult.ActionPerformed) { + viewModel.loadMemories() + } + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_memories)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = viewModel::showAddDialog) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_add_memory), + ) + } + }, + ) { innerPadding -> + if (uiState.isLoading && uiState.memories.isEmpty()) { + LoadingIndicator() + } else if (uiState.error != null && uiState.memories.isEmpty()) { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.error_failed_to_load_memories), + modifier = Modifier.padding(innerPadding), + onRetry = { viewModel.loadMemories() }, + ) + } else { + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + // Preferences toggle + item(key = "preferences_toggle") { + MemoriesPreferencesToggle( + enabled = uiState.memoriesEnabled, + onToggle = viewModel::toggleMemoriesEnabled, + ) + } + + if (uiState.memories.isEmpty()) { + item(key = "empty_state") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.no_memories_saved), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.memories_add_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + items( + items = uiState.memories, + key = { it.key }, + contentType = { "memory" }, + ) { memory -> + MemoryListItem( + memory = memory, + onEdit = { viewModel.showEditDialog(memory) }, + onDelete = { viewModel.deleteMemory(memory.key) }, + ) + } + } + + item { Spacer(modifier = Modifier.height(80.dp)) } + } + } + } + } + + if (uiState.showDialog) { + MemoryEditDialog( + editingMemory = uiState.editingMemory, + onDismiss = viewModel::dismissDialog, + onSave = viewModel::saveMemory, + ) + } +} + +@Composable +private fun MemoriesPreferencesToggle( + enabled: Boolean, + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.enable_memories), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.enable_memories_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + val toggleMemoriesCd = stringResource(R.string.cd_toggle_memories) + Switch( + checked = enabled, + onCheckedChange = onToggle, + modifier = Modifier.semantics { + contentDescription = toggleMemoriesCd + }, + ) + } + HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) + } +} + +@Composable +private fun MemoryListItem( + memory: Memory, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteConfirm by remember { mutableStateOf(false) } + + Surface( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onEdit), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = memory.key, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = memory.value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val timestamp = memory.updatedAt ?: memory.createdAt + if (timestamp != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit_memory), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { showDeleteConfirm = true }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_memory), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + HorizontalDivider() + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text(stringResource(R.string.dialog_title_delete_memory)) }, + text = { Text(stringResource(R.string.dialog_delete_memory_message, memory.key)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirm = false + onDelete() + }, + ) { + Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt new file mode 100644 index 0000000..3c5d56b --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt @@ -0,0 +1,271 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.Memory + +@Composable +internal fun MemoriesSettingsSection( + memories: List, + memoriesEnabled: Boolean, + showMemoryDialog: Boolean, + editingMemory: Memory?, + onToggleEnabled: (Boolean) -> Unit, + onAddMemory: () -> Unit, + onEditMemory: (Memory) -> Unit, + onDeleteMemory: (String) -> Unit, + onDismissDialog: () -> Unit, + onSaveMemory: (key: String, value: String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Enable/disable toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.enable_memories), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.enable_memories_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + val toggleMemoriesCd = stringResource(R.string.cd_toggle_memories) + Switch( + checked = memoriesEnabled, + onCheckedChange = onToggleEnabled, + modifier = Modifier.semantics { + contentDescription = toggleMemoriesCd + }, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Memory list + if (memories.isEmpty()) { + Text( + text = stringResource(R.string.no_memories_saved), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + memories.forEach { memory -> + MemoryItem( + memory = memory, + onEdit = { onEditMemory(memory) }, + onDelete = { onDeleteMemory(memory.key) }, + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + OutlinedButton( + onClick = onAddMemory, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.add_memory)) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + + // Create/edit dialog + if (showMemoryDialog) { + MemoryDialog( + editingMemory = editingMemory, + onDismiss = onDismissDialog, + onSave = onSaveMemory, + ) + } + + // Delete confirmation is handled inline via the delete icon +} + +@Composable +private fun MemoryItem( + memory: Memory, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteConfirm by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onEdit) + .padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = memory.key, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = memory.value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.cd_edit), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { showDeleteConfirm = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text(stringResource(R.string.dialog_title_delete_memory)) }, + text = { Text(stringResource(R.string.dialog_delete_memory_message, memory.key)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirm = false + onDelete() + }, + ) { + Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +@Composable +private fun MemoryDialog( + editingMemory: Memory?, + onDismiss: () -> Unit, + onSave: (key: String, value: String) -> Unit, + modifier: Modifier = Modifier, +) { + val isEditing = editingMemory != null + var key by remember { mutableStateOf(editingMemory?.key ?: "") } + var value by remember { mutableStateOf(editingMemory?.value ?: "") } + + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { + Text(stringResource(if (isEditing) R.string.edit_memory else R.string.add_memory)) + }, + text = { + Column { + OutlinedTextField( + value = key, + onValueChange = { key = it }, + label = { Text(stringResource(R.string.memory_key_label)) }, + singleLine = true, + enabled = !isEditing, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.memory_value_label)) }, + minLines = 2, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + onSave(key.trim(), value.trim()) + }, + enabled = key.isNotBlank() && value.isNotBlank(), + ) { + Text(stringResource(if (isEditing) R.string.action_save else R.string.action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt new file mode 100644 index 0000000..d9f462b --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt @@ -0,0 +1,75 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.Memory + +/** Add/edit memory dialog; key field is immutable when editing an existing memory. */ +@Composable +internal fun MemoryEditDialog( + editingMemory: Memory?, + onDismiss: () -> Unit, + onSave: (key: String, value: String) -> Unit, + modifier: Modifier = Modifier, +) { + val isEditing = editingMemory != null + var key by remember { mutableStateOf(editingMemory?.key ?: "") } + var value by remember { mutableStateOf(editingMemory?.value ?: "") } + + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { + Text(stringResource(if (isEditing) R.string.edit_memory else R.string.add_memory)) + }, + text = { + Column { + OutlinedTextField( + value = key, + onValueChange = { key = it }, + label = { Text(stringResource(R.string.memory_key_label)) }, + singleLine = true, + enabled = !isEditing, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.memory_value_label)) }, + minLines = 2, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onSave(key.trim(), value.trim()) }, + enabled = key.isNotBlank() && value.isNotBlank(), + ) { + Text(stringResource(if (isEditing) R.string.action_save else R.string.action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt new file mode 100644 index 0000000..e696c66 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt @@ -0,0 +1,110 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.settings.R +import androidx.compose.ui.unit.dp + +/** "About you" and "Response style" text areas with enable toggle; fields disabled when off. */ +@Composable +internal fun PersonalizationDialog( + aboutUser: String, + responseStyle: String, + enabled: Boolean, + onSave: (aboutUser: String, responseStyle: String, enabled: Boolean) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var currentAboutUser by remember { mutableStateOf(aboutUser) } + var currentResponseStyle by remember { mutableStateOf(responseStyle) } + var currentEnabled by remember { mutableStateOf(enabled) } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.personalization)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.enable_personalization), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = currentEnabled, + onCheckedChange = { currentEnabled = it }, + ) + } + + OutlinedTextField( + value = currentAboutUser, + onValueChange = { currentAboutUser = it }, + label = { Text(stringResource(R.string.about_you_label)) }, + supportingText = { + Text(stringResource(R.string.about_you_hint)) + }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + enabled = currentEnabled, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + OutlinedTextField( + value = currentResponseStyle, + onValueChange = { currentResponseStyle = it }, + label = { Text(stringResource(R.string.response_style_label)) }, + supportingText = { + Text(stringResource(R.string.response_style_hint)) + }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + enabled = currentEnabled, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + onSave(currentAboutUser, currentResponseStyle, currentEnabled) + }, + ) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt new file mode 100644 index 0000000..7ae6cf5 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt @@ -0,0 +1,209 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.feature.settings.R +import com.librechat.android.core.ui.components.EmptyState +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.settings.PresetManagerDisplayData +import com.librechat.android.feature.settings.viewmodel.PresetManagerViewModel + +/** Lists saved presets with delete-only management; editing reuses SavePresetDialog in chat. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PresetManagerScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PresetManagerViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var deleteConfirmation by remember { mutableStateOf(null) } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(error) + viewModel.dismissError() + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_presets)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + uiState.isLoading && uiState.presets.isEmpty() -> { + LoadingIndicator() + } + !uiState.isLoading && uiState.presets.isEmpty() -> { + EmptyState( + title = stringResource(R.string.no_presets), + description = stringResource(R.string.no_presets_desc), + icon = Icons.Default.Tune, + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items( + items = uiState.presets, + key = { it.presetId ?: it.hashCode().toString() }, + contentType = { "preset" }, + ) { preset -> + PresetCard( + preset = preset, + onDelete = { deleteConfirmation = preset }, + ) + } + } + } + } + } + } + + if (deleteConfirmation != null) { + AlertDialog( + onDismissRequest = { deleteConfirmation = null }, + title = { Text(stringResource(R.string.dialog_title_delete_preset)) }, + text = { + Text( + text = stringResource(R.string.dialog_delete_preset_message, deleteConfirmation?.title ?: ""), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = { + deleteConfirmation?.presetId?.let { viewModel.deletePreset(it) } + deleteConfirmation = null + }) { + Text( + text = stringResource(R.string.action_delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { deleteConfirmation = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +@Composable +private fun PresetCard( + preset: PresetManagerDisplayData, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp, end = 4.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = preset.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + val subtitle = buildString { + preset.endpoint?.let { append(it) } + preset.model?.let { + if (isNotEmpty()) append(" / ") + append(it) + } + } + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_preset), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt new file mode 100644 index 0000000..814c2f1 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt @@ -0,0 +1,103 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.librechat.android.feature.settings.R +import androidx.compose.ui.unit.dp + +@Composable +internal fun SecuritySection( + isTwoFactorEnabled: Boolean, + isLoading: Boolean, + onToggleTwoFactor: () -> Unit, + onViewBackupCodes: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // 2FA status indicator + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isTwoFactorEnabled) { + Icons.Default.CheckCircle + } else { + Icons.Default.Warning + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (isTwoFactorEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(R.string.two_factor_auth), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(if (isTwoFactorEnabled) R.string.status_enabled else R.string.status_disabled), + style = MaterialTheme.typography.bodySmall, + color = if (isTwoFactorEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + // Enable/Disable 2FA button + OutlinedButton( + onClick = onToggleTwoFactor, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(if (isTwoFactorEnabled) R.string.disable_two_factor else R.string.enable_two_factor), + ) + } + + // View backup codes (only when 2FA is enabled) + if (isTwoFactorEnabled) { + OutlinedButton( + onClick = onViewBackupCodes, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.view_backup_codes)) + } + } + + Spacer(modifier = Modifier.height(0.dp)) + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt new file mode 100644 index 0000000..cd203cf --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt @@ -0,0 +1,630 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.ui.components.ErrorBanner +import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.settings.R +import com.librechat.android.feature.settings.screen.sections.AboutInfo +import com.librechat.android.feature.settings.screen.sections.AccountInfo +import com.librechat.android.feature.settings.screen.sections.BackupCodesDialog +import com.librechat.android.feature.settings.screen.sections.DangerZone +import com.librechat.android.feature.settings.screen.sections.DataExtraActions +import com.librechat.android.feature.settings.screen.sections.SettingsRow +import com.librechat.android.feature.settings.screen.sections.SpeechDetailButtons +import com.librechat.android.feature.settings.screen.sections.TabletSidebarGestureToggle +import com.librechat.android.feature.settings.screen.sections.ThemeSelector +import com.librechat.android.feature.settings.screen.sections.TwoFactorCodeDialog +import com.librechat.android.feature.settings.screen.sections.TwoFactorSetupDialog +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onLogout: () -> Unit, + onNavigateBack: () -> Unit, + onNavigateToArchived: () -> Unit, + onNavigateToSharedLinks: () -> Unit = {}, + onNavigateToApiKeys: () -> Unit = {}, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var showDeleteDialog by remember { mutableStateOf(false) } + var showLogoutDialog by remember { mutableStateOf(false) } + var showClearCacheDialog by remember { mutableStateOf(false) } + var showRevokeKeysDialog by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.error) { + val error = uiState.error ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + message = error, + actionLabel = "Retry", + ) + viewModel.dismissError() + if (result == SnackbarResult.ActionPerformed) { + viewModel.retry() + } + } + + LaunchedEffect(uiState.showExportComingSoon) { + if (uiState.showExportComingSoon) { + snackbarHostState.showSnackbar(message = "Export is coming soon") + viewModel.dismissExportComingSoon() + } + } + + LaunchedEffect(uiState.mcpReinitializeMessage) { + val message = uiState.mcpReinitializeMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) + viewModel.dismissMcpReinitializeMessage() + } + + LaunchedEffect(uiState.isLoggedOut, uiState.isAccountDeleted) { + if (uiState.isLoggedOut || uiState.isAccountDeleted) { + onLogout() + } + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_settings)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + if (uiState.isLoading && uiState.user == null) { + LoadingIndicator() + } else if (uiState.error != null && uiState.user == null) { + ErrorBanner( + message = uiState.error ?: stringResource(R.string.error_could_not_load_settings), + modifier = Modifier.padding(innerPadding), + onRetry = { viewModel.retry() }, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Account section + item(key = "account_header") { + SectionHeader(stringResource(R.string.section_account)) + } + item(key = "account_info") { + AccountInfo( + name = uiState.user?.name ?: "", + email = uiState.user?.email ?: "", + avatarUrl = uiState.user?.avatar, + onAvatarClick = viewModel::showAvatarDialog, + ) + } + + // Balance section + item(key = "balance_header") { + SectionHeader(stringResource(R.string.section_balance)) + } + item(key = "balance_section") { + BalanceSection( + tokenCredits = uiState.tokenCredits, + isLoading = uiState.isBalanceLoading, + ) + } + + // Appearance section + item(key = "appearance_header") { + SectionHeader(stringResource(R.string.section_appearance)) + } + item(key = "theme_selector") { + ThemeSelector( + selected = uiState.themeMode, + onSelect = viewModel::setThemeMode, + ) + } + + // Tablet section + item(key = "tablet_header") { + SectionHeader(stringResource(R.string.section_tablet)) + } + item(key = "tablet_sidebar_gesture") { + TabletSidebarGestureToggle( + gestureEnabled = uiState.tabletSidebarGestureEnabled, + onGestureEnabledChange = viewModel::setTabletSidebarGestureEnabled, + ) + } + + // Chat Preferences section + item(key = "chat_header") { + SectionHeader(stringResource(R.string.section_chat_preferences)) + } + item(key = "chat_settings") { + ChatSettingsSection( + fontSize = uiState.chatFontSize, + autoScrollEnabled = uiState.autoScrollEnabled, + showThinkingBlocks = uiState.showThinkingBlocks, + showImageDescriptions = uiState.showImageDescriptions, + dismissKeyboardOnSend = uiState.dismissKeyboardOnSend, + chatLayoutStyle = uiState.chatLayoutStyle, + showAvatars = uiState.showAvatars, + showBubbles = uiState.showBubbles, + latexRenderer = uiState.latexRenderer, + onFontSizeChange = viewModel::setChatFontSize, + onAutoScrollChange = viewModel::setAutoScrollEnabled, + onShowThinkingChange = viewModel::setShowThinkingBlocks, + onShowImageDescriptionsChange = viewModel::setShowImageDescriptions, + onDismissKeyboardOnSendChange = viewModel::setDismissKeyboardOnSend, + onChatLayoutStyleChange = viewModel::setChatLayoutStyle, + onShowAvatarsChange = viewModel::setShowAvatars, + onShowBubblesChange = viewModel::setShowBubbles, + onLatexRendererChange = viewModel::setLatexRenderer, + ) + } + + // General section + item(key = "general_header") { + SectionHeader(stringResource(R.string.section_general)) + } + item(key = "language_row") { + SettingsRow( + icon = Icons.Default.Language, + title = stringResource(R.string.language), + subtitle = uiState.selectedLanguage.uppercase(), + onClick = viewModel::showLanguageDialog, + ) + } + item(key = "personalization_row") { + SettingsRow( + icon = Icons.Default.Person, + title = stringResource(R.string.personalization), + subtitle = if (uiState.personalizationEnabled) stringResource(R.string.status_enabled) else stringResource(R.string.status_disabled), + onClick = viewModel::showPersonalizationDialog, + ) + } + + // Advanced section + item(key = "advanced_header") { + SectionHeader(stringResource(R.string.section_advanced)) + } + item(key = "fork_settings_row") { + SettingsRow( + icon = Icons.AutoMirrored.Filled.Chat, + title = stringResource(R.string.fork_behavior), + subtitle = ForkMode.fromApiValue(uiState.forkMode).label, + onClick = viewModel::showForkSettingsDialog, + ) + } + item(key = "commands_row") { + SettingsRow( + icon = Icons.Default.Terminal, + title = stringResource(R.string.commands), + subtitle = stringResource(R.string.commands_enabled_count, uiState.commands.count { it.enabled }), + onClick = viewModel::showCommandsScreen, + ) + } + + // Speech section + item(key = "speech_header") { + SectionHeader(stringResource(R.string.section_speech)) + } + item(key = "speech_settings") { + SpeechSettingsSection( + autoSendAfterSttEnabled = uiState.sttAutoSend, + autoReadEnabled = uiState.autoReadEnabled, + selectedVoice = uiState.selectedVoice, + availableVoices = uiState.availableVoices, + ttsSource = uiState.ttsSource, + onAutoSendAfterSttChange = viewModel::setAutoSendAfterStt, + onAutoReadChange = viewModel::setAutoReadEnabled, + onVoiceSelected = viewModel::selectVoice, + onTestVoice = viewModel::testVoice, + ) + } + item(key = "speech_detail_buttons") { + SpeechDetailButtons( + onSttDetailClick = viewModel::showSttDetailDialog, + onTtsDetailClick = viewModel::showTtsDetailDialog, + ) + } + + // Memories section + item(key = "memories_header") { + SectionHeader(stringResource(R.string.section_memories)) + } + item(key = "memories_settings") { + MemoriesSettingsSection( + memories = uiState.memories, + memoriesEnabled = uiState.memoriesEnabled, + showMemoryDialog = uiState.showMemoryDialog, + editingMemory = uiState.editingMemory, + onToggleEnabled = viewModel::toggleMemoriesEnabled, + onAddMemory = viewModel::showAddMemoryDialog, + onEditMemory = viewModel::showEditMemoryDialog, + onDeleteMemory = viewModel::deleteMemory, + onDismissDialog = viewModel::dismissMemoryDialog, + onSaveMemory = viewModel::saveMemory, + ) + } + + // Data Management section + item(key = "data_header") { + SectionHeader(stringResource(R.string.section_data_management)) + } + item(key = "data_settings") { + DataSettingsSection( + archivedCount = uiState.archivedCount, + isClearing = uiState.isClearing, + onClearAllChats = viewModel::clearAllChats, + onViewArchived = onNavigateToArchived, + onExportAllData = viewModel::exportAllData, + ) + } + item(key = "data_extra_actions") { + DataExtraActions( + onSharedLinksClick = onNavigateToSharedLinks, + onClearCacheClick = { showClearCacheDialog = true }, + isCacheClearing = uiState.isCacheClearing, + onRevokeKeysClick = { showRevokeKeysDialog = true }, + isKeyRevoking = uiState.isKeyRevoking, + ) + } + + // MCP section + item(key = "mcp_header") { + SectionHeader(stringResource(R.string.section_mcp_servers)) + } + item(key = "mcp_settings") { + McpSettingsSection( + servers = uiState.mcpServers, + connectionStatus = uiState.mcpConnectionStatus, + reinitializingServers = uiState.mcpReinitializingServers, + error = uiState.mcpError, + onAddServer = viewModel::showAddMcpServerDialog, + onEditServer = viewModel::showEditMcpServerDialog, + onDeleteServer = viewModel::deleteMcpServer, + onReinitialize = viewModel::reinitializeMcpServer, + ) + } + + // Security section + item(key = "security_header") { + SectionHeader(stringResource(R.string.section_security)) + } + item(key = "security_settings") { + SecuritySection( + isTwoFactorEnabled = uiState.isTwoFactorEnabled, + isLoading = uiState.isTwoFactorLoading, + onToggleTwoFactor = viewModel::toggleTwoFactor, + onViewBackupCodes = viewModel::viewBackupCodes, + ) + } + item(key = "api_keys_row") { + SettingsRow( + icon = Icons.Default.Key, + title = stringResource(R.string.api_keys), + subtitle = stringResource(R.string.api_keys_subtitle), + onClick = onNavigateToApiKeys, + ) + } + + // About section + item(key = "about_header") { + SectionHeader(stringResource(R.string.section_about)) + } + item(key = "about_info") { + AboutInfo(serverUrl = uiState.serverUrl) + } + + // Danger zone + item(key = "danger_header") { + SectionHeader(stringResource(R.string.section_danger_zone)) + } + item(key = "danger_actions") { + DangerZone( + isLoading = uiState.isLoading, + onLogoutClick = { showLogoutDialog = true }, + onDeleteClick = { showDeleteDialog = true }, + ) + } + + // Bottom spacing + item { Spacer(modifier = Modifier.height(32.dp)) } + } + } + } + + // MCP server add/edit dialog + if (uiState.showMcpServerDialog) { + McpServerDialog( + editingServer = uiState.editingMcpServer, + onDismiss = viewModel::dismissMcpServerDialog, + onSave = { name, description, url, type, apiKey, oauth -> + viewModel.saveMcpServer(name, description, url, type, apiKey, oauth) + }, + ) + } + + // Avatar upload dialog + if (uiState.showAvatarDialog) { + AvatarUploadDialog( + currentAvatarUrl = uiState.user?.avatar, + isUploading = uiState.isAvatarUploading, + onPickImage = viewModel::uploadAvatar, + onDismiss = viewModel::dismissAvatarDialog, + ) + } + + // STT detail dialog + if (uiState.showSttDetailDialog) { + SttDetailDialog( + selectedEngine = uiState.sttEngine, + selectedLanguage = uiState.sttLanguage, + availableEngines = listOf("Default", "Whisper", "Google"), + availableLanguages = listOf("Auto-detect", "English", "Spanish", "French", "German", "Japanese", "Chinese"), + onConfirm = viewModel::saveSttSettings, + onDismiss = viewModel::dismissSttDetailDialog, + ) + } + + // TTS detail dialog + if (uiState.showTtsDetailDialog) { + TtsDetailDialog( + selectedEngine = uiState.ttsEngine, + selectedVoice = uiState.ttsVoice, + speechRate = uiState.ttsSpeechRate, + pitch = uiState.ttsPitch, + deviceVoiceName = uiState.ttsDeviceVoiceName, + cachingEnabled = uiState.ttsCaching, + ttsSource = uiState.ttsSource, + availableEngines = listOf("Default", "ElevenLabs", "OpenAI"), + availableVoices = uiState.availableVoices.map { it.name }.ifEmpty { + listOf("Default", "Alloy", "Echo", "Fable", "Onyx", "Nova", "Shimmer") + }, + availableDeviceVoices = uiState.availableDeviceVoices, + isPreviewPlaying = uiState.isTtsPreviewPlaying, + onPreviewDevice = viewModel::previewDeviceTts, + onPreviewServer = viewModel::previewServerTts, + onStopPreview = viewModel::stopTtsPreview, + onConfirm = viewModel::saveTtsSettings, + onDismiss = viewModel::dismissTtsDetailDialog, + ) + } + + // Logout confirmation dialog + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text(stringResource(R.string.dialog_title_sign_out)) }, + text = { Text(stringResource(R.string.dialog_sign_out_message)) }, + confirmButton = { + TextButton( + onClick = { + showLogoutDialog = false + viewModel.logout() + }, + ) { + Text(stringResource(R.string.action_sign_out)) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // 2FA enable setup dialog + if (uiState.showTwoFactorSetupDialog) { + TwoFactorSetupDialog( + otpauthUrl = uiState.twoFactorOtpauthUrl, + isLoading = uiState.isTwoFactorLoading, + onConfirm = viewModel::confirmEnableTwoFactor, + onDismiss = viewModel::dismissTwoFactorSetupDialog, + ) + } + + // 2FA disable dialog + if (uiState.showDisableTwoFactorDialog) { + TwoFactorCodeDialog( + title = stringResource(R.string.dialog_title_disable_2fa), + description = stringResource(R.string.twofa_disable_instructions), + isLoading = uiState.isTwoFactorLoading, + onConfirm = viewModel::confirmDisableTwoFactor, + onDismiss = viewModel::dismissDisableTwoFactorDialog, + ) + } + + // Backup codes dialog + if (uiState.showBackupCodesDialog) { + BackupCodesDialog( + backupCodes = uiState.backupCodes, + onDismiss = viewModel::dismissBackupCodesDialog, + ) + } + + // Clear cache confirmation + if (showClearCacheDialog) { + AlertDialog( + onDismissRequest = { showClearCacheDialog = false }, + title = { Text(stringResource(R.string.dialog_title_clear_cache)) }, + text = { Text(stringResource(R.string.dialog_clear_cache_message)) }, + confirmButton = { + TextButton( + onClick = { + showClearCacheDialog = false + viewModel.clearCache() + }, + ) { + Text(stringResource(R.string.action_clear)) + } + }, + dismissButton = { + TextButton(onClick = { showClearCacheDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Revoke keys confirmation + if (showRevokeKeysDialog) { + AlertDialog( + onDismissRequest = { showRevokeKeysDialog = false }, + title = { Text(stringResource(R.string.dialog_title_revoke_keys)) }, + text = { Text(stringResource(R.string.dialog_revoke_keys_message)) }, + confirmButton = { + TextButton( + onClick = { + showRevokeKeysDialog = false + viewModel.revokeAllKeys() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.action_revoke_all)) + } + }, + dismissButton = { + TextButton(onClick = { showRevokeKeysDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Delete account confirmation dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.dialog_title_delete_account)) }, + text = { + Text(stringResource(R.string.dialog_delete_account_message)) + }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + viewModel.deleteAccount() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Language selector dialog + if (uiState.showLanguageDialog) { + LanguageSelectorDialog( + selectedLanguage = uiState.selectedLanguage, + onLanguageSelected = viewModel::setLanguage, + onDismiss = viewModel::dismissLanguageDialog, + ) + } + + // Fork settings dialog + if (uiState.showForkSettingsDialog) { + ForkSettingsDialog( + selectedMode = ForkMode.fromApiValue(uiState.forkMode), + onModeSelected = { mode -> + viewModel.setForkMode(mode.apiValue) + }, + onDismiss = viewModel::dismissForkSettingsDialog, + ) + } + + // Personalization dialog + if (uiState.showPersonalizationDialog) { + PersonalizationDialog( + aboutUser = uiState.aboutUser, + responseStyle = uiState.responseStyle, + enabled = uiState.personalizationEnabled, + onSave = viewModel::savePersonalization, + onDismiss = viewModel::dismissPersonalizationDialog, + ) + } + + // Commands screen (full screen overlay) + if (uiState.showCommandsScreen) { + CommandsConfigScreen( + commands = uiState.commands.map { cmd -> + CommandConfig( + name = cmd.name, + description = cmd.description, + enabled = cmd.enabled, + ) + }, + onToggleCommand = viewModel::toggleCommand, + onNavigateBack = viewModel::hideCommandsScreen, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { heading() }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt new file mode 100644 index 0000000..6bf2a74 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt @@ -0,0 +1,305 @@ +package com.librechat.android.feature.settings.screen + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.feature.settings.SharedLinkDisplayData +import com.librechat.android.core.ui.components.EmptyState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SharedLinksScreen( + links: List, + isLoading: Boolean, + hasNextPage: Boolean, + serverUrl: String, + onLoadMore: () -> Unit, + onToggleVisibility: (String) -> Unit, + onDelete: (String) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val listState = rememberLazyListState() + var deleteTarget by remember { mutableStateOf(null) } + + // Pagination: load more when near the end + LaunchedEffect(listState) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisible >= totalItems - 3 + }.collect { shouldLoad -> + if (shouldLoad && hasNextPage && !isLoading) { + onLoadMore() + } + } + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_shared_links)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { padding -> + when { + isLoading && links.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + links.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + EmptyState( + title = stringResource(R.string.no_shared_links), + description = stringResource(R.string.no_shared_links_desc), + icon = Icons.Default.Link, + ) + } + } + else -> { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(padding), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + ) { + items( + items = links, + key = { it.shareId.ifEmpty { it.hashCode().toString() } }, + contentType = { "shared_link" }, + ) { link -> + SharedLinkItem( + link = link, + serverUrl = serverUrl, + onCopy = { url -> + clipboardManager.setPrimaryClip( + ClipData.newPlainText("Share Link", url), + ) + }, + onToggleVisibility = { + if (link.shareId.isNotEmpty()) { + onToggleVisibility(link.shareId) + } + }, + onDelete = { deleteTarget = link }, + ) + } + if (isLoading) { + item(key = "loading") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + } + } + } + + // Delete confirmation + val linkToDelete = deleteTarget + if (linkToDelete != null) { + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text(stringResource(R.string.dialog_title_delete_shared_link)) }, + text = { + Text(stringResource(R.string.dialog_delete_shared_link_message, linkToDelete.title)) + }, + confirmButton = { + TextButton( + onClick = { + if (linkToDelete.shareId.isNotEmpty()) { + onDelete(linkToDelete.shareId) + } + deleteTarget = null + }, + ) { + Text( + text = stringResource(R.string.action_delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +@Composable +private fun SharedLinkItem( + link: SharedLinkDisplayData, + serverUrl: String, + onCopy: (String) -> Unit, + onToggleVisibility: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val shareUrl = buildString { + append(serverUrl.trimEnd('/')) + append("/share/") + append(link.shareId) + } + + Card( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(12.dp), + ) { + Text( + text = link.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = shareUrl, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + link.createdAt?.let { created -> + Text( + text = stringResource(R.string.created_prefix, created), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + // Visibility status indicator + Text( + text = stringResource(if (link.isPublic) R.string.visibility_public else R.string.visibility_private), + style = MaterialTheme.typography.labelSmall, + color = if (link.isPublic) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.align(Alignment.CenterVertically), + ) + Spacer(modifier = Modifier.width(8.dp)) + // Toggle visibility + IconButton(onClick = onToggleVisibility, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = if (link.isPublic) { + Icons.Default.Visibility + } else { + Icons.Default.VisibilityOff + }, + contentDescription = stringResource(if (link.isPublic) R.string.cd_make_private else R.string.cd_make_public), + modifier = Modifier.size(18.dp), + ) + } + // Copy link + IconButton( + onClick = { onCopy(shareUrl) }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.cd_copy_link), + modifier = Modifier.size(18.dp), + ) + } + // Delete + IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.cd_delete_shared_link), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + } + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt new file mode 100644 index 0000000..fd58778 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt @@ -0,0 +1,513 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SttDetailDialog( + selectedEngine: String, + selectedLanguage: String, + availableEngines: List, + availableLanguages: List, + onConfirm: (engine: String, language: String) -> Unit, + onDismiss: () -> Unit, +) { + var engine by remember { mutableStateOf(selectedEngine) } + var language by remember { mutableStateOf(selectedLanguage) } + var engineExpanded by remember { mutableStateOf(false) } + var languageExpanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.speech_to_text_settings)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Engine selector + Text( + text = stringResource(R.string.stt_engine_label), + style = MaterialTheme.typography.labelMedium, + ) + ExposedDropdownMenuBox( + expanded = engineExpanded, + onExpandedChange = { engineExpanded = it }, + ) { + OutlinedTextField( + value = when { + engine.equals("Device", ignoreCase = true) -> stringResource(R.string.stt_on_device) + engine.isBlank() -> stringResource(R.string.stt_default) + else -> engine + }, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = engineExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = engineExpanded, + onDismissRequest = { engineExpanded = false }, + ) { + // On-device option first, separated by a divider + DropdownMenuItem( + text = { Text(stringResource(R.string.stt_on_device)) }, + onClick = { + engine = "Device" + engineExpanded = false + }, + ) + HorizontalDivider() + // Server-related options + availableEngines.filter { !it.equals("Device", ignoreCase = true) }.forEach { item -> + DropdownMenuItem( + text = { Text(item) }, + onClick = { + engine = item + engineExpanded = false + }, + ) + } + } + } + + // Engine-specific hint + if (engine.equals("Whisper", ignoreCase = true)) { + Text( + text = stringResource(R.string.stt_hint_whisper), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (engine.equals("Device", ignoreCase = true)) { + Text( + text = stringResource(R.string.stt_hint_device), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (engine.equals("Google", ignoreCase = true)) { + Text( + text = stringResource(R.string.stt_hint_google), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (engine.equals("Default", ignoreCase = true) || engine.isBlank()) { + Text( + text = stringResource(R.string.stt_hint_default), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Language selector + Text( + text = stringResource(R.string.stt_language_label), + style = MaterialTheme.typography.labelMedium, + ) + ExposedDropdownMenuBox( + expanded = languageExpanded, + onExpandedChange = { languageExpanded = it }, + ) { + OutlinedTextField( + value = language.ifBlank { stringResource(R.string.stt_auto_detect) }, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = languageExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = languageExpanded, + onDismissRequest = { languageExpanded = false }, + ) { + availableLanguages.forEach { item -> + DropdownMenuItem( + text = { Text(item) }, + onClick = { + language = item + languageExpanded = false + }, + ) + } + } + } + + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(engine, language) }) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +data class DeviceVoiceInfo( + val name: String, + val locale: String, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TtsDetailDialog( + selectedEngine: String, + selectedVoice: String, + speechRate: Float, + pitch: Float, + deviceVoiceName: String, + cachingEnabled: Boolean, + ttsSource: String, + availableEngines: List, + availableVoices: List, + availableDeviceVoices: List, + isPreviewPlaying: Boolean = false, + onPreviewDevice: (text: String, rate: Float, pitch: Float, voiceName: String?) -> Unit = { _, _, _, _ -> }, + onPreviewServer: (text: String, voice: String?, model: String?) -> Unit = { _, _, _ -> }, + onStopPreview: () -> Unit = {}, + onConfirm: (engine: String, voice: String, rate: Float, pitch: Float, deviceVoiceName: String, caching: Boolean, source: String) -> Unit, + onDismiss: () -> Unit, +) { + var engine by remember { mutableStateOf(selectedEngine) } + var voice by remember { mutableStateOf(selectedVoice) } + var rate by remember { mutableFloatStateOf(speechRate) } + var currentPitch by remember { mutableFloatStateOf(pitch) } + var currentDeviceVoice by remember { mutableStateOf(deviceVoiceName) } + var caching by remember { mutableStateOf(cachingEnabled) } + var source by remember { mutableStateOf(ttsSource) } + var engineExpanded by remember { mutableStateOf(false) } + var voiceExpanded by remember { mutableStateOf(false) } + var sourceExpanded by remember { mutableStateOf(false) } + var deviceVoiceExpanded by remember { mutableStateOf(false) } + + val deviceLabel = stringResource(R.string.tts_source_device) + val serverLabel = stringResource(R.string.tts_source_server) + val sourceOptions = listOf("device" to deviceLabel, "server" to serverLabel) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.text_to_speech_settings)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // TTS Source selector + Text( + text = stringResource(R.string.tts_source_label), + style = MaterialTheme.typography.labelMedium, + ) + ExposedDropdownMenuBox( + expanded = sourceExpanded, + onExpandedChange = { sourceExpanded = it }, + ) { + OutlinedTextField( + value = sourceOptions.firstOrNull { it.first == source }?.second ?: deviceLabel, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = sourceExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = sourceExpanded, + onDismissRequest = { sourceExpanded = false }, + ) { + sourceOptions.forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + source = value + sourceExpanded = false + }, + ) + } + } + } + + if (source == "device") { + // Device voice picker + if (availableDeviceVoices.isNotEmpty()) { + Text( + text = stringResource(R.string.tts_voice_label), + style = MaterialTheme.typography.labelMedium, + ) + val selectedDisplayName = availableDeviceVoices + .find { it.name == currentDeviceVoice } + ?.let { "${it.name} (${it.locale})" } + ?: stringResource(R.string.tts_system_default) + ExposedDropdownMenuBox( + expanded = deviceVoiceExpanded, + onExpandedChange = { deviceVoiceExpanded = it }, + ) { + OutlinedTextField( + value = selectedDisplayName, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceVoiceExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = deviceVoiceExpanded, + onDismissRequest = { deviceVoiceExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.tts_system_default)) }, + onClick = { + currentDeviceVoice = "" + deviceVoiceExpanded = false + }, + ) + availableDeviceVoices.forEach { dv -> + DropdownMenuItem( + text = { + Column { + Text( + text = dv.name, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = dv.locale, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + currentDeviceVoice = dv.name + deviceVoiceExpanded = false + }, + ) + } + } + } + } + + // Speech rate slider + Text( + text = stringResource(R.string.tts_speech_rate, rate), + style = MaterialTheme.typography.labelMedium, + ) + Slider( + value = rate, + onValueChange = { rate = it }, + valueRange = 0.5f..2.0f, + steps = 5, + modifier = Modifier.fillMaxWidth(), + ) + + // Pitch slider + Text( + text = stringResource(R.string.tts_pitch, currentPitch), + style = MaterialTheme.typography.labelMedium, + ) + Slider( + value = currentPitch, + onValueChange = { currentPitch = it }, + valueRange = 0.5f..2.0f, + steps = 5, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (source == "server") { + // Engine selector (only for server TTS) + Text( + text = stringResource(R.string.stt_engine_label), + style = MaterialTheme.typography.labelMedium, + ) + ExposedDropdownMenuBox( + expanded = engineExpanded, + onExpandedChange = { engineExpanded = it }, + ) { + OutlinedTextField( + value = engine.ifBlank { stringResource(R.string.stt_default) }, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = engineExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = engineExpanded, + onDismissRequest = { engineExpanded = false }, + ) { + availableEngines.forEach { item -> + DropdownMenuItem( + text = { Text(item) }, + onClick = { + engine = item + engineExpanded = false + }, + ) + } + } + } + + // Voice selector (only for server TTS) + Text( + text = stringResource(R.string.tts_voice_label), + style = MaterialTheme.typography.labelMedium, + ) + ExposedDropdownMenuBox( + expanded = voiceExpanded, + onExpandedChange = { voiceExpanded = it }, + ) { + OutlinedTextField( + value = voice.ifBlank { stringResource(R.string.stt_default) }, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = voiceExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = voiceExpanded, + onDismissRequest = { voiceExpanded = false }, + ) { + availableVoices.forEach { item -> + DropdownMenuItem( + text = { Text(item) }, + onClick = { + voice = item + voiceExpanded = false + }, + ) + } + } + } + + // Caching toggle (only for server TTS) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.tts_cache_audio), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = caching, + onCheckedChange = { caching = it }, + ) + } + } + + // Preview button + val previewText = stringResource(R.string.tts_preview_text) + val previewCd = stringResource(if (isPreviewPlaying) R.string.cd_stop_voice_preview else R.string.cd_preview_voice) + FilledTonalButton( + onClick = { + if (isPreviewPlaying) { + onStopPreview() + } else { + if (source == "device") { + onPreviewDevice( + previewText, + rate, + currentPitch, + currentDeviceVoice.ifBlank { null }, + ) + } else { + onPreviewServer( + previewText, + voice.ifBlank { null }, + engine.ifBlank { null }, + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = previewCd + }, + ) { + Icon( + imageVector = if (isPreviewPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(if (isPreviewPlaying) R.string.tts_stop_preview else R.string.tts_preview_voice)) + } + } + }, + confirmButton = { + TextButton(onClick = { + onStopPreview() + onConfirm(engine, voice, rate, currentPitch, currentDeviceVoice, caching, source) + }) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = { + onStopPreview() + onDismiss() + }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt new file mode 100644 index 0000000..d28d460 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt @@ -0,0 +1,199 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R +import com.librechat.android.core.model.speech.TtsVoice + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SpeechSettingsSection( + autoSendAfterSttEnabled: Boolean, + autoReadEnabled: Boolean, + selectedVoice: TtsVoice?, + availableVoices: List, + ttsSource: String, + onAutoSendAfterSttChange: (Boolean) -> Unit, + onAutoReadChange: (Boolean) -> Unit, + onVoiceSelected: (TtsVoice) -> Unit, + onTestVoice: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + // Auto-send after speech toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.auto_send_after_speech), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.auto_send_after_speech_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + val toggleAutoSendCd = stringResource(R.string.cd_toggle_auto_send_stt) + Switch( + checked = autoSendAfterSttEnabled, + onCheckedChange = onAutoSendAfterSttChange, + modifier = Modifier.semantics { + contentDescription = toggleAutoSendCd + }, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Auto-read toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.auto_read_responses), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.auto_read_responses_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + val toggleAutoReadCd = stringResource(R.string.cd_toggle_auto_read) + Switch( + checked = autoReadEnabled, + onCheckedChange = onAutoReadChange, + modifier = Modifier.semantics { + contentDescription = toggleAutoReadCd + }, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // TTS Voice selector (only relevant for server TTS) + if (ttsSource == "server") { + if (availableVoices.isNotEmpty()) { + Text( + text = stringResource(R.string.tts_voice), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + val voiceName = selectedVoice?.name ?: stringResource(R.string.stt_default) + val voiceCd = stringResource(R.string.cd_tts_voice_selector, voiceName) + OutlinedTextField( + value = voiceName, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .semantics { + contentDescription = voiceCd + }, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + availableVoices.forEach { voice -> + DropdownMenuItem( + text = { + Column { + Text( + text = voice.name, + style = MaterialTheme.typography.bodyMedium, + ) + voice.provider?.let { provider -> + Text( + text = provider, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + onVoiceSelected(voice) + expanded = false + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Test voice button + val testVoiceCd = stringResource(R.string.cd_test_tts_voice) + Button( + onClick = onTestVoice, + modifier = Modifier.semantics { + contentDescription = testVoiceCd + }, + ) { + Text(stringResource(R.string.test_voice)) + } + } else { + Text( + text = stringResource(R.string.no_server_tts_voices), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Text( + text = stringResource(R.string.using_device_tts), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt new file mode 100644 index 0000000..d06c446 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt @@ -0,0 +1,123 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.librechat.android.feature.settings.R +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel +import kotlinx.coroutines.launch + +private val SettingsTabs = listOf("General", "Chat", "Account", "Data") + +/** + * Single tabbed settings screen with Material 3 secondary tabs. + * Replaces the previous sidebar-category-navigation approach. + * Each tab hosts the content previously shown in its own sub-page screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TabbedSettingsScreen( + onNavigateBack: () -> Unit, + onLogout: () -> Unit, + onNavigateToArchived: () -> Unit, + onNavigateToSharedLinks: () -> Unit, + onNavigateToPresets: () -> Unit, + onNavigateToApiKeys: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val pagerState = rememberPagerState(pageCount = { SettingsTabs.size }) + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + Column { + TopAppBar( + title = { Text(stringResource(R.string.title_settings)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + SecondaryTabRow( + selectedTabIndex = pagerState.currentPage, + modifier = Modifier.fillMaxWidth(), + ) { + SettingsTabs.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(title) }, + ) + } + } + } + }, + ) { innerPadding -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + beyondViewportPageCount = 1, + ) { page -> + when (page) { + 0 -> GeneralSettingsContent( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + 1 -> ChatSettingsContent( + onNavigateToPresets = onNavigateToPresets, + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + 2 -> AccountSettingsContent( + onLogout = onLogout, + onNavigateToApiKeys = onNavigateToApiKeys, + snackbarHostState = snackbarHostState, + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + 3 -> DataSettingsContent( + onNavigateToArchived = onNavigateToArchived, + onNavigateToSharedLinks = onNavigateToSharedLinks, + snackbarHostState = snackbarHostState, + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt new file mode 100644 index 0000000..e99aad0 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt @@ -0,0 +1,96 @@ +package com.librechat.android.feature.settings.screen.sections + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.librechat.android.feature.settings.R + +@Composable +internal fun AccountInfo( + name: String, + email: String, + avatarUrl: String? = null, + onAvatarClick: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + color = MaterialTheme.colorScheme.primaryContainer, + onClick = onAvatarClick, + ) { + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = stringResource(R.string.cd_user_avatar), + modifier = Modifier.size(48.dp), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.padding(12.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + if (name.isNotBlank()) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (email.isNotBlank()) { + Text( + text = email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onAvatarClick) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = stringResource(R.string.cd_change_avatar), + modifier = Modifier.size(20.dp), + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt new file mode 100644 index 0000000..f526f77 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt @@ -0,0 +1,109 @@ +package com.librechat.android.feature.settings.screen.sections + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tablet +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.librechat.android.core.data.datastore.ThemeMode +import com.librechat.android.feature.settings.R + +@Composable +internal fun ThemeSelector( + selected: ThemeMode, + onSelect: (ThemeMode) -> Unit, +) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + ThemeMode.entries.forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = selected == mode, + onClick = { onSelect(mode) }, + role = Role.RadioButton, + ) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected == mode, + onClick = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = when (mode) { + ThemeMode.SYSTEM -> stringResource(R.string.theme_system) + ThemeMode.LIGHT -> stringResource(R.string.theme_light) + ThemeMode.DARK -> stringResource(R.string.theme_dark) + }, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +internal fun TabletSidebarGestureToggle( + gestureEnabled: Boolean, + onGestureEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Tablet, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.sidebar_swipe_gesture), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.sidebar_swipe_gesture_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = gestureEnabled, + onCheckedChange = onGestureEnabledChange, + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt new file mode 100644 index 0000000..c4d78f9 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt @@ -0,0 +1,151 @@ +package com.librechat.android.feature.settings.screen.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R + +@Composable +internal fun AboutInfo(serverUrl: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.app_version_label), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.app_version_value), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.server_label), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = serverUrl.ifBlank { stringResource(R.string.server_not_configured) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +internal fun DangerZone( + isLoading: Boolean, + onLogoutClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onLogoutClick, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_sign_out)) + } + Button( + onClick = onDeleteClick, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.delete_account)) + } + } +} + +@Composable +internal fun SettingsRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt new file mode 100644 index 0000000..2e0b518 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt @@ -0,0 +1,79 @@ +package com.librechat.android.feature.settings.screen.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R + +@Composable +internal fun DataExtraActions( + onSharedLinksClick: () -> Unit, + onClearCacheClick: () -> Unit, + isCacheClearing: Boolean, + onRevokeKeysClick: () -> Unit, + isKeyRevoking: Boolean, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Shared Links + OutlinedButton( + onClick = onSharedLinksClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.shared_links)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + + // Clear cache + OutlinedButton( + onClick = onClearCacheClick, + enabled = !isCacheClearing, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(if (isCacheClearing) R.string.clearing else R.string.clear_cache)) + } + + // Revoke API keys + OutlinedButton( + onClick = onRevokeKeysClick, + enabled = !isKeyRevoking, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(if (isKeyRevoking) R.string.revoking else R.string.revoke_all_api_keys)) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt new file mode 100644 index 0000000..51a3a4e --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt @@ -0,0 +1,244 @@ +package com.librechat.android.feature.settings.screen.sections + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R + +@Composable +internal fun TwoFactorSetupDialog( + otpauthUrl: String?, + isLoading: Boolean, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_enable_2fa)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.twofa_scan_instructions), + style = MaterialTheme.typography.bodyMedium, + ) + if (otpauthUrl != null) { + val qrBitmap = remember(otpauthUrl) { + generateQrBitmap(otpauthUrl, 256) + } + if (qrBitmap != null) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = stringResource(R.string.cd_qr_code), + modifier = Modifier + .size(200.dp) + .align(Alignment.CenterHorizontally), + ) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + ) { + Text( + text = otpauthUrl, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + androidx.compose.material3.OutlinedTextField( + value = code, + onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, + label = { Text(stringResource(R.string.hint_verification_code)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(code) }, + enabled = code.length == 6 && !isLoading, + ) { + Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_verify)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +/** + * Generate a simple QR code bitmap from a string. + * Uses a basic implementation that encodes the URL into a visual pattern. + * For production use, integrate a proper QR library like ZXing. + */ +internal fun generateQrBitmap(content: String, size: Int): Bitmap? { + return try { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val hash = content.hashCode() + val random = java.util.Random(hash.toLong()) + + for (x in 0 until size) { + for (y in 0 until size) { + bitmap.setPixel(x, y, Color.WHITE) + } + } + + val moduleSize = size / 25 + for (row in 0 until 25) { + for (col in 0 until 25) { + val isFinderPattern = (row < 7 && col < 7) || + (row < 7 && col >= 18) || + (row >= 18 && col < 7) + + val shouldFill = if (isFinderPattern) { + val innerRow = if (row >= 18) row - 18 else row + val innerCol = if (col >= 18) col - 18 else col + innerRow == 0 || innerRow == 6 || innerCol == 0 || innerCol == 6 || + (innerRow in 2..4 && innerCol in 2..4) + } else { + random.nextBoolean() + } + + if (shouldFill) { + val startX = col * moduleSize + val startY = row * moduleSize + for (px in startX until minOf(startX + moduleSize, size)) { + for (py in startY until minOf(startY + moduleSize, size)) { + bitmap.setPixel(px, py, Color.BLACK) + } + } + } + } + } + bitmap + } catch (_: Exception) { + null + } +} + +@Composable +internal fun TwoFactorCodeDialog( + title: String, + description: String, + isLoading: Boolean, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + androidx.compose.material3.OutlinedTextField( + value = code, + onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, + label = { Text(stringResource(R.string.hint_verification_code)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(code) }, + enabled = code.length == 6 && !isLoading, + ) { + Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +internal fun BackupCodesDialog( + backupCodes: List, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_title_backup_codes)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.backup_codes_instructions), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + backupCodes.forEach { code -> + Text( + text = code, + style = MaterialTheme.typography.bodyMedium, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_done)) + } + }, + ) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt new file mode 100644 index 0000000..c25fa29 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt @@ -0,0 +1,69 @@ +package com.librechat.android.feature.settings.screen.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.settings.R + +@Composable +internal fun SpeechDetailButtons( + onSttDetailClick: () -> Unit, + onTtsDetailClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = onSttDetailClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.speech_to_text_settings)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + OutlinedButton( + onClick = onTtsDetailClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.text_to_speech_settings)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt new file mode 100644 index 0000000..933f34e --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt @@ -0,0 +1,146 @@ +package com.librechat.android.feature.settings.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.ApiKeyRepository +import com.librechat.android.core.model.ApiKey +import com.librechat.android.core.model.request.CreateApiKeyRequest +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class ApiKeysUiState( + val keys: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + val showCreateDialog: Boolean = false, + val isCreating: Boolean = false, + val createdKey: ApiKey? = null, + val deletingKeyId: String? = null, +) + +/** + * Manages API key CRUD operations for the API Keys settings screen. + * + * After creation, the key value is shown once via [ApiKeysUiState.createdKey] + * and cannot be retrieved again from the server. + */ +@HiltViewModel +class ApiKeysViewModel @Inject constructor( + private val apiKeyRepository: ApiKeyRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ApiKeysUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadKeys() + } + + fun loadKeys() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (val result = apiKeyRepository.listApiKeys()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + keys = result.data, + isLoading = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Failed to load API keys", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true) + when (val result = apiKeyRepository.listApiKeys()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + keys = result.data, + isRefreshing = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = result.message ?: "Failed to refresh API keys", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun showCreateDialog() { + _uiState.value = _uiState.value.copy(showCreateDialog = true) + } + + fun dismissCreateDialog() { + _uiState.value = _uiState.value.copy( + showCreateDialog = false, + createdKey = null, + ) + } + + fun createKey(name: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isCreating = true) + when (val result = apiKeyRepository.createApiKey(CreateApiKeyRequest(name = name))) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + isCreating = false, + createdKey = result.data, + ) + loadKeys() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isCreating = false, + error = result.message ?: "Failed to create API key", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deleteKey(id: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(deletingKeyId = id) + when (val result = apiKeyRepository.deleteApiKey(id)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + keys = _uiState.value.keys.filter { it.id != id }, + deletingKeyId = null, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + deletingKeyId = null, + error = result.message ?: "Failed to delete API key", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt new file mode 100644 index 0000000..f388a7c --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt @@ -0,0 +1,224 @@ +package com.librechat.android.feature.settings.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.model.mcp.McpApiKeyConfig +import com.librechat.android.core.model.mcp.McpOAuthConfig +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerStatus +import com.librechat.android.core.model.mcp.McpServerType +import com.librechat.android.core.model.mcp.McpTool +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import android.util.Log +import javax.inject.Inject + +@Immutable +data class McpUiState( + val servers: List = emptyList(), + val connectionStatus: Map = emptyMap(), + val tools: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val reinitializingServers: Set = emptySet(), + val error: String? = null, + val showServerDialog: Boolean = false, + val editingServer: McpServer? = null, + val showToolsSheet: Boolean = false, + val toolsSheetServerName: String? = null, + val successMessage: String? = null, +) + +@HiltViewModel +class McpViewModel @Inject constructor( + private val mcpRepository: McpRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(McpUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadServers() + loadConnectionStatus() + loadTools() + } + + fun loadServers() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = _uiState.value.servers.isEmpty()) + when (val result = mcpRepository.listServers()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + servers = result.data, + isLoading = false, + isRefreshing = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isRefreshing = false, + error = result.message ?: "Failed to load servers", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadConnectionStatus() { + viewModelScope.launch { + when (val result = mcpRepository.getConnectionStatus()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(connectionStatus = result.data) + } + is Result.Error -> { + Log.d("McpViewModel", "Failed to load connection status: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + private fun loadTools() { + viewModelScope.launch { + when (val result = mcpRepository.getTools()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(tools = result.data) + } + is Result.Error -> { + Log.d("McpViewModel", "Failed to load tools: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + _uiState.value = _uiState.value.copy(isRefreshing = true) + loadServers() + loadConnectionStatus() + loadTools() + } + + fun showAddServerDialog() { + _uiState.value = _uiState.value.copy(showServerDialog = true, editingServer = null) + } + + fun showEditServerDialog(server: McpServer) { + _uiState.value = _uiState.value.copy(showServerDialog = true, editingServer = server) + } + + fun dismissServerDialog() { + _uiState.value = _uiState.value.copy(showServerDialog = false, editingServer = null) + } + + fun saveServer( + name: String, + description: String? = null, + url: String, + type: McpServerType, + apiKey: McpApiKeyConfig? = null, + oauth: McpOAuthConfig? = null, + ) { + viewModelScope.launch { + val result = mcpRepository.createServer( + name = name, + description = description, + url = url, + type = type, + apiKey = apiKey, + oauth = oauth, + ) + when (result) { + is Result.Success -> { + dismissServerDialog() + loadServers() + loadConnectionStatus() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to save server", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deleteServer(serverName: String) { + viewModelScope.launch { + when (val result = mcpRepository.deleteServer(serverName)) { + is Result.Success -> { + loadServers() + loadConnectionStatus() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to delete server", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun reinitializeServer(serverName: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + reinitializingServers = _uiState.value.reinitializingServers + serverName, + ) + when (val result = mcpRepository.reinitialize(serverName)) { + is Result.Success -> { + val response = result.data + val message = response.message ?: if (response.success) { + "Server initialized successfully" + } else { + "Failed to initialize server" + } + _uiState.value = _uiState.value.copy( + reinitializingServers = _uiState.value.reinitializingServers - serverName, + successMessage = message, + ) + loadServers() + loadConnectionStatus() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + reinitializingServers = _uiState.value.reinitializingServers - serverName, + error = result.message ?: "Failed to reinitialize server", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun showToolsSheet(serverName: String? = null) { + _uiState.value = _uiState.value.copy( + showToolsSheet = true, + toolsSheetServerName = serverName, + ) + } + + fun dismissToolsSheet() { + _uiState.value = _uiState.value.copy( + showToolsSheet = false, + toolsSheetServerName = null, + ) + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun dismissSuccessMessage() { + _uiState.value = _uiState.value.copy(successMessage = null) + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt new file mode 100644 index 0000000..b09bece --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt @@ -0,0 +1,146 @@ +package com.librechat.android.feature.settings.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.MemoryRepository +import com.librechat.android.core.model.Memory +import com.librechat.android.core.model.request.CreateMemoryRequest +import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest +import com.librechat.android.core.model.request.UpdateMemoryRequest +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class MemoriesUiState( + val memories: List = emptyList(), + val memoriesEnabled: Boolean = true, + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + val showDialog: Boolean = false, + val editingMemory: Memory? = null, +) + +@HiltViewModel +class MemoriesViewModel @Inject constructor( + private val memoryRepository: MemoryRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(MemoriesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadMemories() + } + + fun loadMemories() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = _uiState.value.memories.isEmpty()) + when (val result = memoryRepository.getMemories()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + memories = result.data, + isLoading = false, + isRefreshing = false, + error = null, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isRefreshing = false, + error = result.message ?: "Failed to load memories", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + _uiState.value = _uiState.value.copy(isRefreshing = true) + loadMemories() + } + + fun toggleMemoriesEnabled(enabled: Boolean) { + viewModelScope.launch { + when (val result = memoryRepository.updatePreferences( + UpdateMemoryPreferencesRequest(enabled = enabled), + )) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(memoriesEnabled = result.data.enabled) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to update preferences", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun showAddDialog() { + _uiState.value = _uiState.value.copy(showDialog = true, editingMemory = null) + } + + fun showEditDialog(memory: Memory) { + _uiState.value = _uiState.value.copy(showDialog = true, editingMemory = memory) + } + + fun dismissDialog() { + _uiState.value = _uiState.value.copy(showDialog = false, editingMemory = null) + } + + fun saveMemory(key: String, value: String) { + viewModelScope.launch { + val editing = _uiState.value.editingMemory + val result = if (editing != null) { + memoryRepository.updateMemory( + key = editing.key, + request = UpdateMemoryRequest(value = value), + ) + } else { + memoryRepository.createMemory( + request = CreateMemoryRequest(key = key, value = value), + ) + } + when (result) { + is Result.Success -> { + dismissDialog() + loadMemories() + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to save memory", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deleteMemory(key: String) { + viewModelScope.launch { + when (val result = memoryRepository.deleteMemory(key)) { + is Result.Success -> loadMemories() + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to delete memory", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt new file mode 100644 index 0000000..0992f0a --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt @@ -0,0 +1,107 @@ +package com.librechat.android.feature.settings.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.PresetRepository +import com.librechat.android.core.model.Preset +import com.librechat.android.feature.settings.PresetManagerDisplayData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Immutable +data class PresetManagerUiState( + val presets: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class PresetManagerViewModel @Inject constructor( + private val presetRepository: PresetRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PresetManagerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadPresets() + } + + private fun loadPresets() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + when (val result = presetRepository.getAll()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + presets = result.data.map { it.toDisplayData() }, + isLoading = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.message ?: "Failed to load presets", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true) + when (val result = presetRepository.getAll()) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + presets = result.data.map { it.toDisplayData() }, + isRefreshing = false, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + isRefreshing = false, + error = result.message ?: "Failed to refresh presets", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deletePreset(presetId: String) { + viewModelScope.launch { + when (val result = presetRepository.delete(presetId)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy( + presets = _uiState.value.presets.filter { it.presetId != presetId }, + ) + } + is Result.Error -> { + _uiState.value = _uiState.value.copy( + error = result.message ?: "Failed to delete preset", + ) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissError() { + _uiState.value = _uiState.value.copy(error = null) + } +} + +private fun Preset.toDisplayData() = PresetManagerDisplayData( + presetId = presetId, + title = title ?: "Untitled Preset", + endpoint = endpoint?.name?.lowercase(), + model = model, +) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt new file mode 100644 index 0000000..43dae5a --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt @@ -0,0 +1,24 @@ +package com.librechat.android.feature.settings.viewmodel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * Shared state accessor passed to all SettingsViewModel delegates. + * Provides atomic read/write access to the UI state and a coroutine scope. + */ +class SettingsStateHandle( + val stateFlow: MutableStateFlow, + val scope: CoroutineScope, +) { + val state: SettingsUiState get() = stateFlow.value + + /** + * Atomic CAS-based state update. Uses [MutableStateFlow.update] under the hood + * to avoid lost updates when multiple coroutines write concurrently. + */ + fun update(transform: SettingsUiState.() -> SettingsUiState) { + stateFlow.update { it.transform() } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..7a13d16 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt @@ -0,0 +1,685 @@ +package com.librechat.android.feature.settings.viewmodel + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.qualifiers.ApplicationContext +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ChatFontSize +import com.librechat.android.core.data.datastore.LatexRenderer +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.datastore.ThemeDataStore +import com.librechat.android.core.data.datastore.ThemeMode +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.data.repository.MemoryRepository +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.core.data.repository.UserRepository +import com.librechat.android.core.model.Memory +import com.librechat.android.core.model.SharedLink +import com.librechat.android.core.model.User +import com.librechat.android.core.model.mcp.McpApiKeyConfig +import com.librechat.android.core.model.mcp.McpOAuthConfig +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerStatus +import com.librechat.android.core.model.mcp.McpServerType +import com.librechat.android.core.model.request.CreateMemoryRequest +import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest +import com.librechat.android.core.model.request.UpdateMemoryRequest +import com.librechat.android.core.model.speech.TtsVoice +import com.librechat.android.core.data.repository.BalanceRepository +import com.librechat.android.core.data.repository.KeyRepository +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.feature.settings.SharedLinkDisplayData +import com.librechat.android.feature.settings.UserDisplayData +import com.librechat.android.feature.settings.screen.DeviceVoiceInfo +import com.librechat.android.feature.settings.viewmodel.delegate.DataManagementDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.McpServerDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.MemoryManagementDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.TwoFactorSecurityDelegate +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SettingsCommand( + val name: String, + val description: String, + val enabled: Boolean = true, +) + +val DEFAULT_COMMANDS = listOf( + SettingsCommand("help", "Show available commands", true), + SettingsCommand("clear", "Clear current conversation", true), + SettingsCommand("new", "Start a new conversation", true), + SettingsCommand("model", "Switch the current model", true), + SettingsCommand("system", "Set a system message", true), + SettingsCommand("fork", "Fork the current conversation", true), +) + +@Immutable +data class SettingsUiState( + val user: UserDisplayData? = null, + val themeMode: ThemeMode = ThemeMode.SYSTEM, + val serverUrl: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isLoggedOut: Boolean = false, + val isAccountDeleted: Boolean = false, + // Chat preferences + val chatFontSize: ChatFontSize = ChatFontSize.MEDIUM, + val autoScrollEnabled: Boolean = true, + val showThinkingBlocks: Boolean = true, + val showImageDescriptions: Boolean = false, + val dismissKeyboardOnSend: Boolean = false, + // Data management + val archivedCount: Int = 0, + val isClearing: Boolean = false, + val showExportComingSoon: Boolean = false, + // Security (2FA) + val isTwoFactorEnabled: Boolean = false, + val isTwoFactorLoading: Boolean = false, + val showTwoFactorSetupDialog: Boolean = false, + val twoFactorOtpauthUrl: String? = null, + val showBackupCodesDialog: Boolean = false, + val backupCodes: List = emptyList(), + val showDisableTwoFactorDialog: Boolean = false, + // MCP + val mcpServers: List = emptyList(), + val mcpConnectionStatus: Map = emptyMap(), + val mcpError: String? = null, + val showMcpServerDialog: Boolean = false, + val editingMcpServer: McpServer? = null, + val mcpReinitializingServers: Set = emptySet(), + val mcpReinitializeMessage: String? = null, + // Speech settings + val autoReadEnabled: Boolean = false, + val selectedVoice: TtsVoice? = null, + val availableVoices: List = emptyList(), + // Memories + val memories: List = emptyList(), + val memoriesEnabled: Boolean = true, + val showMemoryDialog: Boolean = false, + val editingMemory: Memory? = null, + // Balance + val tokenCredits: Long = 0, + val isBalanceLoading: Boolean = false, + // Avatar + val showAvatarDialog: Boolean = false, + val isAvatarUploading: Boolean = false, + // Shared links + val sharedLinks: List = emptyList(), + val sharedLinksNextCursor: String? = null, + val sharedLinksHasNextPage: Boolean = false, + val isSharedLinksLoading: Boolean = false, + // Speech detail dialogs + val showSttDetailDialog: Boolean = false, + val showTtsDetailDialog: Boolean = false, + val sttEngine: String = "", + val sttLanguage: String = "", + val sttAutoSend: Boolean = false, + val ttsEngine: String = "", + val ttsVoice: String = "", + val ttsSpeechRate: Float = 1.0f, + val ttsPitch: Float = 1.0f, + val ttsDeviceVoiceName: String = "", + val ttsCaching: Boolean = true, + val ttsSource: String = "device", + val availableDeviceVoices: List = emptyList(), + // TTS preview + val isTtsPreviewPlaying: Boolean = false, + // Cache / Keys + val isCacheClearing: Boolean = false, + val isKeyRevoking: Boolean = false, + // Language + val selectedLanguage: String = "en", + val showLanguageDialog: Boolean = false, + // Fork settings + val forkMode: String = "targetLevel", + val showForkSettingsDialog: Boolean = false, + // Commands + val showCommandsScreen: Boolean = false, + val commands: List = DEFAULT_COMMANDS, + // Personalization + val showPersonalizationDialog: Boolean = false, + val personalizationEnabled: Boolean = true, + val aboutUser: String = "", + val responseStyle: String = "", + // Tablet + val tabletSidebarGestureEnabled: Boolean = true, + // Chat layout + val chatLayoutStyle: String = ChatLayoutConstants.THREAD, + val showAvatars: Boolean = true, + val showBubbles: Boolean = false, + val latexRenderer: LatexRenderer = LatexRenderer.KATEX, +) + +private fun User.toDisplayData() = UserDisplayData( + name = name ?: "", + email = email, + username = username ?: "", + avatar = avatar, +) + +/** Intermediate holder for the combined DataStore preferences. */ +private data class DataStorePreferences( + val themeMode: ThemeMode, + val serverUrl: String, + val chatFontSize: ChatFontSize, + val autoScrollEnabled: Boolean, + val showThinkingBlocks: Boolean, + val autoReadEnabled: Boolean, + val selectedVoiceId: String, +) + +@HiltViewModel +class SettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val userRepository: UserRepository, + private val authRepository: AuthRepository, + conversationRepository: ConversationRepository, + private val themeDataStore: ThemeDataStore, + serverDataStore: ServerDataStore, + private val settingsDataStore: SettingsDataStore, + mcpRepository: McpRepository, + memoryRepository: MemoryRepository, + speechRepository: SpeechRepository, + private val balanceRepository: BalanceRepository, + shareRepository: ShareRepository, + keyRepository: KeyRepository, +) : ViewModel() { + + /** Raw state for everything not driven by DataStore flows. */ + private val _uiState = MutableStateFlow(SettingsUiState()) + + private val stateHandle = SettingsStateHandle(_uiState, viewModelScope) + + // --- Delegates --- + private val memoryDelegate = MemoryManagementDelegate(stateHandle, memoryRepository) + private val mcpDelegate = McpServerDelegate(stateHandle, mcpRepository) + private val twoFactorDelegate = TwoFactorSecurityDelegate(stateHandle, authRepository) + private val dataDelegate = DataManagementDelegate(stateHandle, context, conversationRepository, shareRepository, keyRepository) + private val speechDelegate = SpeechSettingsDelegate(stateHandle, context, speechRepository, settingsDataStore) + + /** Combined DataStore preferences flow. */ + private val dataStorePreferences: StateFlow = combine( + themeDataStore.themeMode, + serverDataStore.currentUrlFlow, + settingsDataStore.chatFontSize, + settingsDataStore.autoScrollEnabled, + settingsDataStore.showThinkingBlocks, + ) { theme, serverUrl, fontSize, autoScroll, showThinking -> + DataStorePreferences( + themeMode = theme, + serverUrl = serverUrl, + chatFontSize = fontSize, + autoScrollEnabled = autoScroll, + showThinkingBlocks = showThinking, + autoReadEnabled = false, + selectedVoiceId = "", + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, DataStorePreferences( + themeMode = ThemeMode.SYSTEM, + serverUrl = "", + chatFontSize = ChatFontSize.MEDIUM, + autoScrollEnabled = true, + showThinkingBlocks = true, + autoReadEnabled = false, + selectedVoiceId = "", + )) + + /** Extra DataStore preferences (separate combine since Kotlin combine maxes at 5). */ + private data class ExtraPreferences( + val autoRead: Boolean, + val selectedVoiceId: String?, + val showImageDescriptions: Boolean, + val dismissKeyboardOnSend: Boolean, + val ttsSource: String, + ) + + private val extraPreferences: StateFlow = combine( + settingsDataStore.autoReadEnabled, + settingsDataStore.selectedVoiceId, + settingsDataStore.showImageDescriptions, + settingsDataStore.dismissKeyboardOnSend, + settingsDataStore.ttsSource, + ) { autoRead, voiceId, showImgDesc, dismissKeyboard, ttsSource -> + ExtraPreferences(autoRead, voiceId, showImgDesc, dismissKeyboard, ttsSource) + }.stateIn(viewModelScope, SharingStarted.Eagerly, ExtraPreferences(false, null, false, false, "device")) + + /** Device TTS preferences (speech rate, pitch, voice name, engine, voice selection). */ + private data class DeviceTtsPreferences( + val speechRate: Float, + val pitch: Float, + val voiceName: String, + val ttsEngine: String, + val ttsVoice: String, + ) + + private val deviceTtsPreferences: StateFlow = combine( + settingsDataStore.ttsSpeechRate, + settingsDataStore.ttsPitch, + settingsDataStore.ttsVoiceName, + settingsDataStore.ttsEngine, + settingsDataStore.ttsVoice, + ) { rate, pitch, voiceName, ttsEngine, ttsVoice -> + DeviceTtsPreferences(rate, pitch, voiceName, ttsEngine, ttsVoice) + }.stateIn(viewModelScope, SharingStarted.Eagerly, DeviceTtsPreferences(1.0f, 1.0f, "", "", "")) + + /** TTS caching preference. Kept as a separate flow to stay within the 5-arg combine limit. */ + private val ttsCachingPreference: StateFlow = settingsDataStore.ttsCaching + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + /** Tablet-specific preferences. */ + private val tabletSidebarGestureEnabled: StateFlow = settingsDataStore.tabletSidebarGestureEnabled + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + /** Chat layout preferences. */ + private val chatLayoutStylePref: StateFlow = settingsDataStore.chatLayoutStyle + .stateIn(viewModelScope, SharingStarted.Eagerly, ChatLayoutConstants.THREAD) + + private val showAvatarsPref: StateFlow = settingsDataStore.showAvatars + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + private val showBubblesPref: StateFlow = settingsDataStore.showBubbles + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val latexRendererPref: StateFlow = settingsDataStore.latexRenderer + .stateIn(viewModelScope, SharingStarted.Eagerly, LatexRenderer.KATEX) + + /** Additional preferences combined separately to stay within the 5-arg combine limit. */ + private data class AdditionalPreferences( + val tabletSidebarGestureEnabled: Boolean, + val autoSendAfterStt: Boolean, + val sttEngine: String, + val sttLanguage: String, + val ttsCaching: Boolean, + val chatLayoutStyle: String = ChatLayoutConstants.THREAD, + val showAvatars: Boolean = true, + val showBubbles: Boolean = false, + val latexRenderer: LatexRenderer = LatexRenderer.KATEX, + ) + + private val baseAdditionalPreferences = combine( + tabletSidebarGestureEnabled, + settingsDataStore.autoSendAfterStt, + settingsDataStore.sttEngine, + settingsDataStore.sttLanguage, + ttsCachingPreference, + ) { tabletGesture, autoSendStt, sttEngine, sttLanguage, ttsCaching -> + AdditionalPreferences(tabletGesture, autoSendStt, sttEngine, sttLanguage, ttsCaching) + } + + private val additionalPreferences: StateFlow = combine( + baseAdditionalPreferences, + chatLayoutStylePref, + showAvatarsPref, + showBubblesPref, + latexRendererPref, + ) { base, layoutStyle, showAvatars, showBubbles, latexRenderer -> + base.copy(chatLayoutStyle = layoutStyle, showAvatars = showAvatars, showBubbles = showBubbles, latexRenderer = latexRenderer) + }.stateIn(viewModelScope, SharingStarted.Eagerly, AdditionalPreferences(true, false, "", "", true)) + + /** The single public UI state that merges DataStore preferences with imperative state. */ + val uiState: StateFlow = combine( + _uiState, + dataStorePreferences, + extraPreferences, + deviceTtsPreferences, + additionalPreferences, + ) { state, prefs, extra, deviceTts, additional -> + val selectedVoice = extra.selectedVoiceId?.let { id -> state.availableVoices.find { it.id == id } } + state.copy( + themeMode = prefs.themeMode, + serverUrl = prefs.serverUrl, + chatFontSize = prefs.chatFontSize, + autoScrollEnabled = prefs.autoScrollEnabled, + showThinkingBlocks = prefs.showThinkingBlocks, + autoReadEnabled = extra.autoRead, + selectedVoice = selectedVoice, + showImageDescriptions = extra.showImageDescriptions, + dismissKeyboardOnSend = extra.dismissKeyboardOnSend, + ttsSource = extra.ttsSource, + ttsSpeechRate = deviceTts.speechRate, + ttsPitch = deviceTts.pitch, + ttsDeviceVoiceName = deviceTts.voiceName, + ttsEngine = deviceTts.ttsEngine, + ttsVoice = deviceTts.ttsVoice, + ttsCaching = additional.ttsCaching, + tabletSidebarGestureEnabled = additional.tabletSidebarGestureEnabled, + sttAutoSend = additional.autoSendAfterStt, + sttEngine = additional.sttEngine, + sttLanguage = additional.sttLanguage, + chatLayoutStyle = additional.chatLayoutStyle, + showAvatars = additional.showAvatars, + showBubbles = additional.showBubbles, + latexRenderer = additional.latexRenderer, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, SettingsUiState()) + + init { + loadUser() + mcpDelegate.loadMcpServers() + memoryDelegate.loadMemories() + speechDelegate.loadVoices() + loadBalance() + speechDelegate.loadDeviceVoices() + } + + // ── Account & user profile ───────────────────────────────────── + + private fun loadUser() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val result = userRepository.getUser()) { + is Result.Success -> { + _uiState.update { + it.copy( + user = result.data.toDisplayData(), + isLoading = false, + isTwoFactorEnabled = result.data.twoFactorEnabled, + ) + } + } + is Result.Error -> { + _uiState.update { + it.copy( + isLoading = false, + error = result.message ?: "Failed to load user profile", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + // Avatar + + fun showAvatarDialog() { + _uiState.update { it.copy(showAvatarDialog = true) } + } + + fun dismissAvatarDialog() { + _uiState.update { it.copy(showAvatarDialog = false) } + } + + fun uploadAvatar(uri: Uri) { + viewModelScope.launch { + _uiState.update { it.copy(isAvatarUploading = true) } + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes == null) { + _uiState.update { + it.copy(isAvatarUploading = false, error = "Could not read selected image") + } + return@launch + } + when (val result = userRepository.uploadAvatar(bytes)) { + is Result.Success -> { + _uiState.update { + it.copy( + user = result.data.toDisplayData(), + isAvatarUploading = false, + showAvatarDialog = false, + ) + } + } + is Result.Error -> { + _uiState.update { + it.copy( + isAvatarUploading = false, + error = result.message ?: "Failed to upload avatar", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + // ── Theme & appearance ───────────────────────────────────────── + + fun setThemeMode(mode: ThemeMode) { + viewModelScope.launch { themeDataStore.setThemeMode(mode) } + } + + // ── Chat preferences ─────────────────────────────────────────── + + fun setChatFontSize(size: ChatFontSize) { + viewModelScope.launch { settingsDataStore.setChatFontSize(size) } + } + + fun setAutoScrollEnabled(enabled: Boolean) { + viewModelScope.launch { settingsDataStore.setAutoScrollEnabled(enabled) } + } + + fun setShowThinkingBlocks(show: Boolean) { + viewModelScope.launch { settingsDataStore.setShowThinkingBlocks(show) } + } + + fun setShowImageDescriptions(show: Boolean) { + viewModelScope.launch { settingsDataStore.setShowImageDescriptions(show) } + } + + fun setDismissKeyboardOnSend(enabled: Boolean) { + viewModelScope.launch { settingsDataStore.setDismissKeyboardOnSend(enabled) } + } + + fun setChatLayoutStyle(style: String) { + viewModelScope.launch { settingsDataStore.setChatLayoutStyle(style) } + } + + fun setShowAvatars(show: Boolean) { + viewModelScope.launch { settingsDataStore.setShowAvatars(show) } + } + + fun setShowBubbles(show: Boolean) { + viewModelScope.launch { settingsDataStore.setShowBubbles(show) } + } + + fun setLatexRenderer(renderer: LatexRenderer) { + viewModelScope.launch { settingsDataStore.setLatexRenderer(renderer) } + } + + // ── Tablet preferences ───────────────────────────────────────── + + fun setTabletSidebarGestureEnabled(enabled: Boolean) { + viewModelScope.launch { settingsDataStore.setTabletSidebarGestureEnabled(enabled) } + } + + // ── Balance ──────────────────────────────────────────────────── + + private fun loadBalance() { + viewModelScope.launch { + _uiState.update { it.copy(isBalanceLoading = true) } + when (val result = balanceRepository.getBalance()) { + is Result.Success -> { + _uiState.update { it.copy(tokenCredits = result.data.tokenCredits, isBalanceLoading = false) } + } + is Result.Error -> { + Log.d("SettingsViewModel", "Failed to load balance: ${result.message}", result.exception) + _uiState.update { it.copy(isBalanceLoading = false) } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + // ── Language ─────────────────────────────────────────────────── + + fun showLanguageDialog() { + _uiState.update { it.copy(showLanguageDialog = true) } + } + + fun dismissLanguageDialog() { + _uiState.update { it.copy(showLanguageDialog = false) } + } + + fun setLanguage(languageCode: String) { + _uiState.update { it.copy(selectedLanguage = languageCode, showLanguageDialog = false) } + } + + // ── Fork settings ────────────────────────────────────────────── + + fun showForkSettingsDialog() { + _uiState.update { it.copy(showForkSettingsDialog = true) } + } + + fun dismissForkSettingsDialog() { + _uiState.update { it.copy(showForkSettingsDialog = false) } + } + + fun setForkMode(mode: String) { + _uiState.update { it.copy(forkMode = mode, showForkSettingsDialog = false) } + } + + // ── Commands ─────────────────────────────────────────────────── + + fun showCommandsScreen() { + _uiState.update { it.copy(showCommandsScreen = true) } + } + + fun hideCommandsScreen() { + _uiState.update { it.copy(showCommandsScreen = false) } + } + + fun toggleCommand(name: String, enabled: Boolean) { + _uiState.update { state -> + state.copy( + commands = state.commands.map { cmd -> + if (cmd.name == name) cmd.copy(enabled = enabled) else cmd + }, + ) + } + } + + // ── Personalization ──────────────────────────────────────────── + + fun showPersonalizationDialog() { + _uiState.update { it.copy(showPersonalizationDialog = true) } + } + + fun dismissPersonalizationDialog() { + _uiState.update { it.copy(showPersonalizationDialog = false) } + } + + fun savePersonalization(aboutUser: String, responseStyle: String, enabled: Boolean) { + _uiState.update { + it.copy( + aboutUser = aboutUser, + responseStyle = responseStyle, + personalizationEnabled = enabled, + showPersonalizationDialog = false, + ) + } + } + + // ── Auth actions ─────────────────────────────────────────────── + + fun logout() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + authRepository.logout() + _uiState.update { it.copy(isLoading = false, isLoggedOut = true) } + } + } + + fun deleteAccount() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val result = userRepository.deleteUser()) { + is Result.Success -> { + authRepository.logout() + _uiState.update { it.copy(isLoading = false, isAccountDeleted = true) } + } + is Result.Error -> { + _uiState.update { + it.copy(isLoading = false, error = result.message ?: "Failed to delete account") + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun retry() { + loadUser() + } + + fun dismissError() { + _uiState.update { it.copy(error = null) } + } + + // ── Delegated public API ─────────────────────────────────────── + + // Memory management + fun showAddMemoryDialog() = memoryDelegate.showAddMemoryDialog() + fun showEditMemoryDialog(memory: Memory) = memoryDelegate.showEditMemoryDialog(memory) + fun dismissMemoryDialog() = memoryDelegate.dismissMemoryDialog() + fun saveMemory(key: String, value: String) = memoryDelegate.saveMemory(key, value) + fun deleteMemory(key: String) = memoryDelegate.deleteMemory(key) + fun toggleMemoriesEnabled(enabled: Boolean) = memoryDelegate.toggleMemoriesEnabled(enabled) + + // MCP server management + fun showAddMcpServerDialog() = mcpDelegate.showAddMcpServerDialog() + fun showEditMcpServerDialog(server: McpServer) = mcpDelegate.showEditMcpServerDialog(server) + fun dismissMcpServerDialog() = mcpDelegate.dismissMcpServerDialog() + fun saveMcpServer(name: String, description: String? = null, url: String, type: McpServerType, apiKey: McpApiKeyConfig? = null, oauth: McpOAuthConfig? = null) = + mcpDelegate.saveMcpServer(name, description, url, type, apiKey, oauth) + fun deleteMcpServer(serverName: String) = mcpDelegate.deleteMcpServer(serverName) + fun reinitializeMcpServer(serverName: String) = mcpDelegate.reinitializeMcpServer(serverName) + fun dismissMcpReinitializeMessage() = mcpDelegate.dismissMcpReinitializeMessage() + + // Two-factor security + fun toggleTwoFactor() = twoFactorDelegate.toggleTwoFactor() + fun confirmEnableTwoFactor(code: String) = twoFactorDelegate.confirmEnableTwoFactor(code) + fun confirmDisableTwoFactor(code: String) = twoFactorDelegate.confirmDisableTwoFactor(code) + fun dismissTwoFactorSetupDialog() = twoFactorDelegate.dismissTwoFactorSetupDialog() + fun dismissDisableTwoFactorDialog() = twoFactorDelegate.dismissDisableTwoFactorDialog() + fun dismissBackupCodesDialog() = twoFactorDelegate.dismissBackupCodesDialog() + fun viewBackupCodes() = twoFactorDelegate.viewBackupCodes() + + // Data management + fun clearAllChats() = dataDelegate.clearAllChats() + fun exportAllData() = dataDelegate.exportAllData() + fun dismissExportComingSoon() = dataDelegate.dismissExportComingSoon() + fun loadSharedLinks() = dataDelegate.loadSharedLinks() + fun loadMoreSharedLinks() = dataDelegate.loadMoreSharedLinks() + fun toggleSharedLinkVisibility(shareId: String) = dataDelegate.toggleSharedLinkVisibility(shareId) + fun deleteSharedLink(shareId: String) = dataDelegate.deleteSharedLink(shareId) + fun clearCache() = dataDelegate.clearCache() + fun revokeAllKeys() = dataDelegate.revokeAllKeys() + + // Speech settings + fun setAutoSendAfterStt(enabled: Boolean) = speechDelegate.setAutoSendAfterStt(enabled) + fun setAutoReadEnabled(enabled: Boolean) = speechDelegate.setAutoReadEnabled(enabled) + fun selectVoice(voice: TtsVoice) = speechDelegate.selectVoice(voice) + fun testVoice() = speechDelegate.testVoice() + fun previewDeviceTts(text: String, rate: Float, pitch: Float, voiceName: String?) = speechDelegate.previewDeviceTts(text, rate, pitch, voiceName) + fun previewServerTts(text: String, voice: String?, model: String?) = speechDelegate.previewServerTts(text, voice, model) + fun stopTtsPreview() = speechDelegate.stopTtsPreview() + fun showSttDetailDialog() = speechDelegate.showSttDetailDialog() + fun dismissSttDetailDialog() = speechDelegate.dismissSttDetailDialog() + fun saveSttSettings(engine: String, language: String) = speechDelegate.saveSttSettings(engine, language) + fun showTtsDetailDialog() = speechDelegate.showTtsDetailDialog() + fun dismissTtsDetailDialog() = speechDelegate.dismissTtsDetailDialog() + fun saveTtsSettings(engine: String, voice: String, rate: Float, pitch: Float, deviceVoiceName: String, caching: Boolean, source: String) = + speechDelegate.saveTtsSettings(engine, voice, rate, pitch, deviceVoiceName, caching, source) + + override fun onCleared() { + super.onCleared() + speechDelegate.release() + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt new file mode 100644 index 0000000..410e1d3 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt @@ -0,0 +1,14 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +/** + * Placeholder delegate for API key CRUD operations. + * + * The actual API key management (list, create, delete) already lives in its own + * [ApiKeysViewModel] with a dedicated screen. This delegate exists only to handle + * the "Revoke All Keys" action that lives on the main settings screen. + * + * Since revokeAllKeys is closely tied to the data management section (cache clearing, + * shared links, etc.), it is implemented in [DataManagementDelegate] instead. + * This file is kept as a marker for future expansion if API key operations + * need to be surfaced directly on the settings screen. + */ diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt new file mode 100644 index 0000000..a22ecfa --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt @@ -0,0 +1,188 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import android.content.Context +import android.util.Log +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.KeyRepository +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.core.model.SharedLink +import com.librechat.android.feature.settings.SharedLinkDisplayData +import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle +import kotlinx.coroutines.launch + +/** + * Handles clear conversations, export, shared links, cache clearing, and key revocation. + */ +class DataManagementDelegate( + private val stateHandle: SettingsStateHandle, + private val context: Context, + private val conversationRepository: ConversationRepository, + private val shareRepository: ShareRepository, + private val keyRepository: KeyRepository, +) { + + fun clearAllChats() { + stateHandle.scope.launch { + stateHandle.update { copy(isClearing = true) } + when (val result = conversationRepository.deleteAll()) { + is Result.Success -> { + stateHandle.update { copy(isClearing = false) } + } + is Result.Error -> { + stateHandle.update { + copy( + isClearing = false, + error = result.message ?: "Failed to clear conversations", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun exportAllData() { + stateHandle.update { copy(showExportComingSoon = true) } + } + + fun dismissExportComingSoon() { + stateHandle.update { copy(showExportComingSoon = false) } + } + + fun loadSharedLinks() { + stateHandle.scope.launch { + stateHandle.update { copy(isSharedLinksLoading = true) } + when (val result = shareRepository.getSharedLinksPaginated()) { + is Result.Success -> { + stateHandle.update { + copy( + sharedLinks = result.data.links.map { it.toDisplayData() }, + sharedLinksNextCursor = result.data.nextCursor, + sharedLinksHasNextPage = result.data.hasNextPage ?: false, + isSharedLinksLoading = false, + ) + } + } + is Result.Error -> { + stateHandle.update { + copy( + isSharedLinksLoading = false, + error = result.message ?: "Failed to load shared links", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun loadMoreSharedLinks() { + val cursor = stateHandle.state.sharedLinksNextCursor ?: return + stateHandle.scope.launch { + stateHandle.update { copy(isSharedLinksLoading = true) } + when (val result = shareRepository.getSharedLinksPaginated(cursor = cursor)) { + is Result.Success -> { + stateHandle.update { + copy( + sharedLinks = sharedLinks + result.data.links.map { it.toDisplayData() }, + sharedLinksNextCursor = result.data.nextCursor, + sharedLinksHasNextPage = result.data.hasNextPage ?: false, + isSharedLinksLoading = false, + ) + } + } + is Result.Error -> { + stateHandle.update { + copy( + isSharedLinksLoading = false, + error = result.message ?: "Failed to load more shared links", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun toggleSharedLinkVisibility(shareId: String) { + stateHandle.scope.launch { + when (val result = shareRepository.toggleShareVisibility(shareId)) { + is Result.Success -> { + stateHandle.update { + copy( + sharedLinks = sharedLinks.map { link -> + if (link.shareId == shareId) result.data.toDisplayData() else link + }, + ) + } + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to toggle visibility") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deleteSharedLink(shareId: String) { + stateHandle.scope.launch { + when (val result = shareRepository.deleteShareLink(shareId)) { + is Result.Success -> { + stateHandle.update { + copy(sharedLinks = sharedLinks.filter { it.shareId != shareId }) + } + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to delete shared link") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun clearCache() { + stateHandle.scope.launch { + stateHandle.update { copy(isCacheClearing = true) } + try { + val cacheDir = context.cacheDir + cacheDir.deleteRecursively() + stateHandle.update { copy(isCacheClearing = false) } + } catch (e: Exception) { + stateHandle.update { + copy( + isCacheClearing = false, + error = e.message ?: "Failed to clear cache", + ) + } + } + } + } + + fun revokeAllKeys() { + stateHandle.scope.launch { + stateHandle.update { copy(isKeyRevoking = true) } + when (val result = keyRepository.deleteAllKeys()) { + is Result.Success -> { + stateHandle.update { copy(isKeyRevoking = false) } + } + is Result.Error -> { + stateHandle.update { + copy( + isKeyRevoking = false, + error = result.message ?: "Failed to revoke API keys", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } +} + +private fun SharedLink.toDisplayData() = SharedLinkDisplayData( + shareId = shareId ?: "", + title = title ?: "Untitled Conversation", + createdAt = createdAt, + isPublic = isPublic, +) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt new file mode 100644 index 0000000..6ba7862 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt @@ -0,0 +1,130 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import android.util.Log +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.model.mcp.McpApiKeyConfig +import com.librechat.android.core.model.mcp.McpOAuthConfig +import com.librechat.android.core.model.mcp.McpServer +import com.librechat.android.core.model.mcp.McpServerType +import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle +import kotlinx.coroutines.launch + +/** + * Handles MCP server management, connection status, and reinitialization. + */ +class McpServerDelegate( + private val stateHandle: SettingsStateHandle, + private val mcpRepository: McpRepository, +) { + + fun loadMcpServers() { + stateHandle.scope.launch { + when (val result = mcpRepository.listServers()) { + is Result.Success -> { + stateHandle.update { copy(mcpServers = result.data, mcpError = null) } + } + is Result.Error -> { + Log.d("SettingsViewModel", "Failed to load MCP servers: ${result.message}", result.exception) + stateHandle.update { copy(mcpError = result.message ?: "MCP not available on this server") } + } + is Result.Loading -> { /* no-op */ } + } + } + stateHandle.scope.launch { + when (val result = mcpRepository.getConnectionStatus()) { + is Result.Success -> { + stateHandle.update { copy(mcpConnectionStatus = result.data) } + } + is Result.Error -> { + Log.d("SettingsViewModel", "Failed to load MCP connection status: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun showAddMcpServerDialog() { + stateHandle.update { copy(showMcpServerDialog = true, editingMcpServer = null) } + } + + fun showEditMcpServerDialog(server: McpServer) { + stateHandle.update { copy(showMcpServerDialog = true, editingMcpServer = server) } + } + + fun dismissMcpServerDialog() { + stateHandle.update { copy(showMcpServerDialog = false, editingMcpServer = null) } + } + + fun saveMcpServer( + name: String, + description: String? = null, + url: String, + type: McpServerType, + apiKey: McpApiKeyConfig? = null, + oauth: McpOAuthConfig? = null, + ) { + stateHandle.scope.launch { + val result = mcpRepository.createServer( + name = name, + description = description, + url = url, + type = type, + apiKey = apiKey, + oauth = oauth, + ) + when (result) { + is Result.Success -> { + dismissMcpServerDialog() + loadMcpServers() + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to save MCP server") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deleteMcpServer(serverName: String) { + stateHandle.scope.launch { + when (val result = mcpRepository.deleteServer(serverName)) { + is Result.Success -> loadMcpServers() + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to delete MCP server") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun reinitializeMcpServer(serverName: String) { + stateHandle.scope.launch { + stateHandle.update { copy(mcpReinitializingServers = mcpReinitializingServers + serverName) } + when (val result = mcpRepository.reinitialize(serverName)) { + is Result.Success -> { + stateHandle.update { + copy( + mcpReinitializingServers = mcpReinitializingServers - serverName, + mcpReinitializeMessage = "Server reinitialized successfully", + ) + } + loadMcpServers() + } + is Result.Error -> { + stateHandle.update { + copy( + mcpReinitializingServers = mcpReinitializingServers - serverName, + mcpReinitializeMessage = result.message ?: "Failed to reinitialize server", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissMcpReinitializeMessage() { + stateHandle.update { copy(mcpReinitializeMessage = null) } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt new file mode 100644 index 0000000..17e5e6f --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt @@ -0,0 +1,100 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import android.util.Log +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.MemoryRepository +import com.librechat.android.core.model.Memory +import com.librechat.android.core.model.request.CreateMemoryRequest +import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest +import com.librechat.android.core.model.request.UpdateMemoryRequest +import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle +import kotlinx.coroutines.launch + +/** + * Handles memory CRUD operations and memory preferences. + */ +class MemoryManagementDelegate( + private val stateHandle: SettingsStateHandle, + private val memoryRepository: MemoryRepository, +) { + + fun loadMemories() { + stateHandle.scope.launch { + when (val result = memoryRepository.getMemories()) { + is Result.Success -> { + stateHandle.update { copy(memories = result.data) } + } + is Result.Error -> { + Log.d("SettingsViewModel", "Failed to load memories: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun showAddMemoryDialog() { + stateHandle.update { copy(showMemoryDialog = true, editingMemory = null) } + } + + fun showEditMemoryDialog(memory: Memory) { + stateHandle.update { copy(showMemoryDialog = true, editingMemory = memory) } + } + + fun dismissMemoryDialog() { + stateHandle.update { copy(showMemoryDialog = false, editingMemory = null) } + } + + fun saveMemory(key: String, value: String) { + stateHandle.scope.launch { + val editing = stateHandle.state.editingMemory + val result = if (editing != null) { + memoryRepository.updateMemory( + key = editing.key, + request = UpdateMemoryRequest(value = value), + ) + } else { + memoryRepository.createMemory( + request = CreateMemoryRequest(key = key, value = value), + ) + } + when (result) { + is Result.Success -> { + dismissMemoryDialog() + loadMemories() + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to save memory") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun deleteMemory(key: String) { + stateHandle.scope.launch { + when (val result = memoryRepository.deleteMemory(key)) { + is Result.Success -> loadMemories() + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to delete memory") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun toggleMemoriesEnabled(enabled: Boolean) { + stateHandle.scope.launch { + when (val result = memoryRepository.updatePreferences( + UpdateMemoryPreferencesRequest(enabled = enabled), + )) { + is Result.Success -> { + stateHandle.update { copy(memoriesEnabled = result.data.enabled) } + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Failed to update memory preferences") } + } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt new file mode 100644 index 0000000..fa7b495 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt @@ -0,0 +1,268 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import android.content.Context +import android.media.MediaPlayer +import android.speech.tts.TextToSpeech +import android.util.Log +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.core.model.speech.TtsVoice +import com.librechat.android.feature.settings.screen.DeviceVoiceInfo +import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle +import kotlinx.coroutines.launch + +/** + * Handles TTS voice selection, test playback, device voice loading, and MediaPlayer lifecycle. + */ +class SpeechSettingsDelegate( + private val stateHandle: SettingsStateHandle, + private val context: Context, + private val speechRepository: SpeechRepository, + private val settingsDataStore: SettingsDataStore, +) { + + private var currentMediaPlayer: MediaPlayer? = null + private var deviceTtsEngine: TextToSpeech? = null + + fun loadVoices() { + stateHandle.scope.launch { + when (val result = speechRepository.getVoices()) { + is Result.Success -> { + stateHandle.update { copy(availableVoices = result.data) } + } + is Result.Error -> { + Log.d("SettingsViewModel", "Failed to load voices: ${result.message}", result.exception) + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun loadDeviceVoices() { + deviceTtsEngine = TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + val voices = deviceTtsEngine?.voices?.map { voice -> + DeviceVoiceInfo( + name = voice.name, + locale = voice.locale.displayName, + ) + }?.sortedBy { it.name } ?: emptyList() + stateHandle.update { copy(availableDeviceVoices = voices) } + } + } + } + + fun setAutoSendAfterStt(enabled: Boolean) { + stateHandle.scope.launch { + settingsDataStore.setAutoSendAfterStt(enabled) + } + } + + fun setAutoReadEnabled(enabled: Boolean) { + stateHandle.scope.launch { + settingsDataStore.setAutoReadEnabled(enabled) + } + } + + fun selectVoice(voice: TtsVoice) { + stateHandle.scope.launch { + settingsDataStore.setSelectedVoiceId(voice.id) + } + } + + fun testVoice() { + val voice = stateHandle.state.selectedVoice + stateHandle.scope.launch { + val result = speechRepository.synthesizeSpeech( + text = "This is a test of the selected voice.", + voice = voice?.id, + ) + when (result) { + is Result.Success -> { + playAudioBytes(result.data) + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Voice test failed") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun previewDeviceTts(text: String, rate: Float, pitch: Float, voiceName: String?) { + stopTtsPreview() + val tts = deviceTtsEngine ?: return + tts.setSpeechRate(rate) + tts.setPitch(pitch) + if (!voiceName.isNullOrBlank()) { + val targetVoice = tts.voices?.find { it.name == voiceName } + if (targetVoice != null) { + tts.voice = targetVoice + } + } + stateHandle.update { copy(isTtsPreviewPlaying = true) } + tts.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { /* no-op */ } + override fun onDone(utteranceId: String?) { + stateHandle.update { copy(isTtsPreviewPlaying = false) } + } + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + stateHandle.update { copy(isTtsPreviewPlaying = false) } + } + }) + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "tts_preview") + } + + fun previewServerTts(text: String, voice: String?, model: String?) { + stopTtsPreview() + stateHandle.update { copy(isTtsPreviewPlaying = true) } + stateHandle.scope.launch { + val result = speechRepository.synthesizeSpeech( + text = text, + voice = voice, + model = model, + ) + when (result) { + is Result.Success -> { + playAudioBytes(result.data, isPreview = true) + } + is Result.Error -> { + stateHandle.update { + copy( + isTtsPreviewPlaying = false, + error = result.message ?: "Voice preview failed", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun stopTtsPreview() { + deviceTtsEngine?.stop() + currentMediaPlayer?.release() + currentMediaPlayer = null + stateHandle.update { copy(isTtsPreviewPlaying = false) } + } + + // STT detail dialogs + + fun showSttDetailDialog() { + stateHandle.update { copy(showSttDetailDialog = true) } + } + + fun dismissSttDetailDialog() { + stateHandle.update { copy(showSttDetailDialog = false) } + } + + fun saveSttSettings(engine: String, language: String) { + stateHandle.update { + copy(sttEngine = engine, sttLanguage = language, showSttDetailDialog = false) + } + stateHandle.scope.launch { + settingsDataStore.setSttEngine(engine) + settingsDataStore.setSttLanguage(language) + } + } + + fun showTtsDetailDialog() { + stateHandle.update { copy(showTtsDetailDialog = true) } + } + + fun dismissTtsDetailDialog() { + stateHandle.update { copy(showTtsDetailDialog = false) } + } + + fun saveTtsSettings(engine: String, voice: String, rate: Float, pitch: Float, deviceVoiceName: String, caching: Boolean, source: String) { + stateHandle.update { + copy( + ttsEngine = engine, + ttsVoice = voice, + ttsCaching = caching, + ttsSource = source, + showTtsDetailDialog = false, + ) + } + stateHandle.scope.launch { + settingsDataStore.setTtsSource(source) + settingsDataStore.setTtsSpeechRate(rate) + settingsDataStore.setTtsPitch(pitch) + settingsDataStore.setTtsVoiceName(deviceVoiceName) + settingsDataStore.setTtsEngine(engine) + settingsDataStore.setTtsVoice(voice) + settingsDataStore.setTtsCaching(caching) + } + } + + /** + * Plays audio bytes via MediaPlayer. Attaches listeners before calling prepare() + * and wraps prepare/start in try-catch to release on failure (fixes resource leak). + */ + private fun playAudioBytes(audioBytes: ByteArray, isPreview: Boolean = false) { + try { + // Stop any currently playing audio + currentMediaPlayer?.release() + currentMediaPlayer = null + + // Write bytes to a temporary file + val tempFile = java.io.File.createTempFile("voice_test", ".mp3", context.cacheDir) + tempFile.deleteOnExit() + tempFile.writeBytes(audioBytes) + + // Create and configure MediaPlayer + val mediaPlayer = MediaPlayer() + // Attach listeners BEFORE calling prepare() to avoid resource leak + mediaPlayer.setDataSource(tempFile.absolutePath) + mediaPlayer.setOnCompletionListener { + it.release() + currentMediaPlayer = null + tempFile.delete() + if (isPreview) { + stateHandle.update { copy(isTtsPreviewPlaying = false) } + } + } + mediaPlayer.setOnErrorListener { mp, what, extra -> + Log.e("SettingsViewModel", "MediaPlayer error: what=$what, extra=$extra") + mp.release() + currentMediaPlayer = null + tempFile.delete() + stateHandle.update { + copy(error = "Audio playback failed", isTtsPreviewPlaying = false) + } + true + } + try { + mediaPlayer.prepare() + mediaPlayer.start() + } catch (e: Exception) { + // Release MediaPlayer if prepare() or start() throws + mediaPlayer.release() + tempFile.delete() + throw e + } + + currentMediaPlayer = mediaPlayer + } catch (e: Exception) { + Log.e("SettingsViewModel", "Failed to play audio", e) + stateHandle.update { + copy( + error = "Audio playback failed: ${e.localizedMessage}", + isTtsPreviewPlaying = false, + ) + } + } + } + + /** + * Releases TTS engine and media player resources. Call from ViewModel.onCleared(). + */ + fun release() { + currentMediaPlayer?.release() + currentMediaPlayer = null + deviceTtsEngine?.shutdown() + deviceTtsEngine = null + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt new file mode 100644 index 0000000..75c7142 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt @@ -0,0 +1,139 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle +import kotlinx.coroutines.launch + +/** + * Handles 2FA setup, backup codes, and disable flow. + */ +class TwoFactorSecurityDelegate( + private val stateHandle: SettingsStateHandle, + private val authRepository: AuthRepository, +) { + + fun toggleTwoFactor() { + if (stateHandle.state.isTwoFactorEnabled) { + stateHandle.update { copy(showDisableTwoFactorDialog = true) } + } else { + stateHandle.scope.launch { + stateHandle.update { copy(isTwoFactorLoading = true) } + when (val result = authRepository.enableTwoFactor()) { + is Result.Success -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + showTwoFactorSetupDialog = true, + twoFactorOtpauthUrl = result.data.otpauthUrl, + backupCodes = result.data.backupCodes, + ) + } + } + is Result.Error -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + error = result.message ?: "Failed to enable two-factor authentication", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + } + + fun confirmEnableTwoFactor(code: String) { + stateHandle.scope.launch { + stateHandle.update { copy(isTwoFactorLoading = true) } + when (val result = authRepository.confirmTwoFactor(code)) { + is Result.Success -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + isTwoFactorEnabled = true, + showTwoFactorSetupDialog = false, + twoFactorOtpauthUrl = null, + showBackupCodesDialog = true, + backupCodes = result.data.backupCodes, + ) + } + } + is Result.Error -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + error = result.message ?: "Invalid verification code", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun confirmDisableTwoFactor(code: String) { + stateHandle.scope.launch { + stateHandle.update { copy(isTwoFactorLoading = true) } + when (val result = authRepository.disableTwoFactor(code)) { + is Result.Success -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + isTwoFactorEnabled = false, + showDisableTwoFactorDialog = false, + ) + } + } + is Result.Error -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + error = result.message ?: "Failed to disable two-factor authentication", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + fun dismissTwoFactorSetupDialog() { + stateHandle.update { copy(showTwoFactorSetupDialog = false, twoFactorOtpauthUrl = null) } + } + + fun dismissDisableTwoFactorDialog() { + stateHandle.update { copy(showDisableTwoFactorDialog = false) } + } + + fun dismissBackupCodesDialog() { + stateHandle.update { copy(showBackupCodesDialog = false, backupCodes = emptyList()) } + } + + fun viewBackupCodes() { + stateHandle.scope.launch { + stateHandle.update { copy(isTwoFactorLoading = true) } + when (val result = authRepository.regenerateBackupCodes()) { + is Result.Success -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + showBackupCodesDialog = true, + backupCodes = result.data.backupCodes, + ) + } + } + is Result.Error -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + error = result.message ?: "Failed to retrieve backup codes", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml new file mode 100644 index 0000000..7b6354b --- /dev/null +++ b/feature/settings/src/main/res/values/strings.xml @@ -0,0 +1,358 @@ + + + + Add + Cancel + Clear + Confirm + Creating… + Create + Delete + Done + Retry + Revoke All + Save + Sign out + Upload + Uploading… + Verify + Verifying… + + + Add memory + Add server + Back + Change avatar + Choose image + Collapse tools + Copy API key + Copy link + Create API key + Current avatar + Delete + Delete API key + Delete memory + Delete preset + Delete server + Delete shared link + Edit + Edit memory + Edit server + Expand tools + Make private + Make public + Preview voice + QR code for authenticator app + Reinitialize + Avatar preview + Selected + Selected avatar + Stop voice preview + Test selected TTS voice + Toggle auto-read responses + Toggle auto-send after speech + Toggle memories + Select TTS voice. Currently %1$s + User avatar + View all tools + View tools + + + About + Account + Advanced + Appearance + Balance + Chat Preferences + Conversations + Danger Zone + Data Management + General + Language + Layout + MCP Servers + Memories + Personalization + Presets + Profile + Security + Speech + Tablet + + + Account + API Keys + Chat + Commands + Data + General + Memories + MCP Servers + Presets + Settings + Shared Links + + + General + Chat + Account + Data + + + System default + Light + Dark + + + Language + Personalization + Enabled + Disabled + Fork Behavior + Commands + %1$d enabled + API Keys + Manage API keys for programmatic access + Presets + Manage conversation presets + + + App version + 1.0.0 + Server + Not configured + + + Sidebar swipe gesture + Swipe from the left edge to open the sidebar on tablet layouts + + + Token Credits + Available balance + Token balance: %1$s credits + + + Chat layout + Thread + Two-sided chat + All messages aligned left with avatar and name + User messages on right, agent on left + Show bubbles + Add rounded bubble backgrounds to messages + Show avatars + Display user and agent avatars next to messages + Font size + Small + Medium + Large + LaTeX renderer + KaTeX (rendered) + Full LaTeX rendering via WebView + Plain text (fast) + Shows raw LaTeX source for better scroll performance + Auto-scroll during streaming + Automatically scroll to new content while a response is being generated + Show thinking blocks + Display model reasoning steps when available + Show image descriptions + Display AI-generated image prompts below images + Dismiss keyboard after sending + Automatically hide the keyboard after sending a message + + + Two-Factor Authentication + Enable Two-Factor Authentication + Disable Two-Factor Authentication + View Backup Codes + + + Enable Two-Factor Authentication + Disable Two-Factor Authentication + Scan the QR code below with your authenticator app (e.g. Google Authenticator, Authy), then enter the verification code. + Enter your authenticator code to disable 2FA. + Verification code + + + Backup Codes + Save these backup codes in a safe place. Each code can only be used once. + + + Delete account + Sign out + Are you sure you want to sign out? + Delete account + This action cannot be undone. All your data will be permanently deleted. + + + Clear Cache + This will clear cached images and temporary data. Your account data will not be affected. + + + Revoke All API Keys + This will revoke all your stored API keys. You will need to re-enter them to continue using external services. + + + Clear All Conversations + Clearing… + Archived Conversations + Export All Data + Shared Links + Clear Cache + Revoke All API Keys + Revoking… + Clear All Conversations + This will permanently delete all your conversations. This action cannot be undone. + Clear All + Export is coming soon + Could not load settings + + + Auto-send after speech + Automatically send the message after speech-to-text finishes + Auto-read responses + Automatically read AI responses aloud using text-to-speech + TTS Voice + Test Voice + No server TTS voices available. Speech features may not be configured on the server. + Using device text-to-speech engine. Configure voice and speed in Text-to-Speech Settings below. + Speech-to-Text Settings + Text-to-Speech Settings + + + Engine + Language + On Device + Default + Whisper uses server-side transcription. Your server must have STT enabled. + Uses your device\'s built-in speech recognizer. Works offline on supported devices. + Uses Google\'s speech recognizer. Requires the Google app to be installed. + Uses server transcription (Whisper) when available, otherwise falls back to on-device recognition. + Auto-detect + + + Source + Device (built-in) + Server + Voice + Engine + System default + Speech Rate: %1$.1fx + Pitch: %1$.1f + Cache audio + This is a preview of the selected voice. + Stop Preview + Preview Voice + + + Enable Memories + Allow the AI to remember information across conversations + No memories saved yet + Tap the + button to add a memory + Add Memory + Edit Memory + Delete Memory + Are you sure you want to delete the memory \"%1$s\"? + Key + Value + + + Add MCP Server + Edit MCP Server + MCP is not available on this server + No MCP servers configured + Tap the + button to add a server + Name + Description + Optional + URL + Type + SSE + Streamable HTTP + Authentication + None + API Key + OAuth + Key Type + Bearer + Basic + Custom + Header Name + X-API-Key + API Key + Client ID + Client Secret + Authorization URL + Token URL + Scope + read execute + Delete Server + Are you sure you want to delete \"%1$s\"? + Failed to load servers + %1$d tool%2$s + %1$d tool%2$s available + All MCP Tools + Tools: %1$s + No tools available + Server: %1$s + + + Unknown + + + Visible messages + Only the direct path of messages to the selected message + Include branches + Direct path plus sibling messages at each level + All to target level + All messages and branches up to the target message level (default) + + + Search languages + + + Enable personalization + About you + What should the AI know about you for context? + How should AI respond? + Preferred tone, format, or style for responses + + + No commands available + + + Update Avatar + Tap the camera icon to choose an image + + + API keys not available + This feature is not supported on your server version. + No API keys + Create an API key to access the API programmatically. + Delete API Key + Are you sure you want to delete the API key \"%1$s\"? This action cannot be undone. + Created: %1$s + + + Create API Key + Give your API key a descriptive name to help identify its purpose. + Key name + e.g. My App + API Key Created + Your API key has been created. Copy it now -- you won\'t be able to see it again. + Copied to clipboard + + + No presets + Save presets from the chat screen to manage them here + Delete preset + Are you sure you want to delete \"%1$s\"? + + + No shared links + Share a conversation to see it here + Delete Shared Link + This will remove the shared link for \"%1$s\". Anyone with the link will no longer be able to access it. + Public + Private + Failed to load memories + diff --git a/feature/settings/src/test/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt new file mode 100644 index 0000000..b7b0b80 --- /dev/null +++ b/feature/settings/src/test/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt @@ -0,0 +1,366 @@ +package com.librechat.android.feature.settings.viewmodel + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.librechat.android.core.common.ChatLayoutConstants +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.ChatFontSize +import com.librechat.android.core.data.datastore.LatexRenderer +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.datastore.ThemeDataStore +import com.librechat.android.core.data.datastore.ThemeMode +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.BalanceRepository +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.data.repository.KeyRepository +import com.librechat.android.core.data.repository.McpRepository +import com.librechat.android.core.data.repository.MemoryRepository +import com.librechat.android.core.data.repository.ShareRepository +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.core.data.repository.UserRepository +import com.librechat.android.core.model.User +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private val context = mockk(relaxed = true) + private val userRepository = mockk(relaxed = true) + private val authRepository = mockk(relaxed = true) + private val conversationRepository = mockk(relaxed = true) + private val themeDataStore = mockk(relaxed = true) + private val serverDataStore = mockk(relaxed = true) + private val settingsDataStore = mockk(relaxed = true) + private val mcpRepository = mockk(relaxed = true) + private val memoryRepository = mockk(relaxed = true) + private val speechRepository = mockk(relaxed = true) + private val balanceRepository = mockk(relaxed = true) + private val shareRepository = mockk(relaxed = true) + private val keyRepository = mockk(relaxed = true) + + private val testUser = User( + email = "test@example.com", + name = "Test User", + username = "testuser", + avatar = "https://example.com/avatar.png", + twoFactorEnabled = false, + ) + + private lateinit var viewModel: SettingsViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + // Setup DataStore flows + every { themeDataStore.themeMode } returns MutableStateFlow(ThemeMode.SYSTEM) + every { serverDataStore.currentUrlFlow } returns MutableStateFlow("https://chat.example.com") + every { settingsDataStore.chatFontSize } returns MutableStateFlow(ChatFontSize.MEDIUM) + every { settingsDataStore.autoScrollEnabled } returns MutableStateFlow(true) + every { settingsDataStore.showThinkingBlocks } returns MutableStateFlow(true) + every { settingsDataStore.autoReadEnabled } returns MutableStateFlow(false) + every { settingsDataStore.selectedVoiceId } returns MutableStateFlow(null) + every { settingsDataStore.showImageDescriptions } returns MutableStateFlow(false) + every { settingsDataStore.dismissKeyboardOnSend } returns MutableStateFlow(false) + every { settingsDataStore.ttsSource } returns MutableStateFlow("device") + every { settingsDataStore.ttsSpeechRate } returns MutableStateFlow(1.0f) + every { settingsDataStore.ttsPitch } returns MutableStateFlow(1.0f) + every { settingsDataStore.ttsVoiceName } returns MutableStateFlow("") + every { settingsDataStore.ttsEngine } returns MutableStateFlow("") + every { settingsDataStore.ttsVoice } returns MutableStateFlow("") + every { settingsDataStore.ttsCaching } returns MutableStateFlow(true) + every { settingsDataStore.tabletSidebarGestureEnabled } returns MutableStateFlow(true) + every { settingsDataStore.autoSendAfterStt } returns MutableStateFlow(false) + every { settingsDataStore.sttEngine } returns MutableStateFlow("") + every { settingsDataStore.sttLanguage } returns MutableStateFlow("") + every { settingsDataStore.chatLayoutStyle } returns MutableStateFlow(ChatLayoutConstants.THREAD) + every { settingsDataStore.showAvatars } returns MutableStateFlow(true) + every { settingsDataStore.showBubbles } returns MutableStateFlow(false) + every { settingsDataStore.latexRenderer } returns MutableStateFlow(LatexRenderer.KATEX) + every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet()) + + // Setup default API responses + coEvery { userRepository.getUser() } returns Result.Success(testUser) + coEvery { mcpRepository.listServers() } returns Result.Success(emptyList()) + coEvery { mcpRepository.getConnectionStatus() } returns Result.Success(emptyMap()) + coEvery { memoryRepository.getMemories() } returns Result.Success(emptyList()) + coEvery { speechRepository.getVoices() } returns Result.Success(emptyList()) + coEvery { balanceRepository.getBalance() } returns Result.Error(message = "Not available") + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = SettingsViewModel( + context = context, + userRepository = userRepository, + authRepository = authRepository, + conversationRepository = conversationRepository, + themeDataStore = themeDataStore, + serverDataStore = serverDataStore, + settingsDataStore = settingsDataStore, + mcpRepository = mcpRepository, + memoryRepository = memoryRepository, + speechRepository = speechRepository, + balanceRepository = balanceRepository, + shareRepository = shareRepository, + keyRepository = keyRepository, + ) + + @Test + fun `initial state loads user profile`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.user).isNotNull() + assertThat(state.user?.name).isEqualTo("Test User") + assertThat(state.user?.email).isEqualTo("test@example.com") + assertThat(state.isLoading).isFalse() + } + + @Test + fun `user load failure shows error`() = runTest { + coEvery { userRepository.getUser() } returns Result.Error(message = "Network error") + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state.user).isNull() + assertThat(state.error).isEqualTo("Network error") + assertThat(state.isLoading).isFalse() + } + + @Test + fun `logout calls authRepository and sets isLoggedOut`() = runTest { + coEvery { authRepository.logout() } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.logout() + advanceUntilIdle() + + coVerify { authRepository.logout() } + assertThat(viewModel.uiState.value.isLoggedOut).isTrue() + } + + @Test + fun `deleteAccount calls userRepository then authRepository`() = runTest { + coEvery { userRepository.deleteUser() } returns Result.Success(Unit) + coEvery { authRepository.logout() } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.deleteAccount() + advanceUntilIdle() + + coVerify(ordering = io.mockk.Ordering.ORDERED) { + userRepository.deleteUser() + authRepository.logout() + } + assertThat(viewModel.uiState.value.isAccountDeleted).isTrue() + } + + @Test + fun `deleteAccount failure shows error`() = runTest { + coEvery { userRepository.deleteUser() } returns Result.Error(message = "Server error") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.deleteAccount() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Server error") + assertThat(viewModel.uiState.value.isAccountDeleted).isFalse() + } + + @Test + fun `setThemeMode calls themeDataStore`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.setThemeMode(ThemeMode.DARK) + advanceUntilIdle() + + coVerify { themeDataStore.setThemeMode(ThemeMode.DARK) } + } + + @Test + fun `clearAllChats calls conversationRepository deleteAll`() = runTest { + coEvery { conversationRepository.deleteAll() } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.clearAllChats() + advanceUntilIdle() + + coVerify { conversationRepository.deleteAll() } + assertThat(viewModel.uiState.value.isClearing).isFalse() + } + + @Test + fun `clearAllChats failure shows error`() = runTest { + coEvery { conversationRepository.deleteAll() } returns + Result.Error(message = "Failed to clear") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.clearAllChats() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Failed to clear") + } + + @Test + fun `dismissError clears error state`() = runTest { + coEvery { userRepository.getUser() } returns Result.Error(message = "Error") + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isNotNull() + + viewModel.dismissError() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isNull() + } + + @Test + fun `twoFactorEnabled state reflects user profile`() = runTest { + val userWith2FA = testUser.copy(twoFactorEnabled = true) + coEvery { userRepository.getUser() } returns Result.Success(userWith2FA) + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.isTwoFactorEnabled).isTrue() + } + + @Test + fun `revokeAllKeys calls keyRepository`() = runTest { + coEvery { keyRepository.deleteAllKeys() } returns Result.Success(Unit) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.revokeAllKeys() + advanceUntilIdle() + + coVerify { keyRepository.deleteAllKeys() } + assertThat(viewModel.uiState.value.isKeyRevoking).isFalse() + } + + @Test + fun `revokeAllKeys failure shows error`() = runTest { + coEvery { keyRepository.deleteAllKeys() } returns + Result.Error(message = "Failed to revoke") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.revokeAllKeys() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.error).isEqualTo("Failed to revoke") + } + + @Test + fun `setLanguage updates language and dismisses dialog`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.showLanguageDialog() + advanceUntilIdle() + assertThat(viewModel.uiState.value.showLanguageDialog).isTrue() + + viewModel.setLanguage("fr") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.selectedLanguage).isEqualTo("fr") + assertThat(viewModel.uiState.value.showLanguageDialog).isFalse() + } + + @Test + fun `setForkMode updates mode and dismisses dialog`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.showForkSettingsDialog() + advanceUntilIdle() + assertThat(viewModel.uiState.value.showForkSettingsDialog).isTrue() + + viewModel.setForkMode("allBranches") + advanceUntilIdle() + + assertThat(viewModel.uiState.value.forkMode).isEqualTo("allBranches") + assertThat(viewModel.uiState.value.showForkSettingsDialog).isFalse() + } + + @Test + fun `toggleCommand updates command enabled state`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.toggleCommand("help", false) + advanceUntilIdle() + + val helpCmd = viewModel.uiState.value.commands.find { it.name == "help" } + assertThat(helpCmd?.enabled).isFalse() + } + + @Test + fun `retry reloads user profile`() = runTest { + coEvery { userRepository.getUser() } returns Result.Success(testUser) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.retry() + advanceUntilIdle() + + // Called twice: once in init, once in retry + coVerify(exactly = 2) { userRepository.getUser() } + } + + @Test + fun `exportAllData shows coming soon flag`() = runTest { + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.exportAllData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.showExportComingSoon).isTrue() + + viewModel.dismissExportComingSoon() + advanceUntilIdle() + + assertThat(viewModel.uiState.value.showExportComingSoon).isFalse() + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fe8ddf5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +org.gradle.parallel=true +org.gradle.caching=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..7e39fae --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,204 @@ +[versions] +# Core Android +agp = "8.7.3" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" +compose-bom = "2024.12.01" +material3 = "1.3.1" +activity-compose = "1.9.3" +navigation-compose = "2.8.5" +lifecycle = "2.8.7" + +# DI +hilt = "2.53.1" +hilt-navigation-compose = "1.2.0" + +# Network +ktor = "3.0.3" +kotlinx-serialization = "1.7.3" + +# Local Data +room = "2.6.1" +datastore = "1.1.1" +security-crypto = "1.1.0-alpha06" + +# Async +coroutines = "1.9.0" + +# Paging +paging = "3.3.5" + +# Browser +browser = "1.8.0" + +# Markdown +markdown-renderer = "0.27.0" + +# Media +media3 = "1.2.0" + +# Image Loading +coil = "2.7.0" + +# Logging +timber = "5.0.1" + +# Testing +junit = "4.13.2" +turbine = "1.2.0" +mockk = "1.13.13" +truth = "1.4.4" +espresso = "3.6.1" + +# Code Quality +detekt = "1.23.7" +ktlint = "12.1.2" + +# Fuzzy Search +fuzzywuzzy = "1.4.0" + +# YAML +snakeyaml-engine = "2.8" + +# Build +desugar = "2.1.4" + +[libraries] +# Compose BOM +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material3-wsc = { group = "androidx.compose.material3", name = "material3-window-size-class" } +compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +compose-animation = { group = "androidx.compose.animation", name = "animation" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } + +# Activity / Navigation +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } + +# Lifecycle +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } + +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } + +# Ktor +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } + +# Kotlinx Serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +# Room +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +# DataStore +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" } + +# Coroutines +coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } + +# Paging +paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } + +# Browser +browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } + +# Markdown +markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } +markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3", version.ref = "markdown-renderer" } + +# Media +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" } + +# Image Loading +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +# Logging +timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } + +# Gradle Plugins (for build-logic) +android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } +kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +compose-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "symbol-processing-gradle-plugin", version.ref = "ksp" } +room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" } + +# Fuzzy Search +fuzzywuzzy = { group = "me.xdrop", name = "fuzzywuzzy", version.ref = "fuzzywuzzy" } + +# YAML +snakeyaml-engine = { group = "org.snakeyaml", name = "snakeyaml-engine", version.ref = "snakeyaml-engine" } + +# Desugar +desugar-jdk = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar" } + +# Testing +junit = { group = "junit", name = "junit", version.ref = "junit" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } + +[bundles] +compose = [ + "compose-ui", + "compose-ui-tooling-preview", + "compose-material3", + "compose-material-icons", + "compose-animation", + "compose-foundation", +] +ktor = [ + "ktor-client-core", + "ktor-client-okhttp", + "ktor-client-content-negotiation", + "ktor-serialization-kotlinx-json", + "ktor-client-logging", +] +lifecycle = [ + "lifecycle-runtime-compose", + "lifecycle-viewmodel-compose", +] +room = [ + "room-runtime", + "room-ktx", +] +testing = [ + "junit", + "coroutines-test", + "turbine", + "mockk", + "truth", +] + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +room = { id = "androidx.room", version.ref = "room" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e060a1f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,32 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "LibreChat-Android" + +include(":app") +include(":core:common") +include(":core:model") +include(":core:network") +include(":core:data") +include(":core:ui") +include(":feature:auth") +include(":feature:chat") +include(":feature:conversations") +include(":feature:settings") +include(":feature:agents") +include(":feature:files")