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 <v0.8.5 to avoid
    server-side enum rejection)

Defensive hardening
- ChatApi.startChat asserts Content-Type: application/json before
  decoding ChatStartResponse and catches NoTransformationFoundException
  as a backstop, translating both to a friendly ApiException. Observed
  during testing on v0.8.5-rc1 with summarization enabled, where the
  agents chat-start endpoint returned 200 OK with no Content-Type and
  Ktor leaked a NoTransformationFoundException stack trace into the
  chat surface. Belt-and-suspenders against future backend regressions
  or unsupported-server pointing.

Documentation
- DISCOVERY.md updated for the v0.8.5 surface: /api/endpoints now
  requires JWT, /api/config pre-auth/post-auth split, allowAccountDeletion,
  instanceProjectId removal, favorites endpoints (with all three XOR
  variants: agentId | model+endpoint | spec), prompt-usage endpoint,
  /api/admin/** marked out-of-scope. Plus a new MessageContentPart +
  SUMMARY wire-shape section.

iOS
- Swift SharedFrameworkTest.swift updated for the new
  StreamEvent.ContextSummary variant and Message.contextMeta
  constructor argument.

Out of scope (by design)
- /api/admin/** route family - web-only feature.
- Full ACL migration for agent sharing - replaced by version-gating
  the legacy isCollaborative toggle.
- Spec-pinning UI on mobile - UserFavorite.spec round-trips for
  fidelity with web-authored pins, but mobile does not yet surface
  a spec picker.
This commit is contained in:
Garfie 2026-04-26 17:41:08 -06:00
parent 586674b9b0
commit abde86ca4e
59 changed files with 1504 additions and 162 deletions

View file

@ -134,9 +134,39 @@ POST /api/auth/2fa/verify-temp → { token, user }
### Configuration
```
GET /api/config → startup config (models, features, auth methods, interface config)
GET /api/endpoints → available AI providers and models
GET /api/endpoints → available AI providers and models (JWT required as of v0.8.5)
POST /api/user/settings/favorites → update user favorites (agent/model pins, v0.8.5+)
GET /api/user/settings/favorites → list user favorites (v0.8.5+)
POST /api/prompts/groups/:id/use → record prompt-group usage for analytics (v0.8.5+)
```
**v0.8.5 notes**
- `GET /api/config` response payload is now split into a pre-auth and post-auth variant.
Pre-auth fields (what `validateServerUrl` / `fetchStartupConfig` rely on) are unchanged
from v0.8.4; post-auth adds fields driven by the logged-in user (not consumed by mobile).
- `GET /api/config` removed `instanceProjectId`. Mobile previously used it as an OR fallback
in `ConfigRepositoryImpl.isValidLibreChatConfig`; cleanup landed in v0.8.5 sync.
- `GET /api/config` added `allowAccountDeletion: Boolean`. Mobile honors this and hides
the Delete Account button when `false`. Defaults to `true` for older servers that omit the field.
- `GET /api/endpoints` now requires JWT (was public in v0.8.4). Mobile already called it
post-auth, so this is non-breaking.
- Favorites schema: each entry has exactly one of `{ agentId }`, `{ model, endpoint }`,
or `{ spec }` — three mutually exclusive variants enforced by the server
(`FavoritesController.js`: "Each favorite must have either agentId, model+endpoint, or spec";
`model` and `endpoint` must be supplied together; combining `spec` with any of
`agentId`/`model`/`endpoint` is rejected). Server also enforces 50 entries max /
256-character max per string; mobile short-circuits oversize writes. The `spec`
variant is round-tripped unchanged (mobile does not yet render a spec picker).
`POST` replaces the entire list (upsert-by-overwrite), and the response echoes the stored list.
### Out of scope (admin panel)
```
/api/admin/auth/** → admin-only SAML + Social OAuth callbacks (v0.8.5, web-only)
/api/admin/config/** → admin YAML config endpoints
/api/admin/grants/**, /api/admin/groups/**, /api/admin/roles/**, /api/admin/users/**
```
Admin panel is a web-only surface in upstream; mobile intentionally does not implement it.
### Conversations
```
GET /api/convos → { conversations, nextCursor }
@ -212,6 +242,34 @@ GET /api/banner
- files, attachments, feedback
- error, unfinished, finish_reason, tokenCount
### MessageContentPart
Discriminated by `type`: `text`, `think`, `text_delta`, `tool_call`, `image_file`,
`image_url`, `video_url`, `input_audio`, `agent_update`, `summary`, `error`.
**SUMMARY part wire shape (v0.8.5+)** — context-compaction emits a content part with
fields at the top level (not nested under a `summary` key):
```
{
"type": "summary",
"content": [{"type":"text","text":"..."}], // array OR string (two variants)
"tokenCount": 42,
"summarizing": false,
"summaryVersion": 1,
"model": "gpt-4o",
"provider": "openai",
"createdAt": "2026-04-22T...",
"boundary": {"messageId": "...", "contentIndex": 0}
}
```
Variants for the body text (mirrors upstream `BaseClient.getSummaryText`, last-wins):
1. `content: Array<{type:"text", text}>` — 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

View file

@ -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

View file

@ -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

32
VERSION_GATES.md Normal file
View file

@ -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.

View file

@ -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,

View file

@ -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) - ...`

View file

@ -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)

View file

@ -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,

View file

@ -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
}

View file

@ -21,6 +21,18 @@ interface ConfigRepository {
val startupConfig: StateFlow<StartupConfig?>
val endpointConfigs: StateFlow<Map<String, EndpointConfig>>
val availableModels: StateFlow<Map<String, List<String>>>
/**
* 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<String?>
suspend fun validateServerUrl(url: String): Result<StartupConfig>
suspend fun fetchStartupConfig(): Result<StartupConfig>
suspend fun fetchEndpoints(): Result<Map<String, EndpointConfig>>

View file

@ -29,6 +29,9 @@ class ConfigRepositoryImpl(
private val _availableModels = MutableStateFlow<Map<String, List<String>>>(emptyMap())
override val availableModels: StateFlow<Map<String, List<String>>> = _availableModels.asStateFlow()
private val _detectedBackendVersion = MutableStateFlow<String?>(null)
override val detectedBackendVersion: StateFlow<String?> = _detectedBackendVersion.asStateFlow()
override suspend fun validateServerUrl(url: String): Result<StartupConfig> {
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<StartupConfig> {
@ -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(

View file

@ -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<List<UserFavorite>>
/** Fetches the canonical list from the server and publishes it to [favorites]. */
suspend fun refresh(): Result<List<UserFavorite>>
/**
* 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<UserFavorite>): Result<List<UserFavorite>>
}

View file

@ -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<List<UserFavorite>>(emptyList())
override val favorites: StateFlow<List<UserFavorite>> = _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<List<UserFavorite>> {
return when (val result = safeApiCall { favoritesApi.getFavorites() }) {
is Result.Success -> {
_favorites.value = result.data
result
}
else -> result
}
}
override suspend fun setFavorites(list: List<UserFavorite>): Result<List<UserFavorite>> {
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
}
}
}
}

View file

@ -20,4 +20,10 @@ interface PromptRepository {
suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Result<Prompt>
suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result<Prompt>
suspend fun getPromptsByGroupId(groupId: String): Result<List<Prompt>>
/**
* 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<Unit>
}

View file

@ -74,4 +74,11 @@ class PromptRepositoryImpl(
promptsApi.getPromptsByGroupId(groupId)
}
}
override suspend fun recordPromptGroupUse(groupId: String): Result<Unit> {
return safeApiCall {
promptsApi.recordPromptGroupUse(groupId)
Unit
}
}
}

View file

@ -32,6 +32,9 @@ enum class ContentType {
@SerialName("agent_update")
AGENT_UPDATE,
@SerialName("summary")
SUMMARY,
@SerialName("error")
ERROR,
}

View file

@ -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<String>? = null,
val iconURL: String? = null,
val greeting: String? = null,

View file

@ -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,

View file

@ -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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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 {

View file

@ -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<UserFavorite>)
/**
* 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<UserFavorite> =
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<UserFavorite>): List<UserFavorite> =
client.post {
url { path("api/user/settings/favorites") }
setBody(FavoritesRequest(favorites))
}.body()
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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?,

View file

@ -10,11 +10,31 @@ import com.garfiec.librechat.core.model.ParameterType
*/
object EndpointParameterRegistry {
fun getDefinitions(endpoint: String): List<ParameterDefinition> {
/**
* @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<ParameterDefinition> {
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<String, List<ParameterDefinition>> = 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.",
),

View file

@ -109,6 +109,7 @@ fun ModelParameterSheet(
modifier: Modifier = Modifier,
selectedEndpoint: String = "",
dynamicParameterDefinitions: List<ParameterDefinition>? = 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<ParameterDefinition>? = 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)
}
}

View file

@ -183,6 +183,7 @@
<string name="label_visibility">Visibility</string>
<string name="label_collaborative">Collaborative</string>
<string name="collaborative_description">Allow other users to modify this agent</string>
<string name="collaborative_managed_server_side">Access permissions are managed server-side in this version.</string>
<!-- Visibility labels -->
<string name="visibility_private">Private</string>

View file

@ -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),
)
}
}

View file

@ -538,6 +538,7 @@ fun AgentEditorScreen(
AgentSharingSection(
sharingState = uiState.sharingState,
onSharingChange = viewModel::onSharingChanged,
showCollaborativeToggle = uiState.showCollaborativeToggle,
)
Spacer(modifier = Modifier.height(8.dp))

View file

@ -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<String> = emptyList(),
val allAgents: List<AgentHandoffDisplayData> = 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()

View file

@ -27,6 +27,7 @@ actual val chatPlatformModule: Module = module {
configRepository = get(),
conversationRepository = get(),
draftRepository = get(),
favoritesRepository = get(),
presetRepository = get(),
promptRepository = get(),
shareRepository = get(),

View file

@ -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,
)
}
}

View file

@ -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,

View file

@ -109,6 +109,12 @@
<string name="cd_collapse_thinking">Collapse thinking block</string>
<string name="cd_expand_thinking">Expand thinking block</string>
<!-- Content descriptions - Summary (context compaction, v0.8.5+) -->
<string name="label_summary">Summarized earlier messages</string>
<string name="cd_summary_indicator">Summary indicator</string>
<string name="cd_collapse_summary">Collapse summary block</string>
<string name="cd_expand_summary">Expand summary block</string>
<!-- Content descriptions - Tool calls -->
<string name="cd_tool_call">Tool call</string>
<string name="cd_collapse_tool_call">Collapse tool call %1$s</string>
@ -232,6 +238,8 @@
<!-- Model selector sheet -->
<string name="cd_selected">Selected</string>
<string name="cd_public_agent">Public agent</string>
<string name="cd_pin_favorite">Pin as favorite</string>
<string name="cd_unpin_favorite">Unpin favorite</string>
<string name="cd_collapse_section">Collapse %1$s</string>
<string name="cd_expand_section">Expand %1$s</string>

View file

@ -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<String> = emptySet(),
favoriteModelKeys: Set<String> = 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),
)
}
}

View file

@ -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

View file

@ -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<String> = emptySet(),
/**
* User-pinned model keys. Each key is `"$endpoint::$model"` compare with
* `FavoritesDelegate.favoriteModelKey(endpoint, model)`.
*/
val favoriteModelKeys: Set<String> = 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

View file

@ -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()

View file

@ -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<UserFavorite>): 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<UserFavorite>) {
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<UserFavorite>) {
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"
}
}

