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:
parent
f746c603b0
commit
24d6b2148a
2 changed files with 97 additions and 0 deletions
|
|
@ -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) ?: ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue