From abde86ca4ed70554582f891bd5cf30d6ca4a2b95 Mon Sep 17 00:00:00 2001 From: Garfie Date: Sun, 26 Apr 2026 17:41:08 -0600 Subject: [PATCH] chore(sync): sync to upstream LibreChat v0.8.5 Targets upstream tag v0.8.5 (commit 9ccc8d9be). Bumps the supported backend version constant, advances the upstream submodule pin, and adds wire-protocol parity for new server features. Wire protocol - Add ContentType.SUMMARY enum value and flat sibling fields on MessageContentPart (content as JsonElement to absorb array/string/ legacy-text variants per BaseClient.getSummaryText). - Add Anthropic effort.xhigh dropdown value, ThinkingDisplay (Opus 4.7 reasoning visibility), and Message.contextMeta pass-through. - Add allowAccountDeletion to StartupConfig (defaults to true to preserve older-server behavior). - Drop unused instanceProjectId from StartupConfig and from ConfigRepositoryImpl.isValidLibreChatConfig. New endpoints / features - Favorites: UserFavorite DTO + FavoritesApi + FavoritesRepository, Settings -> Favorites sub-screen (list/unpin), pin icons in chat model selector. Repository owns the canonical list as a StateFlow guarded by Mutex.withLock so chat-side toggles and settings-side bulk-unpin can't drift apart and concurrent writes can't lose each other. - Prompt-group usage telemetry (POST /api/prompts/groups/:id/use, fire-and-forget on prompt insertion). - Summarize SSE events (on_summarize_complete -> StreamEvent.ContextSummary) and a collapsible "Summarized earlier messages" card rendered from the persisted SUMMARY content part. Forward-compat else-arm preserved on both LangGraph and legacy SSE mappers. Backward compatibility - BackendVersion.isCompatibleOrNewer helper added; documented fail-open contract for callers that have already null-checked detectedBackendVersion. - New VERSION_GATES.md catalog at repo root tracks every code path that branches on server version. Initial entries: - isCollaborative agent toggle (hidden on v0.8.5+; full-ACL UX deferred) - xhigh effort dropdown value (filtered out on ` — new default since v0.8.5. +2. `content: string` — intermediate variant; rare but emitted by some code paths. +3. No `content`; `text: "..."` at the top level — legacy fallback from pre-v0.8.5 + summarization or test fixtures. + +Mobile's `MessageContentPart.content` is typed `JsonElement?` to absorb variants 1/2, +and falls back to the existing `text: String?` field for variant 3. + ### File - file_id, filename, filepath, type, bytes, source - user, conversationId, messageId @@ -226,6 +284,20 @@ GET /api/banner 3. Receive events: message, step, created, attachment, final, sync, error 4. On disconnect: reconnect with `?resume=true` for sync event +### Agent-library event names (v0.8.5) +`on_message_delta`, `on_reasoning_delta`, `on_run_step`, `on_run_step_delta`, +`on_run_step_completed`, `on_chat_model_end`, `on_agent_update`, `attachment`, and — +added in v0.8.5 — `on_summarize_start`, `on_summarize_delta`, `on_summarize_complete`. + +`on_summarize_complete` payload nests the finished summary block under a `summary` key +(distinct from the message-persistence SUMMARY content part described in +`MessageContentPart`): +``` +{"id":"...","agentId":"...","summary":{"type":"summary","content":[{"type":"text","text":"..."}],...}} +``` +Mobile only renders the compacted summary once it is persisted to the final message as +a SUMMARY content part; the delta/lifecycle events are surfaced as status only. + ### SSE Event Format ``` event: message diff --git a/README.md b/README.md index e0f4fba..c17248a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # LibreChat Mobile -[![LibreChat](https://img.shields.io/badge/LibreChat-v0.8.4-blue)](https://github.com/danny-avila/LibreChat/releases/tag/v0.8.4) +[![LibreChat](https://img.shields.io/badge/LibreChat-v0.8.4_–_v0.8.5-blue)](https://github.com/danny-avila/LibreChat/releases/tag/v0.8.5) A third-party native mobile client for [LibreChat](https://www.librechat.ai/) (Android & iOS). Not affiliated with the official LibreChat project — this is an independent app that connects to any self-hosted LibreChat server, no backend modifications required. +> **Backend compatibility:** Tested against LibreChat **v0.8.4 – v0.8.5**. Older releases may work but are not guaranteed; newer releases are supported on a best-effort basis until the next sync. + ## 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 diff --git a/UPSTREAM_VERSION b/UPSTREAM_VERSION index f7c056a..b8e48ab 100644 --- a/UPSTREAM_VERSION +++ b/UPSTREAM_VERSION @@ -1,5 +1,5 @@ # Upstream LibreChat version this mobile build tracks. # Updated by the sync-upstream skill. Do not edit manually. -tag=v0.8.4 -commit=0736ff26686e911c9785a237c63a799db1813f0b -date=2026-03-26 +tag=v0.8.5 +commit=9ccc8d9bef407f9a769f07a3756ec4b95ac13f80 +date=2026-04-23 diff --git a/VERSION_GATES.md b/VERSION_GATES.md new file mode 100644 index 0000000..99d81b7 --- /dev/null +++ b/VERSION_GATES.md @@ -0,0 +1,32 @@ +# Version Gates + +LibreChat Mobile supports a range of backend server versions. This file catalogs every +place in the codebase where behavior branches based on the detected server version so +the compatibility surface is auditable. When the minimum supported server version is +raised, entries here can be simplified or removed. + +The canonical API for version comparisons lives in +`core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt`: + +- `BackendVersion.parse(version)` — parse a loose semver string (`"v0.8.5"`, `"0.8"`, …). +- `BackendVersion.isCompatible(supported, actual)` — same-`major.minor` check (patch ignored). +- `BackendVersion.isCompatibleOrNewer(actual, minimum)` — `actual ≥ minimum` by `(major, minor)`. +- `BackendVersion.extractVersionFromFooter(footer)` — fallback parse from `customFooter`. + +The detected server version is exposed via `ConfigRepository.detectedBackendVersion` +(populated once `checkBackendVersion()` runs on app startup / server-switch). + +## Catalog + +| Feature | Gated since | Behavior on older | Behavior on newer | File:line | Safe to remove when min supported server ≥ | +|---|---|---|---|---|---| +| `isCollaborative` agent toggle | v0.8.5 (2026-04-23) | Toggle visible; mobile sends `isCollaborative` + `projectIds` to server | Toggle hidden; inline hint "Access permissions are managed server-side in this version" rendered instead; fields not sent | `feature/agents/.../components/AgentSharingSection.kt` + `feature/agents/.../viewmodel/AgentEditorViewModel.kt` (`observeServerVersion`, `save`) | v0.8.5 | +| `xhigh` reasoning-effort dropdown value | v0.8.5 (2026-04-25) | `xhigh` filtered out of `reasoning_effort` and `effort` dropdowns (older Anthropic/Bedrock/OpenAI schemas reject the unknown enum) | `xhigh` shown alongside `low/medium/high/max` | `core/ui/.../components/EndpointParameterRegistry.kt` (`getDefinitions(xhighEffortSupported)`) + `feature/chat/.../viewmodel/ChatViewModel.kt` (xhigh observer in `init`) | v0.8.5 | + +## Guidelines for adding a new gate + +1. Call `BackendVersion.isCompatible(...)` or `BackendVersion.isCompatibleOrNewer(...)` — never parse versions ad hoc. +2. Default to **older-server behavior** when the version is unknown (`detectedBackendVersion == null`). The server may not advertise its version; failing open avoids hiding features from self-hosted installs with stripped customFooters. +3. Add a row to the table above. Include file + line anchors and the concrete minimum version at which the gate becomes dead code. +4. If the gated field is a request DTO field, omit it (send `null`) rather than sending a value the server will silently drop — unless you can verify round-trip parity. Silent drops lead to UI state that disagrees with server state. +5. Never gate on patch versions; the version detection is best-effort and the compat check is minor-version-only. diff --git a/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt b/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt index b3acbdc..27a6b4f 100644 --- a/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt +++ b/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt @@ -18,6 +18,7 @@ import com.garfiec.librechat.core.data.repository.ChatRepository import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.DraftRepository +import com.garfiec.librechat.core.data.repository.FavoritesRepository import com.garfiec.librechat.core.data.repository.FileRepository import com.garfiec.librechat.core.data.repository.KeyRepository import com.garfiec.librechat.core.data.repository.McpRepository @@ -42,6 +43,7 @@ import com.garfiec.librechat.core.network.api.BannerApi import com.garfiec.librechat.core.network.api.ChatApi import com.garfiec.librechat.core.network.api.ConfigApi import com.garfiec.librechat.core.network.api.ConversationsApi +import com.garfiec.librechat.core.network.api.FavoritesApi import com.garfiec.librechat.core.network.api.FilesApi import com.garfiec.librechat.core.network.api.FilesExtApi import com.garfiec.librechat.core.network.api.KeysApi @@ -125,6 +127,7 @@ class KoinGraphVerificationTest { ChatApi::class, ConfigApi::class, ConversationsApi::class, + FavoritesApi::class, FilesApi::class, FilesExtApi::class, KeysApi::class, @@ -152,6 +155,7 @@ class KoinGraphVerificationTest { ConfigRepository::class, ConversationRepository::class, DraftRepository::class, + FavoritesRepository::class, FileRepository::class, KeyRepository::class, McpRepository::class, diff --git a/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt index 6723fb3..cc2ac19 100644 --- a/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt +++ b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/BackendVersion.kt @@ -18,7 +18,7 @@ object BackendVersion { * 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.4" + const val SUPPORTED_BACKEND_VERSION = "0.8.5" /** * Represents a parsed semantic version (major.minor.patch). @@ -60,6 +60,37 @@ object BackendVersion { supportedVersion.minor == actualVersion.minor } + /** + * Checks whether [actual] is greater than or equal to [minimum] (feature-gate check). + * + * Use this when branching on whether a backend feature was introduced in a + * specific version. Patch differences are ignored. Returns true when [actual] + * cannot be parsed (fail-open: assume feature is present). Returns false when + * [minimum] cannot be parsed (degenerate threshold). + * + * **Contract for callers:** null-check or explicitly handle the unknown-version + * case before invoking. The fail-open default here is intentional because in + * practice this helper is called only after `ConfigRepositoryImpl.checkBackendVersion()` + * has persisted either a parsed-valid version string or an explicit `null` to + * `ConfigRepository.detectedBackendVersion` — garbage never reaches this helper. + * This is a deliberate divergence from the "default to older-server behavior on + * unknown version" guideline in `VERSION_GATES.md` §Guidelines #2: that guideline + * is the callsite rule, and this helper only runs once the callsite has resolved + * the unknown-version case upstream. + * + * @param actual The version detected from the server (e.g., "0.8.5"). + * @param minimum The minimum version at which the gated feature appears (e.g., "0.8.5"). + * @return true if [actual] ≥ [minimum] by (major, minor), false otherwise. + */ + fun isCompatibleOrNewer(actual: String, minimum: String): Boolean { + val actualVersion = parse(actual) ?: return true + val minimumVersion = parse(minimum) ?: return false + if (actualVersion.major != minimumVersion.major) { + return actualVersion.major > minimumVersion.major + } + return actualVersion.minor >= minimumVersion.minor + } + /** * Extracts a version string from a LibreChat customFooter value. * The default footer format is: `[LibreChat vX.Y.Z](https://librechat.ai) - ...` diff --git a/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/result/ApiException.kt b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/result/ApiException.kt index f13e33b..6dcbccf 100644 --- a/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/result/ApiException.kt +++ b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/result/ApiException.kt @@ -8,4 +8,5 @@ class ApiException( val statusCode: Int, override val message: String, val isBanned: Boolean = false, -) : Exception(message) + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt index 175217f..8e4197a 100644 --- a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/di/DataModuleVerificationTest.kt @@ -12,6 +12,7 @@ import com.garfiec.librechat.core.network.api.BannerApi import com.garfiec.librechat.core.network.api.ChatApi import com.garfiec.librechat.core.network.api.ConfigApi import com.garfiec.librechat.core.network.api.ConversationsApi +import com.garfiec.librechat.core.network.api.FavoritesApi import com.garfiec.librechat.core.network.api.FilesApi import com.garfiec.librechat.core.network.api.FilesExtApi import com.garfiec.librechat.core.network.api.KeysApi @@ -53,6 +54,7 @@ class DataModuleVerificationTest { ChatApi::class, ConversationsApi::class, MessagesApi::class, + FavoritesApi::class, FilesApi::class, FilesExtApi::class, AgentsApi::class, diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt index b3c6959..cfd8e70 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt @@ -25,6 +25,8 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.ConversationRepositoryImpl import com.garfiec.librechat.core.data.repository.DraftRepository import com.garfiec.librechat.core.data.repository.DraftRepositoryImpl +import com.garfiec.librechat.core.data.repository.FavoritesRepository +import com.garfiec.librechat.core.data.repository.FavoritesRepositoryImpl import com.garfiec.librechat.core.data.repository.FileRepository import com.garfiec.librechat.core.data.repository.FileRepositoryImpl import com.garfiec.librechat.core.data.repository.KeyRepository @@ -167,4 +169,5 @@ val dataModule = module { singleOf(::SpeechRepositoryImpl) bind SpeechRepository::class singleOf(::UserRepositoryImpl) bind UserRepository::class singleOf(::BannerRepositoryImpl) bind BannerRepository::class + singleOf(::FavoritesRepositoryImpl) bind FavoritesRepository::class } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt index 822ecc2..62a0360 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt @@ -21,6 +21,18 @@ interface ConfigRepository { val startupConfig: StateFlow val endpointConfigs: StateFlow> val availableModels: StateFlow>> + + /** + * The backend version detected from `/api/config` (via the `version` field + * or the `customFooter` pattern). `null` until [checkBackendVersion] runs + * or if the backend does not expose its version. + * + * UI code should consult [com.garfiec.librechat.core.common.BackendVersion.isCompatible] + * when branching on this value and consult `VERSION_GATES.md` at the repo root + * when adding new gates. + */ + val detectedBackendVersion: StateFlow + suspend fun validateServerUrl(url: String): Result suspend fun fetchStartupConfig(): Result suspend fun fetchEndpoints(): Result> diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt index e8ab03f..4ff10c2 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt @@ -29,6 +29,9 @@ class ConfigRepositoryImpl( private val _availableModels = MutableStateFlow>>(emptyMap()) override val availableModels: StateFlow>> = _availableModels.asStateFlow() + private val _detectedBackendVersion = MutableStateFlow(null) + override val detectedBackendVersion: StateFlow = _detectedBackendVersion.asStateFlow() + override suspend fun validateServerUrl(url: String): Result { return try { val config = configApi.getStartupConfig() @@ -57,11 +60,12 @@ class ConfigRepositoryImpl( /** * 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. + * `serverDomain` is a required field on LibreChat's /api/config and has + * defaulted to a non-blank value since v0.7; arbitrary JSON APIs will not + * populate it. */ private fun isValidLibreChatConfig(config: StartupConfig): Boolean { - return config.serverDomain.isNotBlank() || config.instanceProjectId != null + return config.serverDomain.isNotBlank() } override suspend fun fetchStartupConfig(): Result { @@ -163,6 +167,8 @@ class ConfigRepositoryImpl( // Strategy 2: Parse customFooter for version pattern ?: BackendVersion.extractVersionFromFooter(config?.customFooter) + _detectedBackendVersion.value = detectedVersion + if (detectedVersion != null) { Logger.d { "Backend version detected: $detectedVersion (supported: $supported)" } VersionCheckResult( diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/FavoritesRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/FavoritesRepository.kt new file mode 100644 index 0000000..b5cc6ad --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/FavoritesRepository.kt @@ -0,0 +1,32 @@ +package com.garfiec.librechat.core.data.repository + +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.model.UserFavorite +import kotlinx.coroutines.flow.StateFlow + +/** + * Single source of truth for the user's pinned favorites list. + * + * Both the chat-side pin toggles and the Settings → Favorites screen + * observe [favorites] and route writes through [setFavorites], so the + * two surfaces never drift apart and concurrent writes can't lose each + * other (the impl serializes them with a [kotlinx.coroutines.sync.Mutex]). + */ +interface FavoritesRepository { + /** + * Latest known favorites list. Empty until [refresh] or [setFavorites] + * has populated it. UI should observe this rather than caching its own copy. + */ + val favorites: StateFlow> + + /** Fetches the canonical list from the server and publishes it to [favorites]. */ + suspend fun refresh(): Result> + + /** + * Replaces the server-side favorites list with [list]. The new list is + * published to [favorites] optimistically before the network call returns; + * on error the cache is rolled back from the server. Concurrent calls are + * serialized so no write is silently lost. + */ + suspend fun setFavorites(list: List): Result> +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/FavoritesRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/FavoritesRepositoryImpl.kt new file mode 100644 index 0000000..e9e249f --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/FavoritesRepositoryImpl.kt @@ -0,0 +1,78 @@ +package com.garfiec.librechat.core.data.repository + +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.common.result.safeApiCall +import com.garfiec.librechat.core.model.FavoritesLimits +import com.garfiec.librechat.core.model.UserFavorite +import com.garfiec.librechat.core.network.api.FavoritesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class FavoritesRepositoryImpl( + private val favoritesApi: FavoritesApi, +) : FavoritesRepository { + + private val _favorites = MutableStateFlow>(emptyList()) + override val favorites: StateFlow> = _favorites.asStateFlow() + + /** + * Serializes write paths so two rapid pin toggles can't lose each other, + * and so an error-path rollback never overwrites a parallel successful + * write — both [setFavorites] and the rollback's refetch run inside this lock. + */ + private val writeMutex = Mutex() + + override suspend fun refresh(): Result> { + return when (val result = safeApiCall { favoritesApi.getFavorites() }) { + is Result.Success -> { + _favorites.value = result.data + result + } + else -> result + } + } + + override suspend fun setFavorites(list: List): Result> { + if (list.size > FavoritesLimits.MAX_FAVORITES) { + return Result.Error( + message = "Maximum ${FavoritesLimits.MAX_FAVORITES} favorites allowed.", + ) + } + for (fav in list) { + val fields = listOf(fav.agentId, fav.model, fav.endpoint, fav.spec) + if (fields.any { (it?.length ?: 0) > FavoritesLimits.MAX_STRING_LENGTH }) { + return Result.Error( + message = "A favorite field exceeds the ${FavoritesLimits.MAX_STRING_LENGTH} character limit.", + ) + } + } + + return writeMutex.withLock { + // Optimistic publish so callers' star icons toggle immediately. + // Snapshot the prior list inside the lock so a concurrent caller queued + // behind us starts from the post-write state, not the pre-optimistic one. + val previous = _favorites.value + _favorites.value = list + + when (val result = safeApiCall { favoritesApi.updateFavorites(list) }) { + is Result.Success -> { + _favorites.value = result.data + result + } + is Result.Error -> { + // Roll back via a fresh GET so the cache reflects the server's + // authoritative state rather than our stale optimistic value. + when (val refetch = safeApiCall { favoritesApi.getFavorites() }) { + is Result.Success -> _favorites.value = refetch.data + else -> _favorites.value = previous + } + result + } + is Result.Loading -> result + } + } + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt index 977aaa5..b3486c0 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt @@ -20,4 +20,10 @@ interface PromptRepository { suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Result suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result suspend fun getPromptsByGroupId(groupId: String): Result> + + /** + * Records that a prompt group was used (v0.8.5+ server-side analytics). + * Fire-and-forget — failures are swallowed since this is telemetry only. + */ + suspend fun recordPromptGroupUse(groupId: String): Result } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt index f2b5d5b..4fe28ab 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt @@ -74,4 +74,11 @@ class PromptRepositoryImpl( promptsApi.getPromptsByGroupId(groupId) } } + + override suspend fun recordPromptGroupUse(groupId: String): Result { + return safeApiCall { + promptsApi.recordPromptGroupUse(groupId) + Unit + } + } } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/ContentType.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/ContentType.kt index ca90070..e1605f6 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/ContentType.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/ContentType.kt @@ -32,6 +32,9 @@ enum class ContentType { @SerialName("agent_update") AGENT_UPDATE, + @SerialName("summary") + SUMMARY, + @SerialName("error") ERROR, } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt index 3e2e0a8..9cdcad3 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt @@ -27,6 +27,7 @@ data class Conversation( @SerialName("reasoning_effort") val reasoningEffort: String? = null, val effort: String? = null, @SerialName("thinkingLevel") val thinkingLevel: String? = null, + val thinkingDisplay: String? = null, val stop: List? = null, val iconURL: String? = null, val greeting: String? = null, diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Message.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Message.kt index 2ab527f..2df010d 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Message.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Message.kt @@ -29,6 +29,7 @@ data class Message( val feedback: Feedback? = null, @SerialName("thread_id") val threadId: String? = null, val metadata: JsonObject? = null, + val contextMeta: JsonObject? = null, val createdAt: String? = null, val updatedAt: String? = null, val title: String? = null, diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/StreamEvent.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/StreamEvent.kt index acb8781..b48524e 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/StreamEvent.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/StreamEvent.kt @@ -77,4 +77,19 @@ sealed interface StreamEvent { val messageId: String, val parentMessageId: String, ) : StreamEvent + + /** + * Emitted when the server compacts earlier turns of a long agent chat into + * a summary. The final summarized text is carried here; the chat UI surfaces + * it as a "Summarized earlier messages" affordance rather than a normal + * assistant message. + * + * Derived from the `Agents.SummarizeCompleteEvent` LangGraph event introduced + * with context compaction in v0.8.5. + */ + data class ContextSummary( + val summary: String, + val agentId: String? = null, + val groupId: Int? = null, + ) : StreamEvent } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/UserFavorite.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/UserFavorite.kt new file mode 100644 index 0000000..563a885 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/UserFavorite.kt @@ -0,0 +1,30 @@ +package com.garfiec.librechat.core.model + +import kotlinx.serialization.Serializable + +/** + * A user-pinned favorite from `GET/POST /api/user/settings/favorites`. + * + * Upstream contract (v0.8.5): each entry must have either [agentId], or the + * combination of [model] + [endpoint], or a [spec]. Having multiple identifier + * variants on one entry, or none, is rejected server-side with a 400. + * + * Server-enforced limits: 50 entries total, 256 characters per string field. + * `MAX_FAVORITES` and `MAX_STRING_LENGTH` in [FavoritesLimits] mirror those + * caps so the client can short-circuit out-of-bounds writes. + * + * [spec] is passed through unchanged; mobile does not yet render a spec picker, + * but round-trip fidelity keeps other clients' pinned specs intact across saves. + */ +@Serializable +data class UserFavorite( + val agentId: String? = null, + val model: String? = null, + val endpoint: String? = null, + val spec: String? = null, +) + +object FavoritesLimits { + const val MAX_FAVORITES: Int = 50 + const val MAX_STRING_LENGTH: Int = 256 +} diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/config/StartupConfig.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/config/StartupConfig.kt index 455ec35..6133307 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/config/StartupConfig.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/config/StartupConfig.kt @@ -36,7 +36,7 @@ data class StartupConfig( val turnstile: TurnstileConfig? = null, val balance: BalanceConfig? = null, val analyticsGtmId: String? = null, - val instanceProjectId: String? = null, + val allowAccountDeletion: Boolean = true, val bundlerURL: String? = null, val staticBundlerURL: String? = null, val sharePointFilePickerEnabled: Boolean? = null, diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/content/MessageContentPart.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/content/MessageContentPart.kt index 44084b5..7df32dc 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/content/MessageContentPart.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/content/MessageContentPart.kt @@ -3,6 +3,7 @@ package com.garfiec.librechat.core.model.content import com.garfiec.librechat.core.model.ContentType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement @Serializable data class MessageContentPart( @@ -17,8 +18,25 @@ data class MessageContentPart( @SerialName("video_url") val videoUrl: VideoUrlContent? = null, @SerialName("input_audio") val inputAudio: InputAudioContent? = null, @SerialName("agent_update") val agentUpdate: AgentUpdateContent? = null, + // SUMMARY content-part fields (type == "summary"). Fields are top-level on the wire, + // not nested under a `summary` key. `content` can be an array of {type,text} blocks + // or a raw string; legacy servers fall back to the top-level `text` field above. + val content: JsonElement? = null, + val tokenCount: Int? = null, + val summarizing: Boolean? = null, + val summaryVersion: Int? = null, + val model: String? = null, + val provider: String? = null, + val createdAt: String? = null, + val boundary: SummaryBoundary? = null, val agentId: String? = null, val groupId: Int? = null, val stepIndex: Int? = null, val siblingIndex: Int? = null, ) + +@Serializable +data class SummaryBoundary( + val messageId: String? = null, + val contentIndex: Int? = null, +) diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ChatApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ChatApi.kt index 967fdb5..15d36ef 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ChatApi.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/ChatApi.kt @@ -1,5 +1,6 @@ package com.garfiec.librechat.core.network.api +import com.garfiec.librechat.core.common.result.ApiException import com.garfiec.librechat.core.model.request.ChatAbortRequest import com.garfiec.librechat.core.model.request.ChatRequest import com.garfiec.librechat.core.model.response.ActiveJobsResponse @@ -7,21 +8,55 @@ import com.garfiec.librechat.core.model.response.ChatAbortResponse import com.garfiec.librechat.core.model.response.ChatStartResponse import com.garfiec.librechat.core.model.response.ChatStatusResponse import io.ktor.client.HttpClient +import io.ktor.client.call.NoTransformationFoundException 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.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.path class ChatApi constructor( private val client: HttpClient, ) { + /** + * POST /api/agents/chat/{endpoint} — phase-1 of the two-phase SSE protocol. + * + * On v0.8.5-rc1 with `summarization: enabled: true`, the backend has been observed + * returning 200 OK with no `Content-Type` header on this endpoint, which makes Ktor's + * content-negotiation throw `NoTransformationFoundException` — surfacing a Ktor-internal + * stack trace to the user. We catch and translate to [ApiException] so the chat surface + * shows an actionable message instead of "Expected response body of the type 'class + * com.garfiec.librechat.core.model.response.ChatStartResponse...'". + */ @Throws(Exception::class) - suspend fun startChat(endpoint: String, request: ChatRequest): ChatStartResponse = - client.post { + suspend fun startChat(endpoint: String, request: ChatRequest): ChatStartResponse { + val response = client.post { url { path("api/agents/chat/$endpoint") } setBody(request) - }.body() + } + val contentType = response.headers[HttpHeaders.ContentType] + if (contentType == null || ContentType.parse(contentType).match(ContentType.Application.Json).not()) { + throw ApiException( + statusCode = response.status.value, + message = "Server returned an unexpected response when starting the chat. " + + "This usually indicates a backend version incompatibility — please check " + + "that the server is running a supported LibreChat release.", + ) + } + return try { + response.body() + } catch (e: NoTransformationFoundException) { + throw ApiException( + statusCode = response.status.value, + message = "Server returned an unexpected response shape when starting the chat. " + + "This usually indicates a backend version incompatibility — please check " + + "that the server is running a supported LibreChat release.", + cause = e, + ) + } + } suspend fun abortChat(streamId: String): ChatAbortResponse = client.post { diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/FavoritesApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/FavoritesApi.kt new file mode 100644 index 0000000..5ab663b --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/FavoritesApi.kt @@ -0,0 +1,36 @@ +package com.garfiec.librechat.core.network.api + +import com.garfiec.librechat.core.model.UserFavorite +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 kotlinx.serialization.Serializable + +@Serializable +private data class FavoritesRequest(val favorites: List) + +/** + * User-pinned favorites (agents + model/endpoint combinations). + * Introduced in upstream v0.8.5 via `api/server/controllers/FavoritesController.js`. + */ +class FavoritesApi( + private val client: HttpClient, +) { + suspend fun getFavorites(): List = + client.get { + url { path("api/user/settings/favorites") } + }.body() + + /** + * Replaces the user's entire favorites list server-side (upsert-by-overwrite). + * The response echoes the stored list. + */ + suspend fun updateFavorites(favorites: List): List = + client.post { + url { path("api/user/settings/favorites") } + setBody(FavoritesRequest(favorites)) + }.body() +} diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt index 1901bc5..841464a 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt @@ -17,6 +17,16 @@ import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.path +import kotlinx.serialization.Serializable + +/** + * Response payload for `POST /api/prompts/groups/:id/use`. + * Added in upstream v0.8.5 for server-side usage analytics. + */ +@Serializable +data class PromptUseResponse( + val numberOfGenerations: Int = 0, +) class PromptsApi constructor( private val client: HttpClient, @@ -127,4 +137,13 @@ class PromptsApi constructor( url { path("api/prompts") } parameter("groupId", groupId) }.body() + + /** + * Records a prompt-group usage event for analytics (v0.8.5+). + * Fire-and-forget — callers should not block UI on the response. + */ + suspend fun recordPromptGroupUse(groupId: String): PromptUseResponse = + client.post { + url { path("api/prompts/groups/$groupId/use") } + }.body() } diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt index 46ec102..ae6ba87 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/di/NetworkModule.kt @@ -9,6 +9,7 @@ import com.garfiec.librechat.core.network.api.BannerApi import com.garfiec.librechat.core.network.api.ChatApi import com.garfiec.librechat.core.network.api.ConfigApi import com.garfiec.librechat.core.network.api.ConversationsApi +import com.garfiec.librechat.core.network.api.FavoritesApi import com.garfiec.librechat.core.network.api.FilesApi import com.garfiec.librechat.core.network.api.FilesExtApi import com.garfiec.librechat.core.network.api.KeysApi @@ -124,6 +125,7 @@ val networkModule = module { singleOf(::ChatApi) singleOf(::ConfigApi) singleOf(::ConversationsApi) + singleOf(::FavoritesApi) singleOf(::FilesApi) singleOf(::FilesExtApi) singleOf(::KeysApi) diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/sse/SseEventMapper.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/sse/SseEventMapper.kt index b33bf96..7eb5e6e 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/sse/SseEventMapper.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/sse/SseEventMapper.kt @@ -257,11 +257,43 @@ class SseEventMapper(private val json: Json) { "on_run_step_completed" -> mapRunStepCompleted(data, agentId, groupId) "on_chat_model_end" -> null "on_agent_update" -> null + "on_summarize_start" -> null // Lifecycle only; no useful payload to surface + "on_summarize_delta" -> null // Partial summary tokens; we only render the final summary + "on_summarize_complete" -> mapSummarizeComplete(data, agentId, groupId) "attachment" -> mapAttachment(data) - else -> null + else -> null // Forward-compat: unknown agent-library events drop silently. } } + private fun mapSummarizeComplete( + data: JsonObject, + agentId: String?, + groupId: Int?, + ): StreamEvent? { + // {"id":"...","agentId":"...","summary":{"type":"summary","content":[{"type":"text","text":"..."}],...}} + val summary = data["summary"]?.jsonObject ?: return null + val contentArray = summary["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.ContextSummary( + summary = text, + agentId = agentId ?: data["agentId"]?.jsonPrimitive?.contentOrNull, + groupId = groupId, + ) + } + private fun mapMessageDelta( data: JsonObject, agentId: String?, diff --git a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointParameterRegistry.kt b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointParameterRegistry.kt index d14b06d..03e60eb 100644 --- a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointParameterRegistry.kt +++ b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointParameterRegistry.kt @@ -10,11 +10,31 @@ import com.garfiec.librechat.core.model.ParameterType */ object EndpointParameterRegistry { - fun getDefinitions(endpoint: String): List { + /** + * @param xhighEffortSupported when false, the `xhigh` value is filtered out of the + * reasoning-effort and effort dropdowns. `xhigh` was added in upstream v0.8.5; + * on older servers it is rejected at request time. Per `VERSION_GATES.md`, + * default to the older-server behavior when the server version is unknown. + */ + fun getDefinitions( + endpoint: String, + xhighEffortSupported: Boolean = false, + ): List { val key = endpoint.lowercase() - return ENDPOINT_PARAMS[key] ?: ENDPOINT_PARAMS["default"]!! + val base = ENDPOINT_PARAMS[key] ?: ENDPOINT_PARAMS["default"]!! + if (xhighEffortSupported) return base + return base.map { def -> + val options = def.options + if (def.key in EFFORT_KEYS && options != null && "xhigh" in options) { + def.copy(options = options.filterNot { it == "xhigh" }) + } else { + def + } + } } + private val EFFORT_KEYS = setOf("reasoning_effort", "effort") + private val ENDPOINT_PARAMS: Map> = mapOf( "openai" to openAiParams(), "azureopenai" to openAiParams(), @@ -228,10 +248,18 @@ object EndpointParameterRegistry { key = "effort", label = "Effort", type = ParameterType.DROPDOWN, - options = listOf("", "low", "medium", "high", "max"), + options = listOf("", "low", "medium", "high", "xhigh", "max"), default = "", description = "Controls the overall effort level of the response.", ), + ParameterDefinition( + key = "thinkingDisplay", + label = "Reasoning Visibility", + type = ParameterType.DROPDOWN, + options = listOf("", "auto", "summarized", "omitted"), + default = "", + description = "Controls whether reasoning tokens are streamed to the client (Claude Opus 4.7+).", + ), ParameterDefinition( key = "web_search", label = "Web Search", @@ -449,7 +477,7 @@ object EndpointParameterRegistry { key = "effort", label = "Effort", type = ParameterType.DROPDOWN, - options = listOf("", "low", "medium", "high", "max"), + options = listOf("", "low", "medium", "high", "xhigh", "max"), default = "", description = "Controls the overall effort level of the response.", ), diff --git a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ModelParameterSheet.kt b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ModelParameterSheet.kt index 4b4f43d..b0406a1 100644 --- a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ModelParameterSheet.kt +++ b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ModelParameterSheet.kt @@ -109,6 +109,7 @@ fun ModelParameterSheet( modifier: Modifier = Modifier, selectedEndpoint: String = "", dynamicParameterDefinitions: List? = null, + xhighEffortSupported: Boolean = false, onSaveAsPreset: () -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { @@ -122,6 +123,7 @@ fun ModelParameterSheet( onParametersChange = onParametersChange, selectedEndpoint = selectedEndpoint, dynamicParameterDefinitions = dynamicParameterDefinitions, + xhighEffortSupported = xhighEffortSupported, onSaveAsPreset = onSaveAsPreset, modifier = Modifier .padding(horizontal = 24.dp) @@ -137,13 +139,14 @@ fun ModelParameterContent( modifier: Modifier = Modifier, selectedEndpoint: String = "", dynamicParameterDefinitions: List? = null, + xhighEffortSupported: Boolean = false, onSaveAsPreset: () -> Unit = {}, ) { - val definitions = remember(selectedEndpoint, dynamicParameterDefinitions) { + val definitions = remember(selectedEndpoint, dynamicParameterDefinitions, xhighEffortSupported) { if (!dynamicParameterDefinitions.isNullOrEmpty()) { dynamicParameterDefinitions } else { - EndpointParameterRegistry.getDefinitions(selectedEndpoint) + EndpointParameterRegistry.getDefinitions(selectedEndpoint, xhighEffortSupported) } } diff --git a/feature/agents/src/commonMain/composeResources/values/strings.xml b/feature/agents/src/commonMain/composeResources/values/strings.xml index 01d5b14..60a62f4 100644 --- a/feature/agents/src/commonMain/composeResources/values/strings.xml +++ b/feature/agents/src/commonMain/composeResources/values/strings.xml @@ -183,6 +183,7 @@ Visibility Collaborative Allow other users to modify this agent + Access permissions are managed server-side in this version. Private diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/components/AgentSharingSection.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/components/AgentSharingSection.kt index 9d9f3a2..0cff491 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/components/AgentSharingSection.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/components/AgentSharingSection.kt @@ -36,13 +36,20 @@ import com.garfiec.librechat.feature.agents.resources.* import com.garfiec.librechat.feature.agents.resources.Res import org.jetbrains.compose.resources.stringResource -/** Visibility (Private/Team/Public) and collaborative toggle; maps to agent model isPublic/isCollaborative. */ +/** + * Visibility (Private/Team/Public) and collaborative toggle; maps to agent model + * isPublic/isCollaborative. + * + * [showCollaborativeToggle] is gated on server version — see VERSION_GATES.md. + * Upstream v0.8.5 removed the `isCollaborative` field; hide the toggle on v0.8.5+. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentSharingSection( sharingState: AgentSharingState, onSharingChange: (AgentSharingState) -> Unit, modifier: Modifier = Modifier, + showCollaborativeToggle: Boolean = true, ) { var expanded by remember { mutableStateOf(false) } @@ -107,30 +114,39 @@ fun AgentSharingSection( 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(Res.string.label_collaborative), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(Res.string.collaborative_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Collaborative toggle (hidden on v0.8.5+ servers) + if (showCollaborativeToggle) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.label_collaborative), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(Res.string.collaborative_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = sharingState.isCollaborative, + onCheckedChange = { + onSharingChange(sharingState.copy(isCollaborative = it)) + }, ) } - Switch( - checked = sharingState.isCollaborative, - onCheckedChange = { - onSharingChange(sharingState.copy(isCollaborative = it)) - }, + } else { + Text( + text = stringResource(Res.string.collaborative_managed_server_side), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), ) } } diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt index 5752a8b..18aec2c 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt @@ -538,6 +538,7 @@ fun AgentEditorScreen( AgentSharingSection( sharingState = uiState.sharingState, onSharingChange = viewModel::onSharingChanged, + showCollaborativeToggle = uiState.showCollaborativeToggle, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt index 3705478..7d1d3f1 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.garfiec.librechat.core.common.BackendVersion import com.garfiec.librechat.core.common.result.Result import com.garfiec.librechat.core.data.repository.AgentRepository import com.garfiec.librechat.core.data.repository.ConfigRepository @@ -88,6 +89,12 @@ data class AgentEditorUiState( val isCodeInterpreterAvailable: Boolean = true, // Sharing val sharingState: AgentSharingState = AgentSharingState(), + /** + * Whether to show the Collaborative toggle in [AgentSharingSection]. Upstream + * v0.8.5 removed `isCollaborative` + `projectIds` from the agent model in favor + * of ACL permissions, so the toggle is hidden on v0.8.5+. See VERSION_GATES.md. + */ + val showCollaborativeToggle: Boolean = true, // Handoff val handoffAgentIds: List = emptyList(), val allAgents: List = emptyList(), @@ -129,12 +136,28 @@ class AgentEditorViewModel( loadMcpTools() loadAllAgents() loadCodeInterpreterAvailability() + observeServerVersion() if (editAgentId != null) { loadAgent(editAgentId) loadActions() } } + /** + * Observes the detected backend version and hides the Collaborative toggle + * on v0.8.5+ where the server no longer honors `isCollaborative`/`projectIds`. + * See VERSION_GATES.md at the repo root. + */ + private fun observeServerVersion() { + viewModelScope.launch { + configRepository.detectedBackendVersion.collect { version -> + val show = version == null || + !BackendVersion.isCompatibleOrNewer(version, "0.8.5") + _uiState.value = _uiState.value.copy(showCollaborativeToggle = show) + } + } + } + /** * Observes the agents endpoint config capabilities to determine * whether code interpreter (execute_code) is available on this server. @@ -628,7 +651,14 @@ class AgentEditorViewModel( _uiState.value = _uiState.value.copy(isSaving = true, error = null) val isPublic = state.sharingState.visibility == AgentVisibility.PUBLIC - val isCollaborative = state.sharingState.isCollaborative + // On v0.8.5+ the server dropped `isCollaborative` / `projectIds` in favor + // of ACL permissions. When the toggle is hidden we omit the field so the + // server doesn't silently ignore it. See VERSION_GATES.md. + val isCollaborative = if (state.showCollaborativeToggle) { + state.sharingState.isCollaborative + } else { + null + } val supportContact = if (state.supportContact.name.isNotBlank() || state.supportContact.email.isNotBlank() diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt index 11123e1..7dd7830 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt @@ -27,6 +27,7 @@ actual val chatPlatformModule: Module = module { configRepository = get(), conversationRepository = get(), draftRepository = get(), + favoritesRepository = get(), presetRepository = get(), promptRepository = get(), shareRepository = get(), diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt index 2ced5b8..4c71260 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt @@ -717,6 +717,7 @@ actual fun ChatScreen( onParametersChange = viewModel::updateModelParameters, onDismiss = viewModel::hideModelParameters, selectedEndpoint = uiState.selectedEndpoint, + xhighEffortSupported = uiState.xhighEffortSupported, ) } @@ -765,6 +766,10 @@ actual fun ChatScreen( viewModel.dismissSendBlockReason() viewModel.dismissError() }, + favoriteAgentIds = uiState.favoriteAgentIds, + favoriteModelKeys = uiState.favoriteModelKeys, + onToggleAgentFavorite = viewModel::toggleAgentFavorite, + onToggleModelFavorite = viewModel::toggleModelFavorite, ) } @@ -782,6 +787,10 @@ actual fun ChatScreen( }, onDismiss = { showSecondaryModelSheet = false }, serverUrl = uiState.serverUrl, + favoriteAgentIds = uiState.favoriteAgentIds, + favoriteModelKeys = uiState.favoriteModelKeys, + onToggleAgentFavorite = viewModel::toggleAgentFavorite, + onToggleModelFavorite = viewModel::toggleModelFavorite, ) } } diff --git a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt index 3c2f610..7a9e62c 100644 --- a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt +++ b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/di/ChatModuleVerificationTest.kt @@ -10,6 +10,7 @@ import com.garfiec.librechat.core.data.repository.ChatRepository import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.DraftRepository +import com.garfiec.librechat.core.data.repository.FavoritesRepository import com.garfiec.librechat.core.data.repository.FileRepository import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MessageRepository @@ -36,6 +37,7 @@ class ChatModuleVerificationTest { ConfigRepository::class, ConversationRepository::class, DraftRepository::class, + FavoritesRepository::class, FileRepository::class, PresetRepository::class, PromptRepository::class, diff --git a/feature/chat/src/commonMain/composeResources/values/strings.xml b/feature/chat/src/commonMain/composeResources/values/strings.xml index 03fcde2..01c83e5 100644 --- a/feature/chat/src/commonMain/composeResources/values/strings.xml +++ b/feature/chat/src/commonMain/composeResources/values/strings.xml @@ -109,6 +109,12 @@ Collapse thinking block Expand thinking block + + Summarized earlier messages + Summary indicator + Collapse summary block + Expand summary block + Tool call Collapse tool call %1$s @@ -232,6 +238,8 @@ Selected Public agent + Pin as favorite + Unpin favorite Collapse %1$s Expand %1$s diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt index 3958a7a..24a84b9 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt @@ -19,6 +19,8 @@ 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.filled.Star +import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Create import androidx.compose.material.icons.outlined.Public @@ -99,6 +101,10 @@ fun ModelSelectorSheet( serverUrl: String = "", errorMessage: String? = null, onErrorDismiss: () -> Unit = {}, + favoriteAgentIds: Set = emptySet(), + favoriteModelKeys: Set = emptySet(), + onToggleAgentFavorite: ((agentId: String) -> Unit)? = null, + onToggleModelFavorite: ((endpoint: String, model: String) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var searchQuery by remember { mutableStateOf("") } @@ -119,9 +125,10 @@ fun ModelSelectorSheet( } } - // Filter agents by search query (fuzzy matching), sorted by score when searching - val filteredAgents = remember(agents, searchQuery) { - if (!isSearching) { + // Filter agents by search query (fuzzy matching), sorted by score when searching. + // When not searching, pinned agents (v0.8.5+) sort to the top of the list. + val filteredAgents = remember(agents, searchQuery, favoriteAgentIds) { + val base = if (!isSearching) { agents } else if (searchQuery.length <= 2) { agents.filter { agent -> @@ -137,6 +144,11 @@ fun ModelSelectorSheet( .sortedByDescending { (_, score) -> score } .map { (agent, _) -> agent } } + if (isSearching) { + base + } else { + base.sortedByDescending { it.id in favoriteAgentIds } + } } ModalBottomSheet( @@ -209,6 +221,8 @@ fun ModelSelectorSheet( isSelected = selectedEndpoint == EndpointConstants.AGENTS && agent.id == selectedModel, serverUrl = serverUrl, onClick = { onModelSelect(EndpointConstants.AGENTS, agent.id) }, + isFavorite = agent.id in favoriteAgentIds, + onToggleFavorite = onToggleAgentFavorite?.let { toggle -> { toggle(agent.id) } }, ) } } @@ -216,7 +230,7 @@ fun ModelSelectorSheet( // Endpoint model groups filteredByEndpoint.forEach { (endpointName, models) -> - val filteredModels = if (!isSearching) { + val baseFiltered = if (!isSearching) { models } else if (searchQuery.length <= 2) { models.filter { fuzzyMatches(it, searchQuery) } @@ -226,6 +240,12 @@ fun ModelSelectorSheet( .sortedByDescending { (_, score) -> score } .map { (model, _) -> model } } + // Pinned models (v0.8.5+) sort to the top when not searching. + val filteredModels = if (isSearching) { + baseFiltered + } else { + baseFiltered.sortedByDescending { "$endpointName::$it" in favoriteModelKeys } + } if (filteredModels.isNotEmpty()) { val config = endpointConfigs[endpointName] val displayLabel = config?.modelDisplayLabel ?: endpointName @@ -244,6 +264,7 @@ fun ModelSelectorSheet( if (isExpanded) { items(filteredModels, key = { "${endpointName}_$it" }, contentType = { "model" }) { model -> val isSelected = endpointName == selectedEndpoint && model == selectedModel + val isFavorite = "$endpointName::$model" in favoriteModelKeys Row( modifier = Modifier .fillMaxWidth() @@ -265,6 +286,13 @@ fun ModelSelectorSheet( tint = MaterialTheme.colorScheme.primary, ) } + if (onToggleModelFavorite != null) { + Spacer(modifier = Modifier.width(4.dp)) + FavoriteStarButton( + isFavorite = isFavorite, + onToggle = { onToggleModelFavorite(endpointName, model) }, + ) + } } } } @@ -360,6 +388,8 @@ private fun LazyItemScope.AgentListItem( isSelected: Boolean, serverUrl: String, onClick: () -> Unit, + isFavorite: Boolean = false, + onToggleFavorite: (() -> Unit)? = null, ) { val agentName = agent.name ?: agent.id val resolvedAvatarUrl = agent.avatarUrl?.let { url -> @@ -415,5 +445,30 @@ private fun LazyItemScope.AgentListItem( tint = MaterialTheme.colorScheme.primary, ) } + if (onToggleFavorite != null) { + Spacer(modifier = Modifier.width(4.dp)) + FavoriteStarButton(isFavorite = isFavorite, onToggle = onToggleFavorite) + } + } +} + +@Composable +private fun FavoriteStarButton( + isFavorite: Boolean, + onToggle: () -> Unit, +) { + IconButton(onClick = onToggle, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder, + contentDescription = stringResource( + if (isFavorite) Res.string.cd_unpin_favorite else Res.string.cd_pin_favorite, + ), + tint = if (isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) } } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt index 966aa12..0267aa5 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt @@ -21,6 +21,7 @@ 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.Notes import androidx.compose.material.icons.filled.Psychology import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.Card @@ -57,6 +58,11 @@ import com.garfiec.librechat.feature.chat.components.artifact.detectArtifacts import com.garfiec.librechat.feature.chat.components.artifact.groupArtifactVersions import com.garfiec.librechat.feature.chat.resources.* import com.garfiec.librechat.feature.chat.resources.Res +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.compose.resources.stringResource // ─── ContentPartDispatcher ────────────────────────────────────────── @@ -166,6 +172,14 @@ internal fun ContentPartDispatcher( modifier = mod, ) } + ContentType.SUMMARY -> { + SummaryContentPart( + summaryText = extractSummaryText(part), + modifier = mod, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + ) + } else -> { if (!part.text.isNullOrEmpty()) { MarkdownContent(part.text.orEmpty(), mod, fontSizeMultiplier, useKatex) @@ -174,6 +188,30 @@ internal fun ContentPartDispatcher( } } +/** + * Extracts text from a SUMMARY content part. Mirrors upstream's `getSummaryText`: + * `content` may be an array of {type:"text", text} blocks, a raw string, or absent — + * in which case the legacy top-level `text` field is the fallback. + */ +private fun extractSummaryText(part: MessageContentPart): String { + val content = part.content + if (content is JsonArray) { + val builder = StringBuilder() + for (element in content) { + val item = element as? JsonObject ?: continue + val type = item["type"]?.jsonPrimitive?.contentOrNull + if (type == "text") { + item["text"]?.jsonPrimitive?.contentOrNull?.let { builder.append(it) } + } + } + return builder.toString() + } + if (content is JsonPrimitive && content.isString) { + return content.content + } + return part.text.orEmpty() +} + // ─── ToolCallDispatcher ───────────────────────────────────────────── @Composable @@ -370,6 +408,78 @@ private fun ThinkingContentPart( } } +// ─── SummaryContentPart ───────────────────────────────────────────── + +/** + * Collapsed "Summarized earlier messages" card rendered when the server + * emits a SUMMARY content part. Content-compaction is triggered by long + * agent chats (v0.8.5+); tap to expand and read the summary text. + */ +@Composable +private fun SummaryContentPart( + summaryText: String, + modifier: Modifier = Modifier, + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, +) { + if (summaryText.isBlank()) return + + var isExpanded by remember { mutableStateOf(false) } + val summaryToggleCd = + stringResource(if (isExpanded) Res.string.cd_collapse_summary else Res.string.cd_expand_summary) + + 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 = summaryToggleCd + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Notes, + stringResource(Res.string.cd_summary_indicator), + Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(Res.string.label_summary), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + stringResource(if (isExpanded) Res.string.cd_collapse else Res.string.cd_expand), + 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( + summaryText, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + ) + } + } + } +} + // ─── GenericToolCallCard ──────────────────────────────────────────── @Composable diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt index c6bca00..30ba00c 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt @@ -166,6 +166,22 @@ data class ChatUiState( val runCodeEnabled: Boolean = true, val fileSearchEnabled: Boolean = true, val bookmarksEnabled: Boolean = true, + /** + * User-pinned agent IDs (v0.8.5 favorites). Pinned agents sort to the top + * of the "My Agents" group in [ModelSelectorSheet] and get a filled star. + */ + val favoriteAgentIds: Set = emptySet(), + /** + * User-pinned model keys. Each key is `"$endpoint::$model"` — compare with + * `FavoritesDelegate.favoriteModelKey(endpoint, model)`. + */ + val favoriteModelKeys: Set = emptySet(), + /** + * Whether the detected backend is v0.8.5+ and therefore accepts the `xhigh` + * value in the reasoning-effort and effort dropdowns. False on older or + * unknown servers (per VERSION_GATES.md guideline #2). See VERSION_GATES.md. + */ + val xhighEffortSupported: Boolean = false, ) { /** * Effective tool set that merges [enabledTools] with the web search state from diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt index 33eeb8a..73a9616 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt @@ -3,6 +3,7 @@ package com.garfiec.librechat.feature.chat.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.garfiec.librechat.core.common.BackendVersion import com.garfiec.librechat.core.common.EndpointConstants import com.garfiec.librechat.core.common.ToolConstants import com.garfiec.librechat.core.common.network.ConnectivityObserver @@ -16,6 +17,7 @@ import com.garfiec.librechat.core.data.repository.ChatRepository import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.DraftRepository +import com.garfiec.librechat.core.data.repository.FavoritesRepository import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MessageRepository import com.garfiec.librechat.core.data.repository.PresetRepository @@ -40,6 +42,7 @@ import com.garfiec.librechat.feature.chat.model.PromptMentionDisplayData import com.garfiec.librechat.feature.chat.util.NEW_CHAT_DRAFT_KEY import com.garfiec.librechat.feature.chat.util.buildActiveMessagePath import com.garfiec.librechat.feature.chat.viewmodel.delegate.ConversationActionsDelegate +import com.garfiec.librechat.feature.chat.viewmodel.delegate.FavoritesDelegate import com.garfiec.librechat.feature.chat.viewmodel.delegate.InConversationSearchDelegate import com.garfiec.librechat.feature.chat.viewmodel.delegate.ModelSelectionDelegate import com.garfiec.librechat.feature.chat.viewmodel.delegate.PlatformDelegateFactory @@ -74,6 +77,7 @@ class ChatViewModel( private val configRepository: ConfigRepository, private val conversationRepository: ConversationRepository, private val draftRepository: DraftRepository, + favoritesRepository: FavoritesRepository, presetRepository: PresetRepository, promptRepository: PromptRepository, shareRepository: ShareRepository, @@ -102,6 +106,7 @@ class ChatViewModel( private val conversationActionsDelegate = ConversationActionsDelegate(stateHandle, conversationRepository, shareRepository) private val presetPromptDelegate = PresetPromptDelegate(stateHandle, presetRepository, promptRepository) + private val favoritesDelegate = FavoritesDelegate(stateHandle, favoritesRepository) private val modelDelegate = ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore, permissionGate) @@ -255,6 +260,16 @@ class ChatViewModel( } } + // Gate the `xhigh` reasoning-effort dropdown value to v0.8.5+ servers. + // Older servers reject the unknown enum at request time. See VERSION_GATES.md. + viewModelScope.launch { + configRepository.detectedBackendVersion.collect { version -> + val supported = version != null && + BackendVersion.isCompatibleOrNewer(version, "0.8.5") + _uiState.update { it.copy(xhighEffortSupported = supported) } + } + } + viewModelScope.launch { val endpointsResult = configRepository.fetchEndpoints() if (endpointsResult is Result.Error) { @@ -287,6 +302,9 @@ class ChatViewModel( } presetPromptDelegate.loadPresets() + // Favorites is user-personal (not server-permission-gated upstream); load eagerly + // so the chat-side pin stars and Settings → Favorites stay in sync from cold start. + favoritesDelegate.load() loadUserProfile() loadFlags() voiceDelegate.loadSpeechConfig() @@ -822,42 +840,7 @@ class ChatViewModel( 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) - } - } - } - if (lastErrorWasNetwork) { - lastErrorWasNetwork = false - cancelConnectivityObserver() - } - val createdCopy = if (_uiState.value.retryInfo != null) { - _uiState.value.copy(conversationId = event.conversationId, retryInfo = null) - } else { - _uiState.value.copy(conversationId = event.conversationId) - } - _uiState.value = createdCopy - if (isNewConversation && event.conversationId != null && - _uiState.value.pendingNavigationConversationId == null && - !_uiState.value.comparisonState.isEnabled - ) { - _uiState.value = _uiState.value.copy( - pendingNavigationConversationId = event.conversationId, - ) - } - // Eagerly fetch and cache the conversation the server just created, - // so it appears in the conversation list even if the stream fails later - if (event.conversationId != null) { - viewModelScope.launch { - conversationRepository.getConversation(event.conversationId) - } - } - } + is StreamEvent.Created -> handleCreated(event) is StreamEvent.ContentDelta -> { val isComparison = _uiState.value.comparisonState.isEnabled if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) { @@ -914,71 +897,7 @@ class ChatViewModel( streamingBufferDirty = true } } - is StreamEvent.Final -> { - stopStreamingUpdater() - val isComparison = _uiState.value.comparisonState.isEnabled - 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.Final -> handleFinal(event) is StreamEvent.Error -> { stopStreamingUpdater() // Track network errors so auto-reconnect can kick in when connectivity returns @@ -1099,6 +1018,114 @@ class ChatViewModel( flushStreamingBuffer() } is StreamEvent.Step -> { /* no-op */ } + is StreamEvent.ContextSummary -> { + // Server compacted earlier turns into a summary. The compacted text is + // persisted to the final message as a SUMMARY content part and rendered + // there; nothing extra to do during streaming. + } + } + } + + private fun handleCreated(event: 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) + } + } + } + if (lastErrorWasNetwork) { + lastErrorWasNetwork = false + cancelConnectivityObserver() + } + val createdCopy = if (_uiState.value.retryInfo != null) { + _uiState.value.copy(conversationId = event.conversationId, retryInfo = null) + } else { + _uiState.value.copy(conversationId = event.conversationId) + } + _uiState.value = createdCopy + if (isNewConversation && event.conversationId != null && + _uiState.value.pendingNavigationConversationId == null && + !_uiState.value.comparisonState.isEnabled + ) { + _uiState.value = _uiState.value.copy( + pendingNavigationConversationId = event.conversationId, + ) + } + // Eagerly fetch and cache the conversation the server just created, + // so it appears in the conversation list even if the stream fails later + if (event.conversationId != null) { + viewModelScope.launch { + conversationRepository.getConversation(event.conversationId) + } + } + } + + private fun handleFinal(event: StreamEvent.Final) { + stopStreamingUpdater() + val isComparison = _uiState.value.comparisonState.isEnabled + 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) } } @@ -1614,6 +1641,10 @@ class ChatViewModel( fun handlePromptMention(displayData: PromptMentionDisplayData) = presetPromptDelegate.handlePromptMention(displayData) fun handleSlashCommand(displayData: PromptMentionDisplayData) = presetPromptDelegate.handleSlashCommand(displayData) + // Favorites (v0.8.5) + fun toggleAgentFavorite(agentId: String) = favoritesDelegate.toggleAgent(agentId) + fun toggleModelFavorite(endpoint: String, model: String) = favoritesDelegate.toggleModel(endpoint, model) + // Model selection and comparison fun onModelSelected(endpoint: String, model: String) = modelDelegate.onModelSelected(endpoint, model) fun toggleComparison() = modelDelegate.toggleComparison() diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/FavoritesDelegate.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/FavoritesDelegate.kt new file mode 100644 index 0000000..c57366e --- /dev/null +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/FavoritesDelegate.kt @@ -0,0 +1,120 @@ +package com.garfiec.librechat.feature.chat.viewmodel.delegate + +import co.touchlab.kermit.Logger +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.data.repository.FavoritesRepository +import com.garfiec.librechat.core.model.FavoritesLimits +import com.garfiec.librechat.core.model.UserFavorite +import com.garfiec.librechat.feature.chat.viewmodel.ChatStateHandle +import kotlinx.coroutines.launch + +/** + * Routes pin/unpin actions from chat-side pickers (model/agent selector) to + * [FavoritesRepository] and republishes the canonical list into [ChatStateHandle]. + * + * The repository owns the cached list and serializes writes via a Mutex, so + * this delegate is a thin shim: it observes the shared flow and forwards + * toggles. The settings-side `FavoritesViewModel` reads the same flow. + * + * Spec pinning is intentionally not surfaced yet — mobile has no spec picker + * today, but [UserFavorite.spec] is preserved in round-trips so third-party + * clients' pinned specs survive a save from this client. + */ +class FavoritesDelegate( + private val stateHandle: ChatStateHandle, + private val favoritesRepository: FavoritesRepository, +) { + + fun load() { + observeFavorites() + stateHandle.scope.launch { + when (val result = favoritesRepository.refresh()) { + is Result.Success -> Unit + is Result.Error -> { + // Non-critical; leave favorites empty in UI. + Logger.d(result.exception) { "Failed to load favorites: ${result.message}" } + } + is Result.Loading -> Unit + } + } + } + + private fun observeFavorites() { + stateHandle.scope.launch { + favoritesRepository.favorites.collect { list -> publish(list) } + } + } + + /** Toggle an agent pin. Fire-and-forget — the repository does the optimistic update + rollback. */ + fun toggleAgent(agentId: String) { + val current = favoritesRepository.favorites.value + val currentlyPinned = current.any { it.agentId == agentId } + val nextList = if (currentlyPinned) { + current.filterNot { it.agentId == agentId } + } else { + if (atLimit(current)) { + reportLimit() + return + } + current + UserFavorite(agentId = agentId) + } + persist(nextList) + } + + /** Toggle a model pin. A model favorite is identified by the (endpoint, model) pair. */ + fun toggleModel(endpoint: String, model: String) { + val current = favoritesRepository.favorites.value + val currentlyPinned = current.any { it.endpoint == endpoint && it.model == model } + val nextList = if (currentlyPinned) { + current.filterNot { it.endpoint == endpoint && it.model == model } + } else { + if (atLimit(current)) { + reportLimit() + return + } + current + UserFavorite(endpoint = endpoint, model = model) + } + persist(nextList) + } + + private fun atLimit(current: List): Boolean = current.size >= FavoritesLimits.MAX_FAVORITES + + private fun reportLimit() { + stateHandle.update { + copy(error = "You can pin up to ${FavoritesLimits.MAX_FAVORITES} favorites.") + } + } + + private fun persist(nextList: List) { + stateHandle.scope.launch { + when (val result = favoritesRepository.setFavorites(nextList)) { + is Result.Success -> Unit + is Result.Error -> { + Logger.w(result.exception) { "Failed to save favorites: ${result.message}" } + stateHandle.update { copy(error = result.message ?: "Could not update favorites.") } + } + is Result.Loading -> Unit + } + } + } + + private fun publish(list: List) { + val agentIds = list.mapNotNullTo(mutableSetOf()) { it.agentId } + val modelKeys = list.mapNotNullTo(mutableSetOf()) { + val endpoint = it.endpoint + val model = it.model + if (endpoint != null && model != null) favoriteModelKey(endpoint, model) else null + } + stateHandle.update { + copy( + favoriteAgentIds = agentIds, + favoriteModelKeys = modelKeys, + ) + } + } + + companion object { + /** Stable composite key for a (endpoint, model) favorite — used by pickers for membership checks and sorting. */ + fun favoriteModelKey(endpoint: String, model: String): String = "$endpoint::$model" + } +} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt index 24b122b..5b85de3 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/delegate/PresetPromptDelegate.kt @@ -123,6 +123,7 @@ class PresetPromptDelegate( currentInput + (displayData.command ?: displayData.name) + " " } stateHandle.update { copy(inputText = newText) } + recordUseFor(displayData) } fun handleSlashCommand(displayData: PromptMentionDisplayData) { @@ -143,6 +144,24 @@ class PresetPromptDelegate( stateHandle.update { copy(inputText = promptText ?: (displayData.command ?: displayData.name)) } + recordUseFor(displayData) + } + + /** + * Fire-and-forget telemetry ping for `POST /api/prompts/groups/:id/use` (v0.8.5+). + * Errors are swallowed — analytics is never a user-facing failure. + */ + private fun recordUseFor(displayData: PromptMentionDisplayData) { + val group = cachedPromptGroups.find { + it.name == displayData.name && it.command == displayData.command + } ?: return + val groupId = group.id ?: return + stateHandle.scope.launch { + when (val result = promptRepository.recordPromptGroupUse(groupId)) { + is Result.Error -> Logger.d(result.exception) { "recordPromptGroupUse failed (non-fatal): ${result.message}" } + else -> Unit + } + } } } diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt index db0e367..45c1bbd 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt @@ -25,6 +25,7 @@ actual val chatPlatformModule: Module = module { configRepository = get(), conversationRepository = get(), draftRepository = get(), + favoritesRepository = get(), presetRepository = get(), promptRepository = get(), shareRepository = get(), diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt index 475f0c4..2a0eb7c 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt @@ -559,6 +559,10 @@ actual fun ChatScreen( viewModel.dismissSendBlockReason() viewModel.dismissError() }, + favoriteAgentIds = uiState.favoriteAgentIds, + favoriteModelKeys = uiState.favoriteModelKeys, + onToggleAgentFavorite = viewModel::toggleAgentFavorite, + onToggleModelFavorite = viewModel::toggleModelFavorite, ) } diff --git a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt index 2a6bde8..5d33514 100644 --- a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt +++ b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModuleVerificationTest.kt @@ -8,7 +8,9 @@ import com.garfiec.librechat.core.data.datastore.ThemeDataStore import com.garfiec.librechat.core.data.repository.ApiKeyRepository import com.garfiec.librechat.core.data.repository.AuthRepository import com.garfiec.librechat.core.data.repository.BalanceRepository +import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository +import com.garfiec.librechat.core.data.repository.FavoritesRepository import com.garfiec.librechat.core.data.repository.KeyRepository import com.garfiec.librechat.core.data.repository.McpRepository import com.garfiec.librechat.core.data.repository.MemoryRepository @@ -33,7 +35,9 @@ class SettingsModuleVerificationTest { Application::class, UserRepository::class, AuthRepository::class, + ConfigRepository::class, ConversationRepository::class, + FavoritesRepository::class, McpRepository::class, MemoryRepository::class, RoleRepository::class, diff --git a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt index ad46f2d..653b5d2 100644 --- a/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt +++ b/feature/settings/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModelTest.kt @@ -10,6 +10,7 @@ import com.garfiec.librechat.core.data.datastore.ThemeDataStore import com.garfiec.librechat.core.data.datastore.ThemeMode import com.garfiec.librechat.core.data.repository.AuthRepository import com.garfiec.librechat.core.data.repository.BalanceRepository +import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.KeyRepository import com.garfiec.librechat.core.data.repository.McpRepository @@ -63,6 +64,7 @@ class SettingsViewModelTest { private val keyRepository = mockk(relaxed = true) private val roleRepository = mockk(relaxed = true) private val permissionGate = mockk(relaxed = true) + private val configRepository = mockk(relaxed = true) private val testUser = User( email = "test@example.com", @@ -116,6 +118,7 @@ class SettingsViewModelTest { // same load paths via the `?: != false` idiom. every { roleRepository.userPermissions } returns MutableStateFlow(null) coEvery { permissionGate.awaitRole() } returns null + every { configRepository.startupConfig } returns MutableStateFlow(null) } @After @@ -140,6 +143,7 @@ class SettingsViewModelTest { keyRepository = keyRepository, roleRepository = roleRepository, permissionGate = permissionGate, + configRepository = configRepository, ) @Test diff --git a/feature/settings/src/commonMain/composeResources/values/strings.xml b/feature/settings/src/commonMain/composeResources/values/strings.xml index 71ea7f5..0a0c621 100644 --- a/feature/settings/src/commonMain/composeResources/values/strings.xml +++ b/feature/settings/src/commonMain/composeResources/values/strings.xml @@ -115,6 +115,13 @@ Manage API keys for programmatic access Presets Manage conversation presets + Favorites + Manage pinned agents and models + Favorites + No favorites yet + Pin agents or models from the model selector to see them here. + %1$d of %2$d + Unpin favorite App version diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModule.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModule.kt index 76c1ae8..e540129 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModule.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/di/SettingsModule.kt @@ -1,6 +1,7 @@ package com.garfiec.librechat.feature.settings.di import com.garfiec.librechat.feature.settings.viewmodel.ApiKeysViewModel +import com.garfiec.librechat.feature.settings.viewmodel.FavoritesViewModel import com.garfiec.librechat.feature.settings.viewmodel.McpViewModel import com.garfiec.librechat.feature.settings.viewmodel.MemoriesViewModel import com.garfiec.librechat.feature.settings.viewmodel.PresetManagerViewModel @@ -16,6 +17,7 @@ val settingsModule = module { viewModelOf(::SettingsViewModel) viewModelOf(::ApiKeysViewModel) + viewModelOf(::FavoritesViewModel) viewModelOf(::MemoriesViewModel) viewModelOf(::McpViewModel) viewModelOf(::PresetManagerViewModel) diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/FavoritesNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/FavoritesNavigation.kt new file mode 100644 index 0000000..a3a6d52 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/FavoritesNavigation.kt @@ -0,0 +1,18 @@ +package com.garfiec.librechat.feature.settings.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.feature.settings.screen.FavoritesScreen +import kotlinx.serialization.Serializable + +@Serializable data object Favorites : SettingsRoute + +fun EntryProviderScope.favoritesEntry( + onBack: () -> Unit, +) { + entry { + FavoritesScreen( + onNavigateBack = onBack, + ) + } +} diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt index 0349a5d..ef7fc8d 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt @@ -51,6 +51,7 @@ fun EntryProviderScope.settingsEntries( onNavigateToSharedLinks = { onNavigate(SharedLinks) }, onNavigateToPresets = { onNavigate(PresetManager) }, onNavigateToApiKeys = { onNavigate(ApiKeys) }, + onNavigateToFavorites = { onNavigate(Favorites) }, ) } entry { @@ -69,6 +70,7 @@ fun EntryProviderScope.settingsEntries( onLogout = onLogout, onNavigateBack = onBack, onNavigateToApiKeys = { onNavigate(ApiKeys) }, + onNavigateToFavorites = { onNavigate(Favorites) }, ) } entry { @@ -107,6 +109,7 @@ fun EntryProviderScope.settingsEntries( onNavigateBack = onBack, ) } + favoritesEntry(onBack = onBack) } val settingsSerializersModule = SerializersModule { @@ -121,5 +124,6 @@ val settingsSerializersModule = SerializersModule { subclass(ApiKeys::class, ApiKeys.serializer()) subclass(Memories::class, Memories.serializer()) subclass(McpServers::class, McpServers.serializer()) + subclass(Favorites::class, Favorites.serializer()) } } diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/AccountSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/AccountSettingsScreen.kt index 50ae947..8435d92 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/AccountSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/AccountSettingsScreen.kt @@ -18,6 +18,7 @@ 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.material.icons.filled.Star import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -71,6 +72,7 @@ fun AccountSettingsScreen( onLogout: () -> Unit, onNavigateBack: () -> Unit, onNavigateToApiKeys: () -> Unit, + onNavigateToFavorites: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = remember { SnackbarHostState() } @@ -95,6 +97,7 @@ fun AccountSettingsScreen( AccountSettingsContent( onLogout = onLogout, onNavigateToApiKeys = onNavigateToApiKeys, + onNavigateToFavorites = onNavigateToFavorites, snackbarHostState = snackbarHostState, modifier = Modifier .fillMaxSize() @@ -111,6 +114,7 @@ fun AccountSettingsScreen( fun AccountSettingsContent( onLogout: () -> Unit, onNavigateToApiKeys: () -> Unit, + onNavigateToFavorites: () -> Unit, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: SettingsViewModel = koinViewModel(), @@ -195,6 +199,14 @@ fun AccountSettingsContent( onClick = onNavigateToApiKeys, ) } + item(key = "favorites_row") { + AccountSettingsRow( + icon = Icons.Default.Star, + title = stringResource(Res.string.favorites), + subtitle = stringResource(Res.string.favorites_subtitle), + onClick = onNavigateToFavorites, + ) + } // Danger zone item(key = "danger_header") { @@ -203,6 +215,7 @@ fun AccountSettingsContent( item(key = "danger_actions") { DangerZone( isLoading = uiState.isLoading, + allowAccountDeletion = uiState.allowAccountDeletion, onLogoutClick = { showLogoutDialog = true }, onDeleteClick = { showDeleteDialog = true }, ) @@ -445,6 +458,7 @@ private fun AccountInfo( @Composable private fun DangerZone( isLoading: Boolean, + allowAccountDeletion: Boolean, onLogoutClick: () -> Unit, onDeleteClick: () -> Unit, ) { @@ -461,16 +475,18 @@ private fun DangerZone( ) { Text(stringResource(Res.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(Res.string.delete_account)) + if (allowAccountDeletion) { + Button( + onClick = onDeleteClick, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(Res.string.delete_account)) + } } } } diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/FavoritesScreen.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/FavoritesScreen.kt new file mode 100644 index 0000000..ec56a3b --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/FavoritesScreen.kt @@ -0,0 +1,159 @@ +package com.garfiec.librechat.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.padding +import androidx.compose.foundation.layout.size +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.Star +import androidx.compose.material.icons.filled.StarBorder +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.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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.garfiec.librechat.core.ui.components.EmptyState +import com.garfiec.librechat.core.ui.components.LoadingIndicator +import com.garfiec.librechat.feature.settings.resources.* +import com.garfiec.librechat.feature.settings.resources.Res +import com.garfiec.librechat.feature.settings.viewmodel.FavoriteDisplayData +import com.garfiec.librechat.feature.settings.viewmodel.FavoritesViewModel +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavoritesScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: FavoritesViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + 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(Res.string.favorites_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + uiState.isLoading && uiState.favorites.isEmpty() -> LoadingIndicator() + uiState.favorites.isEmpty() -> EmptyState( + title = stringResource(Res.string.favorites_empty_title), + description = stringResource(Res.string.favorites_empty_message), + ) + else -> { + Text( + text = stringResource( + Res.string.favorites_count_of, + uiState.favorites.size, + uiState.limit, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + items = uiState.favorites, + key = FavoriteDisplayData::itemKey, + ) { favorite -> + FavoriteRow( + favorite = favorite, + isSaving = uiState.isSaving, + onUnpin = { viewModel.remove(favorite.itemKey) }, + ) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun FavoriteRow( + favorite: FavoriteDisplayData, + isSaving: Boolean, + onUnpin: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = favorite.label, + style = MaterialTheme.typography.bodyLarge, + ) + if (favorite.subtitle.isNotBlank()) { + Spacer(Modifier.size(2.dp)) + Text( + text = favorite.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = onUnpin, enabled = !isSaving) { + Icon( + imageVector = Icons.Filled.StarBorder, + contentDescription = stringResource(Res.string.favorites_unpin_cd), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/TabbedSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/TabbedSettingsScreen.kt index f56f06b..3181615 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/TabbedSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/screen/TabbedSettingsScreen.kt @@ -43,6 +43,7 @@ fun TabbedSettingsScreen( onNavigateToSharedLinks: () -> Unit, onNavigateToPresets: () -> Unit, onNavigateToApiKeys: () -> Unit, + onNavigateToFavorites: () -> Unit, modifier: Modifier = Modifier, ) { val pagerState = rememberPagerState(pageCount = { SettingsTabs.size }) @@ -102,6 +103,7 @@ fun TabbedSettingsScreen( 2 -> AccountSettingsContent( onLogout = onLogout, onNavigateToApiKeys = onNavigateToApiKeys, + onNavigateToFavorites = onNavigateToFavorites, snackbarHostState = snackbarHostState, modifier = Modifier.fillMaxSize(), ) diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/FavoritesViewModel.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/FavoritesViewModel.kt new file mode 100644 index 0000000..760c6e8 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/FavoritesViewModel.kt @@ -0,0 +1,165 @@ +package com.garfiec.librechat.feature.settings.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.data.repository.FavoritesRepository +import com.garfiec.librechat.core.model.FavoritesLimits +import com.garfiec.librechat.core.model.UserFavorite +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@Immutable +data class FavoriteDisplayData( + val label: String, + val subtitle: String, + val kind: FavoriteKind, + val agentId: String? = null, + val model: String? = null, + val endpoint: String? = null, + val spec: String? = null, +) { + /** Stable identity for LazyColumn `key` — each variant uses a different identifier. */ + val itemKey: String = when (kind) { + FavoriteKind.AGENT -> "agent::${agentId.orEmpty()}" + FavoriteKind.MODEL -> "model::${endpoint.orEmpty()}::${model.orEmpty()}" + FavoriteKind.SPEC -> "spec::${spec.orEmpty()}" + } +} + +enum class FavoriteKind { AGENT, MODEL, SPEC } + +@Immutable +data class FavoritesUiState( + val favorites: List = emptyList(), + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val limit: Int = FavoritesLimits.MAX_FAVORITES, +) + +/** + * Manages the user's pinned-favorites list (`GET/POST /api/user/settings/favorites`, v0.8.5+). + * + * The Settings → Favorites sub-screen uses this to list all pinned items (agents, + * model+endpoint combos, model specs) so users can review and unpin from one place. + * Pinning itself happens from the picker surfaces (chat model selector) via the + * `FavoritesDelegate` in `feature/chat`. Both paths read/write the same + * [FavoritesRepository.favorites] flow so the two surfaces never drift apart. + */ +class FavoritesViewModel( + private val favoritesRepository: FavoritesRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FavoritesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + observeFavorites() + refresh() + } + + private fun observeFavorites() { + viewModelScope.launch { + favoritesRepository.favorites.collect { list -> + _uiState.update { it.copy(favorites = list.mapNotNull { fav -> fav.toDisplay() }) } + } + } + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + when (val result = favoritesRepository.refresh()) { + is Result.Success -> { + _uiState.update { it.copy(isLoading = false) } + } + is Result.Error -> { + Logger.w(result.exception) { "Failed to load favorites: ${result.message}" } + _uiState.update { + it.copy( + isLoading = false, + error = result.message ?: "Could not load favorites", + ) + } + } + is Result.Loading -> Unit + } + } + } + + fun remove(itemKey: String) { + val current = favoritesRepository.favorites.value + val updated = current.filter { it.toDisplay()?.itemKey != itemKey } + if (updated.size == current.size) return + persist(updated) + } + + fun dismissError() { + _uiState.update { it.copy(error = null) } + } + + private fun persist(newList: List) { + viewModelScope.launch { + _uiState.update { it.copy(isSaving = true, error = null) } + when (val result = favoritesRepository.setFavorites(newList)) { + is Result.Success -> { + _uiState.update { it.copy(isSaving = false) } + } + is Result.Error -> { + Logger.w(result.exception) { "Failed to save favorites: ${result.message}" } + _uiState.update { + it.copy( + isSaving = false, + error = result.message ?: "Could not save favorites", + ) + } + } + is Result.Loading -> Unit + } + } + } + + /** + * The server's XOR invariant (agentId vs model+endpoint vs spec) means we can + * unambiguously derive the kind from which field is populated. Returns null + * for malformed entries from older clients so the UI just skips them. + */ + private fun UserFavorite.toDisplay(): FavoriteDisplayData? { + val a = agentId + if (!a.isNullOrBlank()) { + return FavoriteDisplayData( + label = "Agent", + subtitle = a, + kind = FavoriteKind.AGENT, + agentId = a, + ) + } + val m = model + val e = endpoint + if (!m.isNullOrBlank() && !e.isNullOrBlank()) { + return FavoriteDisplayData( + label = m, + subtitle = e, + kind = FavoriteKind.MODEL, + model = m, + endpoint = e, + ) + } + val s = spec + if (!s.isNullOrBlank()) { + return FavoriteDisplayData( + label = "Spec", + subtitle = s, + kind = FavoriteKind.SPEC, + spec = s, + ) + } + return null + } +} diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt index e89e208..54c1154 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/viewmodel/SettingsViewModel.kt @@ -14,6 +14,7 @@ import com.garfiec.librechat.core.data.datastore.ThemeDataStore import com.garfiec.librechat.core.data.datastore.ThemeMode import com.garfiec.librechat.core.data.repository.AuthRepository import com.garfiec.librechat.core.data.repository.BalanceRepository +import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.KeyRepository import com.garfiec.librechat.core.data.repository.McpRepository @@ -75,6 +76,13 @@ data class SettingsUiState( val error: String? = null, val isLoggedOut: Boolean = false, val isAccountDeleted: Boolean = false, + /** + * Mirrors `StartupConfig.allowAccountDeletion`. New in v0.8.5 — older servers + * don't send the flag, so it defaults to `true` and the Delete Account button + * stays visible. When the server sends `false`, mobile hides the button to + * avoid a guaranteed 403 on submission. + */ + val allowAccountDeletion: Boolean = true, // Chat preferences val chatFontSize: ChatFontSize = ChatFontSize.MEDIUM, val autoScrollEnabled: Boolean = true, @@ -209,6 +217,7 @@ class SettingsViewModel( keyRepository: KeyRepository, private val roleRepository: RoleRepository, private val permissionGate: PermissionGate, + private val configRepository: ConfigRepository, ) : ViewModel() { /** Raw state for everything not driven by DataStore flows. */ @@ -387,6 +396,7 @@ class SettingsViewModel( speechDelegate.loadDeviceVoices() loadRoleGatedData() observePermissionFlags() + observeAccountDeletionPolicy() } /** @@ -426,6 +436,19 @@ class SettingsViewModel( } } + /** + * Observes the v0.8.5+ `allowAccountDeletion` flag from `/api/config`. + * Defaults to `true` (older-server behavior) when the field is absent or + * the config hasn't loaded yet — see VERSION_GATES.md guideline #2. + */ + private fun observeAccountDeletionPolicy() { + viewModelScope.launch { + configRepository.startupConfig.collect { config -> + _uiState.update { it.copy(allowAccountDeletion = config?.allowAccountDeletion ?: true) } + } + } + } + // ── Account & user profile ───────────────────────────────────── private fun loadUser() { diff --git a/iosApp/iosApp/SharedFrameworkTest.swift b/iosApp/iosApp/SharedFrameworkTest.swift index 34903d6..471a663 100644 --- a/iosApp/iosApp/SharedFrameworkTest.swift +++ b/iosApp/iosApp/SharedFrameworkTest.swift @@ -36,6 +36,7 @@ func verifySharedFrameworkImport() { feedback: nil, threadId: nil, metadata: nil, + contextMeta: nil, createdAt: nil, updatedAt: nil, title: nil @@ -67,7 +68,7 @@ func verifySharedFrameworkImport() { assert(d.chunk == "Hello") case .error, .final, .toolCallStart, .toolCallComplete, .thinkingDelta, .attachmentCreated, .retrying, .sync, - .step, .created: + .step, .created, .contextSummary: assertionFailure("Wrong case") } diff --git a/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt b/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt index e3bc4f3..36c825a 100644 --- a/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt +++ b/shared/src/iosMain/kotlin/com/garfiec/librechat/shared/IosSharedModule.kt @@ -11,6 +11,7 @@ import com.garfiec.librechat.core.network.api.BannerApi import com.garfiec.librechat.core.network.api.ChatApi import com.garfiec.librechat.core.network.api.ConfigApi import com.garfiec.librechat.core.network.api.ConversationsApi +import com.garfiec.librechat.core.network.api.FavoritesApi import com.garfiec.librechat.core.network.api.FilesApi import com.garfiec.librechat.core.network.api.FilesExtApi import com.garfiec.librechat.core.network.api.KeysApi @@ -114,6 +115,7 @@ val iosSharedModule = module { singleOf(::MessagesApi) singleOf(::UserApi) singleOf(::AgentsApi) + singleOf(::FavoritesApi) singleOf(::PresetsApi) singleOf(::PromptsApi) singleOf(::RolesApi) diff --git a/upstream b/upstream index 0736ff2..9ccc8d9 160000 --- a/upstream +++ b/upstream @@ -1 +1 @@ -Subproject commit 0736ff26686e911c9785a237c63a799db1813f0b +Subproject commit 9ccc8d9bef407f9a769f07a3756ec4b95ac13f80