View file

@ -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
}
}
}
}

View file

@ -25,6 +25,7 @@ actual val chatPlatformModule: Module = module {
configRepository = get(),
conversationRepository = get(),
draftRepository = get(),
favoritesRepository = get(),
presetRepository = get(),
promptRepository = get(),
shareRepository = get(),

View file

@ -559,6 +559,10 @@ actual fun ChatScreen(
viewModel.dismissSendBlockReason()
viewModel.dismissError()
},
favoriteAgentIds = uiState.favoriteAgentIds,
favoriteModelKeys = uiState.favoriteModelKeys,
onToggleAgentFavorite = viewModel::toggleAgentFavorite,
onToggleModelFavorite = viewModel::toggleModelFavorite,
)
}

View file

@ -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,

View file

@ -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<KeyRepository>(relaxed = true)
private val roleRepository = mockk<RoleRepository>(relaxed = true)
private val permissionGate = mockk<PermissionGate>(relaxed = true)
private val configRepository = mockk<ConfigRepository>(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

View file

@ -115,6 +115,13 @@
<string name="api_keys_subtitle">Manage API keys for programmatic access</string>
<string name="presets">Presets</string>
<string name="presets_subtitle">Manage conversation presets</string>
<string name="favorites">Favorites</string>
<string name="favorites_subtitle">Manage pinned agents and models</string>
<string name="favorites_title">Favorites</string>
<string name="favorites_empty_title">No favorites yet</string>
<string name="favorites_empty_message">Pin agents or models from the model selector to see them here.</string>
<string name="favorites_count_of">%1$d of %2$d</string>
<string name="favorites_unpin_cd">Unpin favorite</string>
<!-- About info -->
<string name="app_version_label">App version</string>

View file

@ -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)

