Separar la UI de chat de la lógica de los LLMs no es una optimización: es una decisión arquitectónica que evita acoplamiento, suposiciones implícitas y deuda técnica.

Ilustración conceptual de una interfaz de chat desacoplada de la lógica de IA

Diseñar una UI de chat que no sabe nada de LLMs (y por qué eso importa)

Durante los últimos días he estado construyendo ChatKit, un pequeño SDK en SwiftUI para renderizar interfaces de chat.
No para “hablar con ChatGPT”.
No para “integrar IA”.
Simplemente para mostrar mensajes.

Y esa distinción, aunque parezca menor, cambia completamente el diseño.


El problema habitual con las UIs de chat

La mayoría de interfaces de chat actuales —especialmente las que rodean a LLMs— cometen el mismo error de base:

La UI intenta entender la conversación.

Decide cuándo toca responder el asistente.
Asume alternancia usuario → assistant.
Gestiona estados de red, tokens, streaming, retries.
Se acopla al proveedor (OpenAI, Anthropic, local, etc.).

El resultado es una UI:

Yo quería justo lo contrario.


Principio fundamental: la UI no piensa

ChatKit parte de una idea muy simple:

La UI no decide nada. Solo renderiza lo que le pasan.

Si recibe:

No hay reglas implícitas.
No hay “lógica conversacional”.
No hay suposiciones.

La conversación no existe como concepto.
Solo existe una lista ordenada de mensajes.


Roles como datos, no como reglas

En ChatKit, un mensaje tiene un rol:

El rol solo afecta a cómo se renderiza (alineación, estilo, color).
No define quién habla después ni cuántas veces puede hacerlo.

Si el consumidor inyecta:

ChatKit no lo cuestiona. Lo renderiza.


El ViewModel como frontera explícita

La interacción con el exterior ocurre a través de un ChatViewModel inicializado así:

Cuando el usuario envía un mensaje, ChatKit:

Qué ocurre después no es responsabilidad del SDK:

ChatKit no envía requests.
Notifica eventos.


Estados mínimos y visuales

ChatKit define estados solo para la UI:

No hay estados mágicos.
No hay inferencias implícitas.

Si el consumidor quiere:

lo decide fuera.


Sobre el “streaming” (honestidad técnica)

Aunque el modelo define estados como streaming, a día de hoy ChatKit no implementa un pipeline oficial de tokens.

Esto es intencional.

Antes de añadir complejidad, quise validar que el diseño:

El diseño está preparado para crecer hacia streaming, pero no lo promete todavía.


Apariencia: configuración, no temas cerrados

ChatKit expone la apariencia como configuración:

No impone temas cerrados ni branding propio.

La app contenedora decide:

El SDK no intenta ser más listo que el consumidor.


Animaciones: sutiles y opcionales

Las animaciones están pensadas como:

No afectan al modelo de datos ni al flujo.

Si mañana se eliminan, el sistema sigue funcionando.


Cómo probarlo contra un LLM real (sin buenas prácticas)

Para validar que el diseño funciona fuera del entorno teórico, conecté ChatKit directamente a la API de OpenAI desde un proyecto nuevo, importándolo como Swift Package.

El objetivo no es enseñar a integrar OpenAI correctamente, sino demostrar que el SDK funciona con un backend real.

Para ello creé un OpenAIClient mínimo

import Foundation
import ChatKit

struct OpenAIClient {

    let apiKey: String

    func send(
        messages: [ChatMessage],
        completion: @escaping (Result<String, Error>) -> Void
    ) {
        let url = URL(string: "https://api.openai.com/v1/chat/completions")!

        let payload: [String: Any] = [
            "model": "gpt-4o-mini",
            "messages": messages.map {
                [
                    "role": mapRole($0.role),
                    "content": $0.content
                ]
            }
        ]

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: payload)

        URLSession.shared.dataTask(with: request) { data, _, error in
            if let error {
                completion(.failure(error))
                return
            }

            guard
                let data,
                let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                let choices = json["choices"] as? [[String: Any]],
                let message = choices.first?["message"] as? [String: Any],
                let content = message["content"] as? String
            else {
                completion(.failure(NSError(domain: "OpenAI", code: -1)))
                return
            }

            completion(.success(content))
        }.resume()
    }

    private func mapRole(_ role: ChatRole) -> String {
        switch role {
        case .user:
            return "user"
        case .assistant:
            return "assistant"
        case .system:
            return "system"
        }
    }
}

y hardcodeé la API key directamente en el ContentView

import SwiftUI
import ChatKit

struct ContentView: View {

    @StateObject private var viewModel: ChatViewModel
    private let brandAppearance: ChatAppearancePair

    init() {
        var vmRef: ChatViewModel?

        let vm = ChatViewModel(
            initialMessages: [
                ChatMessage(
                    role: .assistant,
                    content: "Hi! Ask me anything.",
                    status: .completed
                )
            ],
            quickPrompts: [
                QuickPrompt(id: UUID(), title: "Resume this", message: "Resume this"),
                QuickPrompt(id: UUID(), title: "Give examples", message: "Give examples"),
                QuickPrompt(id: UUID(), title: "Explain like I'm 5", message: "Explain like I'm 5")
            ],
            onSend: { message in
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                    let response = ChatMessage(
                        role: .assistant,
                        content: "Fake LLM response to: \(message.content)",
                        status: .completed
                    )
                    vmRef?.appendMessage(response)
                }
            }
        )
        
        vmRef = vm

        let lightAppearance = ChatAppearance(
            background: Color(.systemGroupedBackground),
            font: .callout,
            contentPadding: 14,
            userBubbleBackground: Color(red: 255/255, green: 204/255, blue: 0/255).opacity(0.25), // Vueling yellow
            assistantBubbleBackground: Color.gray.opacity(0.12),
            userForeground: .black,
            assistantForeground: .primary,
            cornerRadius: 18
        )

        let darkAppearance = ChatAppearance(
            background: Color(.black),
            font: .callout,
            contentPadding: 14,
            userBubbleBackground: Color(red: 255/255, green: 204/255, blue: 0/255).opacity(0.35),
            assistantBubbleBackground: Color.white.opacity(0.12),
            userForeground: .black,
            assistantForeground: .white,
            cornerRadius: 18
        )

        brandAppearance = ChatAppearancePair(
            light: lightAppearance,
            dark: darkAppearance
        )

        _viewModel = StateObject(wrappedValue: vm)
    }

    var body: some View {
        ChatView(
            viewModel: viewModel,
            appearance: brandAppearance,
            layout: .compact,
            behavior: .default
        )
    }
}

El ChatViewModel notifica cuando el usuario envía un mensaje.
El cliente llama a OpenAI y, cuando llega la respuesta, la inyecta como un mensaje con rol .assistant.

ChatKit no sabe:

Solo renderiza lo que recibe.

Aquí el objetivo es otro: demostrar que el diseño funciona cuando deja de ser teórico.

Y funciona.


Conclusión

ChatKit no intenta ser un “chat inteligente”.
Intenta ser una UI predecible en un ecosistema lleno de suposiciones implícitas.

Separar la UI del backend no es una optimización prematura.
Es una decisión estructural.

Y, en proyectos reales, suele marcar la diferencia.


🚧 Status

Este proyecto está en desarrollo activo.
La API pública no se considera estable antes de la versión 1.0.

Eugenio

Autor

Eugenio

Desarrllador iOS. Swift, SwiftUI y Core ML. Enfoque en IA integrada y arquitectura de producto.