n4nAI

Building a Jetpack Compose chat UI for an LLM API

Step-by-step tutorial to build a Jetpack Compose chat UI for an LLM API in Kotlin, covering streaming, state management, and error handling.

n4n Team2 min read512 words

Audio narration

Coming soon — every post will get a voice note here.

Building a jetpack compose chat ui llm api client forces you to confront streaming, backpressure, and state synchronization early. This tutorial walks through a production-shaped implementation in Kotlin, from HTTP client to composable messages list, so you can drop it into a real Android app.

Prerequisites

  • Android Studio Hedgehog (2023.1.1) or newer, Kotlin 1.9.20+.
  • Jetpack Compose BOM 2024.02.00 with androidx.compose* libraries.
  • Ktor HTTP client dependencies for Android and SSE-style streaming.
  • An API key for an OpenAI-compatible endpoint. We’ll target n4n.ai’s single endpoint that fronts 240+ models and handles automatic fallback when a provider is degraded.
  • Familiarity with MVVM and ViewModelScope.

Gradle setup

Add these to your module build.gradle.kts:

dependencies {
    implementation("androidx.compose.ui:ui:1.6.0")
    implementation("androidx.compose.material3:material3:1.2.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
    implementation("io.ktor:ktor-client-android:2.3.7")
    implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

Sync before continuing.

Data model

Define the wire format. The LLM API expects a list of messages with role and content. We enable stream: true to get token deltas.

import kotlinx.serialization.*

@Serializable
data class ChatMessage(val role: String, val content: String)

@Serializable
data class ChatRequest(
    val model: String,
    val messages: List<ChatMessage>,
    val stream: Boolean = true
)

A minimal request body looks like this:

{
  "model": "gpt-3.5-turbo",
  "messages": [{"role": "user", "content": "Hello"}],
  "stream": true
}

Why stream instead of polling

Polling a non-streaming endpoint doubles latency and wastes bandwidth on a chat screen. Server-Sent Events (SSE) let the model push tokens the moment they are generated. The jetpack compose chat ui llm api pattern should treat each token as a state mutation, not a full response reload.

API client with streaming

Ktor’s HttpClient can read the raw response channel. OpenAI-compatible endpoints return SSE where each data: line holds a JSON delta. We parse lines and emit concatenated content.

import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.flow.*

class LLMChatClient(private val apiKey: String, private val baseUrl: String) {
    private val client = HttpClient(Android) {
        install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation)
    }

    fun streamCompletion(req: ChatRequest): Flow<String> = flow {
        val response: HttpResponse = client.post("$baseUrl/v1/chat/completions") {
            contentType(ContentType.Application.Json)
            headers { append(HttpHeaders.Authorization, "Bearer $apiKey") }
            setBody(req)
        }
        val channel = response.bodyAsChannel()
        while (!channel.isClosedForRead) {
            val line = channel.readUTF8Line() ?: continue
            if (line.startsWith("data:")) {
                val data = line.removePrefix("data:").trim()
                if (data == "[DONE]") break
                val content = parseDeltaContent(data)
                if (content != null) emit(content)
            }
        }
    }

    private fun parseDeltaContent(json: String): String? {
        // Minimal extraction; in production use Json.decodeFromString on a DTO
        val idx = json.indexOf("\"content\"")
        if (idx == -1) return null
        val start = json.indexOf('"', idx + 10) + 1
        val end = json.indexOf('"', start)
        return if (start > 0 && end > start) json.substring(start, end) else null
    }
}

Connection cleanup

Hold a single HttpClient instance at the Application level or close it in ViewModel.onCleared. Leaking clients across configuration changes will exhaust sockets on long chat sessions.

ViewModel and state

The ViewModel owns the conversation and drives streaming collection. We keep an immutable List<ChatMessage> where the last assistant message mutates as tokens arrive.

class ChatViewModel(private val client: LLMChatClient) : ViewModel() {
    private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
    val messages: StateFlow<List<ChatMessage>> = _messages

    private val _input = MutableStateFlow("")
    val input: StateFlow<String> = _input

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading

    fun onInputChange(text: String) { _input.value = text }

    fun send() {
        val userText = _input.value.trim()
        if (userText.isBlank() || _isLoading.value) return
        val history = _messages.value + ChatMessage("user", userText)
        _messages.value = history + ChatMessage("assistant", "")
        _input.value = ""
        _isLoading.value = true

        viewModelScope.launch {
            try {
                client.streamCompletion(ChatRequest("gpt-3.5-turbo", history))
                    .collect { token ->
                        val current = _messages.value.toMutableList()
                        val last = current.last()
                        current[current.size - 1] = last.copy(content = last.content + token)
                        _messages.value = current
                    }
            } catch (e: Exception) {
                val current = _messages.value.toMutableList()
                val last = current.last()
                current[current.size - 1] = last.copy(content = "Error: ${e.message}")
                _messages.value = current
            } finally {
                _isLoading.value = false
            }
        }
    }
}

Cancellation and backpressure

Flow.collect inside viewModelScope cancels automatically when the scope dies. If the user navigates away mid-stream, the coroutine stops, and the channel reads abort. No manual queue needed; Compose re-composes only the changed list item because we emit a new list reference.

Compose chat UI

Three composables: a scrollable message list, a message bubble, and an input row. Use LazyColumn and auto-scroll on new items.

@Composable
fun ChatScreen(vm: ChatViewModel) {
    val messages by vm.messages.collectAsState()
    val input by vm.input.collectAsState()
    val isLoading by vm.isLoading.collectAsState()
    val listState = rememberLazyListState()

    LaunchedEffect(messages.size) {
        if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
    }

    Column(Modifier.fillMaxSize()) {
        LazyColumn(
            state = listState,
            modifier = Modifier.weight(1f).padding(8.dp),
            verticalArrangement = Arrangement.spacedBy(4.dp)
        ) {
            items(messages) { msg ->
                MessageBubble(msg)
            }
        }
        Row(Modifier.padding(8.dp).fillMaxWidth()) {
            TextField(
                value = input,
                onValueChange = vm::onInputChange,
                modifier = Modifier.weight(1f),
                placeholder = { Text("Type a message") }
            )
            Spacer(Modifier.width(8.dp))
            Button(onClick = vm::send, enabled = !isLoading) {
                Text("Send")
            }
        }
    }
}

@Composable
fun MessageBubble(msg: ChatMessage) {
    val bg = if (msg.role == "user") Color(0xFFDCF8C6) else Color(0xFFECECEC)
    Box(
        Modifier.fillMaxWidth().padding(4.dp)
            .background(bg, RoundedCornerShape(8.dp)).padding(12.dp)
    ) {
        Text(msg.content)
    }
}

Checkpoint: run and observe

Deploy to an emulator or device. Type “Explain coroutines in one sentence” and tap Send.

Expected behavior:

  • The user bubble appears immediately.
  • An empty assistant bubble shows, then fills token-by-token (e.g., “Coroutines are lightweight threads managed by Kotlin for asynchronous programming.”).
  • The list auto-scrolls. Logcat shows no IllegalStateException if you rotate the screen; ViewModel survives.

If the API key is invalid, the assistant bubble reads Error: 401 Unauthorized (or similar). The UI stays responsive because streaming runs in viewModelScope.

Handling provider routing hints

OpenAI-compatible gateways often forward cache-control or routing directives. If you need to honor a specific provider, pass extra headers in the Ktor post block:

headers {
    append(HttpHeaders.Authorization, "Bearer $apiKey")
    append("X-Route", "openai") // example client routing directive
}

The n4n.ai endpoint respects such directives and forwards provider cache-control hints, so your latency-sensitive calls can pin a backend.

Wrapping up

You now have a jetpack compose chat ui llm api integration that streams tokens, manages state in a ViewModel, and degrades gracefully on errors. Replace the minimal JSON parser with kotlinx.serialization DTOs, add cancellation on clear, and you’re ready for production traffic.

Tagsandroidjetpack-composechat-uikotlin

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All kotlin & android llm integration posts →