From 24d6b2148a08dee70e4da003aaf289b7765ebfbd Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Wed, 24 Jun 2026 10:18:33 +0200 Subject: [PATCH] Add image generation + streaming recomposition isolation Image generation: - KaizenApi.generateImage() calls POST /api/v1/image-gen - Bild mode pill in ChatInput triggers image generation instead of chat - Generated image displayed as attachment in assistant message - Conversation created + title generated automatically Streaming isolation (from previous uncommitted work in ChatScreen): - liveStream StateFlow emits content during streaming - Only the active MessageRow subscribes via collectAsState() - Other LazyColumn items stay completely static during streaming --- .../kaizen/app/chat/ChatScreenViewModel.kt | 64 +++++++++++++++++++ .../main/java/dev/kaizen/app/net/KaizenApi.kt | 33 ++++++++++ 2 files changed, 97 insertions(+) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt index 5f32c62..9e9ecc8 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt @@ -220,6 +220,10 @@ class ChatScreenViewModel( } fun send(text: String, branchParentId: String? = null, branchAttachments: List = emptyList()) { + if (activeMode == R.string.mode_image && text.isNotBlank()) { + sendImageGeneration(text.trim()) + return + } val trimmed = text.trim() val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded } if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return @@ -382,6 +386,66 @@ class ChatScreenViewModel( streamJob?.cancel() } + private fun sendImageGeneration(prompt: String) { + if (isStreaming) return + isStreaming = true + val cfg = session.config ?: run { isStreaming = false; return } + haptics.click() + + val userMsg = Message(nextId++, Role.User, prompt) + messages.add(userMsg) + input = "" + val assistantId = nextId++ + val assistantWireId = java.util.UUID.randomUUID().toString() + messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) + val assistantIdx = messages.lastIndex + + viewModelScope.launch { + try { + when (val r = KaizenApi.generateImage(cfg.baseUrl, cfg.token, prompt)) { + is FetchResult.Ok -> { + val imgAtt = Attachment(url = r.data.url, kind = "image", mimeType = r.data.mimeType, name = "generated.png") + if (assistantIdx < messages.size) { + messages[assistantIdx] = messages[assistantIdx].copy( + streaming = false, thinking = false, + attachments = listOf(imgAtt), + ) + } + val cid = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token) + if (cid != null) { + if (conversationId == null) { + conversationId = cid + chat?.conversationRepo?.insertOptimistic(cid, prompt.take(60)) + } + KaizenApi.saveMessages(cfg.baseUrl, cfg.token, cid, listOf( + SaveMessage(id = userMsg.wireId, parentId = null, role = "user", content = prompt), + SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = "", attachments = listOf(imgAtt)), + )) + val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid) + if (title != null) chat?.conversationRepo?.updateTitle(cid, title) + refreshConversations() + } + } + is FetchResult.Fail -> { + if (assistantIdx < messages.size) { + messages[assistantIdx] = messages[assistantIdx].copy( + streaming = false, thinking = false, + content = "⚠ ${r.reason}", + ) + } + } + } + } catch (_: Exception) { + if (assistantIdx < messages.size) { + messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false, content = "⚠ Image generation failed") + } + } finally { + isStreaming = false + haptics.responseEnd() + } + } + } + private fun str(resId: Int, vararg args: Any): String = app?.getString(resId, *args) ?: "" diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index b9ec7b4..ccf3d0c 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -670,6 +670,39 @@ object KaizenApi { } } + @Serializable private data class ImageGenRequest(val prompt: String, val modelId: String? = null) + @Serializable data class ImageGenResponse( + val url: String = "", + val model: String = "", + val mimeType: String = "", + val cost: Double? = null, + ) + + suspend fun generateImage(baseUrl: String, token: String, prompt: String, modelId: String? = null): FetchResult = + withContext(Dispatchers.IO) { + val body = json.encodeToString(ImageGenRequest( + prompt = prompt.take(2000), + modelId = modelId, + )).toRequestBody(jsonMedia) + val req = Request.Builder() + .url("$baseUrl/api/v1/image-gen") + .post(body) + .header("Authorization", "Bearer $token") + .build() + try { + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) { + Log.w(TAG, "generateImage: HTTP ${resp.code}") + return@use FetchResult.Fail("image-gen: HTTP ${resp.code}") + } + FetchResult.Ok(json.decodeFromString(resp.body!!.string())) + } + } catch (e: Exception) { + Log.w(TAG, "generateImage: ${e.javaClass.simpleName}: ${e.message}") + FetchResult.Fail(diagnoseFetchError("image-gen", e)) + } + } + suspend fun fetchMeta(baseUrl: String): MetaResponse? = withContext(Dispatchers.IO) { val req = Request.Builder().url("$baseUrl/api/v1/meta").build() try {