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
This commit is contained in:
Bruno Deanoz 2026-06-24 10:18:33 +02:00
parent f746c603b0
commit 24d6b2148a
2 changed files with 97 additions and 0 deletions

View file

@ -220,6 +220,10 @@ class ChatScreenViewModel(
}
fun send(text: String, branchParentId: String? = null, branchAttachments: List<Attachment> = 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) ?: ""

View file

@ -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<ImageGenResponse> =
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<ImageGenResponse>(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 {