diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt index 11be6e480..1a44ba04e 100644 --- a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt @@ -7,7 +7,6 @@ import com.garfiec.librechat.core.model.request.CreatePromptData import com.garfiec.librechat.core.model.request.CreatePromptGroupData import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.core.network.api.PromptsApi import com.google.common.truth.Truth.assertThat import io.mockk.coEvery @@ -33,19 +32,23 @@ class PromptRepositoryRevisionTest { group = CreatePromptGroupData(name = "Group"), ) + private val addRequest = AddPromptToGroupRequest( + prompt = CreatePromptData(prompt = "new body", type = "text"), + ) + @Test fun everyAcceptedMutationBumpsTheRevision() = runTest { coEvery { promptsApi.createPrompt(any()) } returns group coEvery { promptsApi.updatePromptGroup(any(), any()) } returns group coEvery { promptsApi.deletePromptGroup(any()) } returns Unit coEvery { promptsApi.addPromptToGroup(any(), any()) } returns prompt - coEvery { promptsApi.updatePromptProductionTag(any(), any()) } returns prompt + coEvery { promptsApi.updatePromptProductionTag(any()) } returns Unit val start = repository.revision.value repository.create(createRequest) repository.update("g-1", UpdatePromptGroupRequest(name = "Group")) - repository.addPromptToGroup("g-1", AddPromptToGroupRequest(prompt = "new body", type = "text")) - repository.updatePromptProductionTag("p-1", UpdatePromptTagRequest(productionPromptId = "p-1")) + repository.addPromptToGroup("g-1", addRequest) + repository.updatePromptProductionTag("p-1") repository.delete("g-1") // One per mutation: a missed bump is a surface left serving pre-save values with nothing @@ -77,7 +80,7 @@ class PromptRepositoryRevisionTest { val start = repository.revision.value repository.create(createRequest) repository.delete("g-1") - repository.addPromptToGroup("g-1", AddPromptToGroupRequest(prompt = "new body", type = "text")) + repository.addPromptToGroup("g-1", addRequest) // The bump sits after the API call inside `safeApiCall`, so a throw skips it. Announcing a // failed delete would make the library refetch and repaint the prompt the user was told diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt index bdb678eae..d8fb7323a 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt @@ -6,7 +6,6 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.core.model.response.PromptGroupListResponse import kotlinx.coroutines.flow.StateFlow @@ -32,8 +31,16 @@ interface PromptRepository { suspend fun create(request: CreatePromptRequest): Result suspend fun update(groupId: String, request: UpdatePromptGroupRequest): Result suspend fun delete(groupId: String): Result + + /** + * Adds a version to a group, answering the version created. A new version is not live until + * [updatePromptProductionTag] promotes it. + */ suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Result - suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result + + /** Promotes a version to its group's production prompt — the body every surface reads. */ + suspend fun updatePromptProductionTag(promptId: String): Result + suspend fun getPromptsByGroupId(groupId: String): Result> /** diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt index 1fc318385..78cbf6914 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt @@ -7,7 +7,6 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.core.model.response.PromptGroupListResponse import com.garfiec.librechat.core.network.api.PromptsApi import kotlinx.coroutines.flow.MutableStateFlow @@ -69,9 +68,10 @@ class PromptRepositoryImpl( } } - override suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result { + override suspend fun updatePromptProductionTag(promptId: String): Result { return safeApiCall { - promptsApi.updatePromptProductionTag(promptId, request).also { bumpRevision() } + promptsApi.updatePromptProductionTag(promptId) + bumpRevision() } } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt index ef009ea48..0a6449a85 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt @@ -2,8 +2,14 @@ package com.garfiec.librechat.core.model.request import kotlinx.serialization.Serializable +/** + * `POST /api/prompts/groups/{groupId}/prompts` body. + * + * The version's fields nest under `prompt`: the route reads `req.body.prompt.prompt`, so a flat + * body is rejected with HTTP 400. The group comes from the path — the route overwrites any + * `groupId` the body carries. See `feature/chat/CLAUDE.md`. + */ @Serializable data class AddPromptToGroupRequest( - val prompt: String, - val type: String, + val prompt: CreatePromptData, ) diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/UpdatePromptTagRequest.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/UpdatePromptTagRequest.kt deleted file mode 100644 index 472422290..000000000 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/UpdatePromptTagRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.garfiec.librechat.core.model.request - -import kotlinx.serialization.Serializable - -@Serializable -data class UpdatePromptTagRequest( - val productionPromptId: String? = null, -) diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/response/AddPromptToGroupResponse.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/response/AddPromptToGroupResponse.kt new file mode 100644 index 000000000..2274cc42d --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/response/AddPromptToGroupResponse.kt @@ -0,0 +1,17 @@ +package com.garfiec.librechat.core.model.response + +import com.garfiec.librechat.core.model.Prompt +import kotlinx.serialization.Serializable + +/** + * `POST /api/prompts/groups/{groupId}/prompts` response — the new version wrapped under `prompt`, + * not a bare [Prompt]. + * + * [prompt] is deliberately non-nullable: the route answers `{ "message": "Error saving prompt" }` + * with HTTP 200 when the write fails, so a nullable field would decode that as a success carrying + * nothing, and the caller promotes to production on the strength of it. + */ +@Serializable +data class AddPromptToGroupResponse( + val prompt: Prompt, +) diff --git a/core/network/src/androidUnitTest/kotlin/com/garfiec/librechat/core/network/api/PromptsApiWireShapeTest.kt b/core/network/src/androidUnitTest/kotlin/com/garfiec/librechat/core/network/api/PromptsApiWireShapeTest.kt new file mode 100644 index 000000000..0c0603da6 --- /dev/null +++ b/core/network/src/androidUnitTest/kotlin/com/garfiec/librechat/core/network/api/PromptsApiWireShapeTest.kt @@ -0,0 +1,117 @@ +package com.garfiec.librechat.core.network.api + +import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest +import com.garfiec.librechat.core.model.request.CreatePromptData +import com.garfiec.librechat.core.network.di.librechatJson +import com.google.common.truth.Truth.assertThat +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.contentType +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Test + +/** + * Pins the wire shapes of the two prompt routes whose request and response bodies do not match the + * bare-object convention the rest of `PromptsApi` follows. + * + * The bodies below are live captures from a v0.8.7 server. This has to live at `:core:network`: no + * test above it can see a JSON key, so a mismatch here compiles, type-checks, and surfaces only as + * a `Result.Error` the UI handles correctly. + */ +class PromptsApiWireShapeTest { + + private lateinit var lastRequest: HttpRequestData + + private fun api(responseBody: String, status: HttpStatusCode = HttpStatusCode.OK): PromptsApi { + val engine = MockEngine { request -> + lastRequest = request + respond( + content = responseBody, + status = status, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + val client = HttpClient(engine) { + install(ContentNegotiation) { json(librechatJson) } + // Mirrors production's defaultRequest, which is what makes the body serialize as JSON. + defaultRequest { contentType(ContentType.Application.Json) } + } + return PromptsApi(client) + } + + private fun sentBody(): String = (lastRequest.body as TextContent).text + + private val addRequest = AddPromptToGroupRequest( + prompt = CreatePromptData(prompt = "EDITED BODY", type = "text"), + ) + + /** A live 200 from `POST /api/prompts/groups/{id}/prompts`. */ + private val addResponse = """ + {"prompt":{"groupId":"6a7d506cfee2d387a18fc986","author":"6a797e6b09dd4560d08b49d6", + "prompt":"EDITED BODY","type":"text","_id":"6a7d506df4113f7e6c7140bc", + "createdAt":"2026-08-13T05:04:45.855Z","updatedAt":"2026-08-13T05:04:45.855Z","__v":0}} + """.trimIndent().replace("\n", "") + + @Test + fun `adding a version nests the body under prompt`() = runTest { + api(addResponse).addPromptToGroup("g-1", addRequest) + + // The route reads req.body.prompt.prompt. Sent flat, req.body.prompt is the text itself and + // the route answers 400 — which makes every body edit in the app impossible, on any server. + val body = Json.parseToJsonElement(sentBody()).jsonObject + val nested = body["prompt"]!!.jsonObject + assertThat(nested["prompt"]!!.jsonPrimitive.content).isEqualTo("EDITED BODY") + assertThat(nested["type"]!!.jsonPrimitive.content).isEqualTo("text") + } + + @Test + fun `adding a version unwraps the prompt the server answers with`() = runTest { + val added = api(addResponse).addPromptToGroup("g-1", addRequest) + + // savePrompt returns { prompt: … }, not a bare Prompt. The id is the point: it is the only + // place the new version's id appears, and promoting it to production needs it. + assertThat(added.id).isEqualTo("6a7d506df4113f7e6c7140bc") + assertThat(added.prompt).isEqualTo("EDITED BODY") + assertThat(added.groupId).isEqualTo("6a7d506cfee2d387a18fc986") + } + + @Test(expected = Exception::class) + fun `a failed add reports failure even though the route answers 200`() = runTest { + // savePrompt answers { message: "Error saving prompt" } — still HTTP 200 — when the write + // fails. Nothing above this can tell that from a success, so the decode has to: a nullable + // prompt hands the caller a success carrying nothing, and it promotes on the strength of it. + api("""{"message":"Error saving prompt"}""").addPromptToGroup("g-1", addRequest) + } + + @Test + fun `promoting a version sends no body and decodes none`() = runTest { + // The route promotes req.params.promptId and never reads req.body, so a body naming a + // different prompt is silently ignored while reading as though it chose which version + // went live. + api("""{"message":"Prompt production made successfully"}""").updatePromptProductionTag("p-2") + + assertThat(lastRequest.body).isNotInstanceOf(TextContent::class.java) + assertThat(lastRequest.url.encodedPath).isEqualTo("/api/prompts/p-2/tags/production") + } + + @Test + fun `promoting a version tolerates the message body the route answers with`() = runTest { + // The response is { message: … }, not the prompt. Decoding it as a Prompt throws + // MissingFieldException, turning an accepted promotion into a Result.Error — which skips + // the repository's revision bump, leaving the `/` picker on the superseded body. + api("""{"message":"Prompt production made successfully"}""").updatePromptProductionTag("p-2") + } +} diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt index 1dfef3042..ee08c11e1 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt @@ -5,7 +5,7 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest +import com.garfiec.librechat.core.model.response.AddPromptToGroupResponse import com.garfiec.librechat.core.model.response.CreatePromptResponse import com.garfiec.librechat.core.model.response.PromptGroupListResponse import io.ktor.client.HttpClient @@ -82,22 +82,32 @@ class PromptsApi constructor( } /** - * Add a prompt to an existing group. + * Adds a new version to an existing group, and answers the version it created. + * + * Adding a version does not make it live — the group's `productionId` still points at the old + * one, so callers editing a prompt must follow this with [updatePromptProductionTag], using the + * returned id. Request and response both nest under `prompt`; see [AddPromptToGroupRequest] and + * [AddPromptToGroupResponse]. */ suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Prompt = client.post { url { path("api/prompts/groups/$groupId/prompts") } setBody(request) - }.body() + }.body().prompt /** - * Update the production tag for a prompt. + * Promotes a version to its group's production prompt — the body every prompt surface reads. + * + * Deliberately sends no body: the route promotes `req.params.promptId` and never reads + * `req.body`, so a body naming a different prompt is silently ignored while reading as though + * it chose. The response is `{ "message": ... }` with HTTP 200 either way, so there is nothing + * worth decoding; callers re-read the group to confirm the tag moved. */ - suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Prompt = + suspend fun updatePromptProductionTag(promptId: String) { client.patch { url { path("api/prompts/$promptId/tags/production") } - setBody(request) - }.body() + } + } /** * Get all prompts belonging to a group. diff --git a/feature/chat/CLAUDE.md b/feature/chat/CLAUDE.md index df68bf43b..e2697dfbd 100644 --- a/feature/chat/CLAUDE.md +++ b/feature/chat/CLAUDE.md @@ -197,13 +197,31 @@ Two consequences worth knowing before touching that block: `PROMPTS.USE` gate, so a denied user issues no request; the revision-driven library reload swallows its own errors (nobody asked for that fetch — only the pull-to-refresh gesture and the initial load report failure). -- **A prompt save is two or three requests, and `saved` may only flip when all of them landed.** +- **A prompt save is up to three requests, and `saved` may only flip when all of them landed.** The create route carries neither the oneliner nor the `/` command, and a body edit rides on `addPromptToGroup` rather than the group update — so each save reads every follow-up `Result` before reporting success. Every `PromptRepository` method is `safeApiCall`-wrapped: failure arrives as a returned `Result.Error`, never as a throw, so a `try/catch` around one is dead code. `PromptEditorScreen` pops on `saved`, so discarding a result loses the user's edit behind a success animation. +- **A body edit is not saved until the new version is promoted.** `addPromptToGroup` appends a + version and leaves the group's `productionId` pointing at the old one, so the edit is stored + where nothing reads it: the library row, the `/` picker and the editor itself all show the + *production* body. `publishNewVersion` therefore follows the add with `updatePromptProductionTag` + and reports the save on both, using the id from the add's response — the only place the new + version's id appears. Web does the same on save (`alwaysMakeProd`, default on); mobile exposes no + toggle for it, because a Save that visibly changes nothing is not an outcome worth offering. +- **Two prompt routes do not follow the bare-object convention, and both mismatches are invisible + from inside the app.** `POST /groups/{id}/prompts` nests the body under `prompt` in *both* + directions, and `PATCH /{id}/tags/production` takes no body and answers `{ message }` rather than + the prompt — sending HTTP 200 whether it worked or not. A flat add body is rejected with HTTP 400 + ("Prompt text is required and must be a non-empty string"), since the route reads + `req.body.prompt.prompt`; `AddPromptToGroupResponse.prompt` is non-nullable so that the 200-with- + `{ message }` failure fails to decode rather than reading as an empty success. Getting either + wrong compiles, type-checks and fails only against a real server, where it surfaces as a + `Result.Error` the UI handles correctly and a mutation the user cannot make. + `PromptsApiWireShapeTest` holds live captures of both; no test above `:core:network` can see a + JSON key. ## `ask_user_question`: one question, two cards (v0.8.8) diff --git a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt index 60af2cd9a..997f848ee 100644 --- a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt +++ b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt @@ -119,21 +119,85 @@ class PromptEditorSaveOutcomeTest { assertFalse(state.saved) assertNotNull(state.error) assertFalse(state.isSaving) + // Nothing was written, so there is no version to promote. + coVerify(exactly = 0) { promptRepository.updatePromptProductionTag(any()) } } @Test - fun anAcceptedBodyUpdateReportsSaved() = runTest(testDispatcher) { + fun anAcceptedBodyUpdatePromotesTheNewVersionAndReportsSaved() = runTest(testDispatcher) { val editor = loadedEditor() advanceUntilIdle() coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) coEvery { promptRepository.addPromptToGroup(any(), any()) } returns Result.Success(existingPrompt.copy(id = "p-2", prompt = "new body")) + coEvery { promptRepository.updatePromptProductionTag("p-2") } returns Result.Success(Unit) editor.updatePromptText("new body") editor.save() advanceUntilIdle() + // Adding a version does not move the group's productionId. Without the promotion the edit + // is stored where nothing reads it, so the user's change looks discarded. + coVerify(exactly = 1) { promptRepository.updatePromptProductionTag("p-2") } assertTrue(editor.uiState.value.saved) assertNull(editor.uiState.value.error) } + + @Test + fun aFailedPromotionDoesNotReportTheEditAsSaved() = runTest(testDispatcher) { + val editor = loadedEditor() + advanceUntilIdle() + coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) + coEvery { promptRepository.addPromptToGroup(any(), any()) } returns + Result.Success(existingPrompt.copy(id = "p-2", prompt = "new body")) + coEvery { promptRepository.updatePromptProductionTag("p-2") } returns Result.Error(message = "offline") + + editor.updatePromptText("new body") + editor.save() + advanceUntilIdle() + + // The version exists but is not live, so the edit is not in effect anywhere the user can + // see. Popping the editor here would animate success over a prompt that still answers with + // the old body. + val state = editor.uiState.value + assertFalse(state.saved) + assertNotNull(state.error) + assertFalse(state.isSaving) + } + + @Test + fun aNewVersionWithNoIdIsNotReportedAsSaved() = runTest(testDispatcher) { + val editor = loadedEditor() + advanceUntilIdle() + coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) + coEvery { promptRepository.addPromptToGroup(any(), any()) } returns + Result.Success(existingPrompt.copy(id = null, prompt = "new body")) + + editor.updatePromptText("new body") + editor.save() + advanceUntilIdle() + + // The response is the only place the new version's id appears, so no id means no promotion + // — and an unpromoted version is an edit nothing will read. + coVerify(exactly = 0) { promptRepository.updatePromptProductionTag(any()) } + assertFalse(editor.uiState.value.saved) + assertNotNull(editor.uiState.value.error) + } + + @Test + fun anUnchangedBodyWritesNoVersionAndSaves() = runTest(testDispatcher) { + val editor = loadedEditor() + advanceUntilIdle() + coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) + + editor.updateCommand("summarize") + editor.save() + advanceUntilIdle() + + // Renaming or re-commanding a prompt must not mint a version identical to the live one — + // the version history is the user's, and every save would otherwise add a row to it. + coVerify(exactly = 0) { promptRepository.addPromptToGroup(any(), any()) } + coVerify(exactly = 0) { promptRepository.updatePromptProductionTag(any()) } + assertTrue(editor.uiState.value.saved) + } } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt index 1bb3a98d1..dc5e39794 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt @@ -11,7 +11,6 @@ import com.garfiec.librechat.core.model.request.CreatePromptData import com.garfiec.librechat.core.model.request.CreatePromptGroupData import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -119,8 +118,7 @@ class PromptEditorViewModel( val state = _uiState.value val groupId = state.groupId ?: return viewModelScope.launch { - val request = UpdatePromptTagRequest(productionPromptId = promptId) - when (val result = promptRepository.updatePromptProductionTag(promptId, request)) { + when (val result = promptRepository.updatePromptProductionTag(promptId)) { is Result.Success -> { _uiState.value = _uiState.value.copy( productionId = promptId, @@ -212,16 +210,12 @@ class PromptEditorViewModel( ) when (val result = promptRepository.update(groupId, updateRequest)) { is Result.Success -> { - // A changed body is added as a new version. This call carries the body — the + // A changed body is added as a new version. Those calls carry the body — the // metadata update above does not — and the screen pops on `saved`, so flipping it - // without reading this result loses the user's edit behind a success animation. + // without reading their results loses the user's edit behind a success animation. val currentProduction = state.prompts.find { it.id == state.productionId } val textSaved = if (currentProduction == null || currentProduction.prompt != state.promptText) { - val addRequest = AddPromptToGroupRequest( - prompt = state.promptText, - type = "text", - ) - promptRepository.addPromptToGroup(groupId, addRequest) is Result.Success + publishNewVersion(groupId, state.promptText) } else { true } @@ -241,6 +235,26 @@ class PromptEditorViewModel( } } + /** + * Writes the edited body as a new version and makes it the live one, reporting whether both + * halves landed. + * + * Adding a version does not move the group's `productionId`, so the promotion is not a + * refinement — without it the edit is stored where nothing reads it. Every surface shows the + * *production* body: the library row, the composer's `/` picker, and this editor when it + * reopens. + */ + private suspend fun publishNewVersion(groupId: String, text: String): Boolean { + val addRequest = AddPromptToGroupRequest( + prompt = CreatePromptData(prompt = text, type = "text"), + ) + val added = promptRepository.addPromptToGroup(groupId, addRequest) + // The response is the only place the new version's id appears, so no id means no promotion + // — report the save as incomplete rather than popping the editor over an unread edit. + val newPromptId = (added as? Result.Success)?.data?.id ?: return false + return promptRepository.updatePromptProductionTag(newPromptId) is Result.Success + } + fun consumeSaved() { _uiState.value = _uiState.value.copy(saved = false) } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt index b2d2c16a4..b53216054 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt @@ -12,7 +12,6 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.permissions.Permission import com.garfiec.librechat.core.model.permissions.PermissionType import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.feature.chat.prompts.components.PromptSortOrder import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -261,8 +260,7 @@ class PromptsViewModel( fun setProductionTag(promptId: String) { viewModelScope.launch { - val request = UpdatePromptTagRequest(productionPromptId = promptId) - when (val result = promptRepository.updatePromptProductionTag(promptId, request)) { + when (val result = promptRepository.updatePromptProductionTag(promptId)) { is Result.Success -> { // Reload the selected group to reflect the new production tag val groupId = _uiState.value.selectedGroup?.id