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:
parent
586674b9b0
commit
abde86ca4e
59 changed files with 1504 additions and 162 deletions
74
DISCOVERY.md
74
DISCOVERY.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# LibreChat Mobile
|
||||
|
||||
[](https://github.com/danny-avila/LibreChat/releases/tag/v0.8.4)
|
||||
[](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
|
||||
|
|
|
|||
|
|
@ -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
32
VERSION_GATES.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) - ...`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,4 +74,11 @@ class PromptRepositoryImpl(
|
|||
promptsApi.getPromptsByGroupId(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun recordPromptGroupUse(groupId: String): Result<Unit> {
|
||||
return safeApiCall {
|
||||
promptsApi.recordPromptGroupUse(groupId)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ enum class ContentType {
|
|||
@SerialName("agent_update")
|
||||
AGENT_UPDATE,
|
||||
|
||||
@SerialName("summary")
|
||||
SUMMARY,
|
||||
|
||||
@SerialName("error")
|
||||
ERROR,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -538,6 +538,7 @@ fun AgentEditorScreen(
|
|||
AgentSharingSection(
|
||||
sharingState = uiState.sharingState,
|
||||
onSharingChange = viewModel::onSharingChanged,
|
||||
showCollaborativeToggle = uiState.showCollaborativeToggle,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ actual val chatPlatformModule: Module = module {
|
|||
configRepository = get(),
|
||||
conversationRepository = get(),
|
||||
draftRepository = get(),
|
||||
favoritesRepository = get(),
|
||||
presetRepository = get(),
|
||||
promptRepository = get(),
|
||||
shareRepository = get(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ actual val chatPlatformModule: Module = module {
|
|||
configRepository = get(),
|
||||
conversationRepository = get(),
|
||||
draftRepository = get(),
|
||||
favoritesRepository = get(),
|
||||
presetRepository = get(),
|
||||
promptRepository = get(),
|
||||
shareRepository = get(),
|
||||
|
|
|
|||
|
|
@ -559,6 +559,10 @@ actual fun ChatScreen(
|
|||
viewModel.dismissSendBlockReason()
|
||||
viewModel.dismissError()
|
||||
},
|
||||
favoriteAgentIds = uiState.favoriteAgentIds,
|
||||
favoriteModelKeys = uiState.favoriteModelKeys,
|
||||
onToggleAgentFavorite = viewModel::toggleAgentFavorite,
|
||||
onToggleModelFavorite = viewModel::toggleModelFavorite,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
upstream
2
upstream
|
|
@ -1 +1 @@
|
|||
Subproject commit 0736ff26686e911c9785a237c63a799db1813f0b
|
||||
Subproject commit 9ccc8d9bef407f9a769f07a3756ec4b95ac13f80
|
||||
Loading…
Reference in a new issue