View file

@ -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<NavKey>.favoritesEntry(
onBack: () -> Unit,
) {
entry<Favorites> {
FavoritesScreen(
onNavigateBack = onBack,
)
}
}

View file

@ -51,6 +51,7 @@ fun EntryProviderScope<NavKey>.settingsEntries(
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
onNavigateToPresets = { onNavigate(PresetManager) },
onNavigateToApiKeys = { onNavigate(ApiKeys) },
onNavigateToFavorites = { onNavigate(Favorites) },
)
}
entry<SettingsGeneral> {
@ -69,6 +70,7 @@ fun EntryProviderScope<NavKey>.settingsEntries(
onLogout = onLogout,
onNavigateBack = onBack,
onNavigateToApiKeys = { onNavigate(ApiKeys) },
onNavigateToFavorites = { onNavigate(Favorites) },
)
}
entry<SettingsData> {
@ -107,6 +109,7 @@ fun EntryProviderScope<NavKey>.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())
}
}

View file

@ -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))
}
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -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(),
)

View file

@ -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<FavoriteDisplayData> = 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<FavoritesUiState> = _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<UserFavorite>) {
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
}
}

View file

@ -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() {

View file

@ -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")
}

View file

@ -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)

@ -1 +1 @@
Subproject commit 0736ff26686e911c9785a237c63a799db1813f0b
Subproject commit 9ccc8d9bef407f9a769f07a3756ec4b95ac13f80