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
IllegalStateExceptionif you rotate the screen;ViewModelsurvives.
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.