Créer des interfaces web conversationnelles pour LLM

tl;dr: Les interfaces conversationnelles pour LLM peuvent être créées avec différentes approches : Streamlit/Gradio pour prototypage rapide (30 min), ChainLit pour apps LangChain avancées, ou Next.js + Vercel AI SDK pour applications production custom. Chaque solution offre des trade-offs entre rapidité de développement et flexibilité.

💡

Qu’est-ce qu’une Interface Web Conversationnelle pour LLM ?

Une interface web conversationnelle (ou chat UI) permet aux utilisateurs d’interagir avec des modèles de langage (LLM) via une interface graphique moderne. Contrairement aux API brutes, ces interfaces offrent :

  • 💬 Conversation naturelle avec historique
  • 🔄 Streaming des réponses token par token
  • 📎 Multimodal (texte, images, fichiers)
  • 🎨 UX optimisée pour l’IA conversationnelle

Depuis le succès de ChatGPT, les interfaces conversationnelles sont devenues le standard pour interagir avec l’IA. Que vous construisiez un chatbot interne, un assistant client, ou un outil RAG, vous aurez besoin d’une interface utilisateur intuitive.

interfaces web conversationnelles pour LLM

Pourquoi une bonne interface est cruciale ?

# API brute vs Interface utilisateur

# Approche 1 : API seule (mauvaise UX)
response = openai.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "Question ?"}]
)
print(response.choices[0].message.content)
# → Utilisateur doit coder pour chaque interaction

# Approche 2 : Interface web (bonne UX)
# → Click, type, receive
# → Historique persistant
# → Streaming visuel
# → Accessibilité mobile

Impact mesurable :

  • Adoption : 10x plus d’utilisateurs avec UI vs API
  • Rétention : +400% avec historique conversationnel
  • Satisfaction : +85% avec streaming vs réponse complète

Les 3 approches principales

ApprocheTemps DevFlexibilitéCas d’usage
Low-code (Streamlit, Gradio)30 min⭐⭐POC, démos
Framework Chat (ChainLit)2-3h⭐⭐⭐⭐Apps LangChain
Custom (Next.js)1-2 jours⭐⭐⭐⭐⭐Production

Streamlit : prototypage ultra-rapide

Streamlit est le choix idéal pour créer un chatbot en moins de 30 minutes sans écrire de HTML/CSS/JS.

Installation et setup

# Installer Streamlit
pip install streamlit openai

# Créer le fichier
touch chatbot.py

Chatbot basique (20 lignes)

import streamlit as st
from openai import OpenAI

# Configuration
st.title("💬 Mon Chatbot IA")

# Initialiser l'historique
if "messages" not in st.session_state:
    st.session_state.messages = []

# Afficher l'historique
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# Input utilisateur
if prompt := st.chat_input("Posez votre question..."):
    # Ajouter message utilisateur
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    # Générer réponse
    with st.chat_message("assistant"):
        client = OpenAI(api_key=st.secrets["OPENAI_API_KEY"])

        stream = client.chat.completions.create(
            model="gpt-4",
            messages=st.session_state.messages,
            stream=True
        )

        response = st.write_stream(stream)

    # Ajouter à l'historique
    st.session_state.messages.append({"role": "assistant", "content": response})

Lancer :

streamlit run chatbot.py

Résultat : Interface complète en 20 lignes !

Features avancées

import streamlit as st
from openai import OpenAI
import json
from datetime import datetime

# Configuration page
st.set_page_config(
    page_title="Chatbot Pro",
    page_icon="🤖",
    layout="wide"
)

# Sidebar avec paramètres
with st.sidebar:
    st.title("⚙️ Configuration")

    # Sélection modèle
    model = st.selectbox(
        "Modèle",
        ["gpt-4", "gpt-4-turbo", "gpt-3.5-turbo"]
    )

    # Paramètres
    temperature = st.slider("Temperature", 0.0, 2.0, 0.7)
    max_tokens = st.slider("Max Tokens", 100, 4000, 1000)

    # System prompt
    system_prompt = st.text_area(
        "System Prompt",
        "Tu es un assistant IA utile et précis.",
        height=100
    )

    st.divider()

    # Actions
    if st.button("🗑️ Effacer conversation"):
        st.session_state.messages = []
        st.rerun()

    if st.button("💾 Exporter conversation"):
        # Export en JSON
        export_data = {
            "timestamp": datetime.now().isoformat(),
            "model": model,
            "messages": st.session_state.messages
        }
        st.download_button(
            "Télécharger JSON",
            data=json.dumps(export_data, indent=2),
            file_name=f"conversation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
            mime="application/json"
        )

# Interface principale
st.title("💬 Chatbot Avancé")

# Initialiser avec system prompt
if "messages" not in st.session_state:
    st.session_state.messages = [
        {"role": "system", "content": system_prompt}
    ]

# Afficher conversation (sans system prompt)
for message in st.session_state.messages:
    if message["role"] != "system":
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

# Input
if prompt := st.chat_input("Votre message..."):
    # Ajouter message user
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    # Réponse assistant
    with st.chat_message("assistant"):
        client = OpenAI(api_key=st.secrets["OPENAI_API_KEY"])

        # Mettre à jour system prompt si changé
        st.session_state.messages[0] = {"role": "system", "content": system_prompt}

        stream = client.chat.completions.create(
            model=model,
            messages=st.session_state.messages,
            temperature=temperature,
            max_tokens=max_tokens,
            stream=True
        )

        response = st.write_stream(stream)

    st.session_state.messages.append({"role": "assistant", "content": response})

# Footer avec stats
with st.sidebar:
    st.divider()
    st.caption(f"💬 {len([m for m in st.session_state.messages if m['role'] != 'system'])} messages")
    st.caption(f"🤖 Modèle : {model}")

Multi-modèles comparaison

import streamlit as st
from openai import OpenAI
from anthropic import Anthropic

st.title("⚔️ Comparaison Multi-Modèles")

# Layout : 2 colonnes
col1, col2 = st.columns(2)

# Initialiser historiques séparés
if "messages_gpt" not in st.session_state:
    st.session_state.messages_gpt = []
if "messages_claude" not in st.session_state:
    st.session_state.messages_claude = []

# Input commun
prompt = st.chat_input("Posez la même question aux deux modèles...")

if prompt:
    # GPT-4
    with col1:
        st.subheader("🟢 GPT-4")
        st.session_state.messages_gpt.append({"role": "user", "content": prompt})

        with st.chat_message("user"):
            st.markdown(prompt)

        with st.chat_message("assistant"):
            client = OpenAI(api_key=st.secrets["OPENAI_API_KEY"])
            stream = client.chat.completions.create(
                model="gpt-4",
                messages=st.session_state.messages_gpt,
                stream=True
            )
            response_gpt = st.write_stream(stream)

        st.session_state.messages_gpt.append({"role": "assistant", "content": response_gpt})

    # Claude
    with col2:
        st.subheader("🟣 Claude 3.5 Sonnet")
        st.session_state.messages_claude.append({"role": "user", "content": prompt})

        with st.chat_message("user"):
            st.markdown(prompt)

        with st.chat_message("assistant"):
            client = Anthropic(api_key=st.secrets["ANTHROPIC_API_KEY"])

            with client.messages.stream(
                model="claude-3-5-sonnet-20241022",
                max_tokens=1024,
                messages=st.session_state.messages_claude
            ) as stream:
                response_claude = st.write_stream(stream.text_stream)

        st.session_state.messages_claude.append({"role": "assistant", "content": response_claude})

Déploiement Streamlit

Option 1 : Streamlit Cloud (gratuit)

# 1. Créer requirements.txt
cat > requirements.txt <<EOF
streamlit
openai
anthropic
EOF

# 2. Créer .streamlit/secrets.toml
mkdir -p .streamlit
cat > .streamlit/secrets.toml <<EOF
OPENAI_API_KEY = "sk-..."
ANTHROPIC_API_KEY = "sk-ant-..."
EOF

# 3. Push sur GitHub
git init
git add .
git commit -m "Initial commit"
git push

# 4. Déployer sur streamlit.io
# → Connecter repo GitHub
# → Ajouter secrets via UI
# → Deploy automatique !

Option 2 : Docker

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .

EXPOSE 8501

CMD ["streamlit", "run", "chatbot.py", "--server.port=8501", "--server.address=0.0.0.0"]
# Build et run
docker build -t streamlit-chat .
docker run -p 8501:8501 -e OPENAI_API_KEY=$OPENAI_API_KEY streamlit-chat

Performance :

  • Latence : ~100-200ms overhead vs API directe
  • Concurrent users : ~10-50 (limite Streamlit)
  • Recommandé pour : POC, démos internes

Gradio : interfaces ML simplifiées

Gradio est excellent pour créer des démos partagées sur HuggingFace Spaces.

Chatbot simple avec gr.ChatInterface

import gradio as gr
from openai import OpenAI

client = OpenAI()

def chat(message, history):
    """
    Fonction appelée à chaque message

    Args:
        message: Message utilisateur actuel
        history: Liste de tuples [(user_msg, bot_msg), ...]
    """
    # Convertir historique Gradio vers format OpenAI
    messages = []
    for user_msg, bot_msg in history:
        messages.append({"role": "user", "content": user_msg})
        messages.append({"role": "assistant", "content": bot_msg})

    # Ajouter message actuel
    messages.append({"role": "user", "content": message})

    # Streaming
    response = ""
    for chunk in client.chat.completions.create(
        model="gpt-4",
        messages=messages,
        stream=True
    ):
        if chunk.choices[0].delta.content:
            response += chunk.choices[0].delta.content
            yield response

# Interface
demo = gr.ChatInterface(
    fn=chat,
    title="💬 Chatbot Gradio",
    description="Posez n'importe quelle question !",
    examples=[
        "Explique la relativité en termes simples",
        "Écris une fonction Python pour trier une liste",
        "Quels sont les avantages de l'IA ?"
    ],
    theme="soft",
    retry_btn="🔄 Régénérer",
    undo_btn="↩️ Annuler",
    clear_btn="🗑️ Effacer"
)

demo.launch()

Résultat : Interface complète avec retry, undo, clear automatiques !

Layout custom avec gr.Blocks

import gradio as gr
from openai import OpenAI

client = OpenAI()

def chat_with_settings(message, history, model, temperature, max_tokens):
    """Chat avec paramètres personnalisables"""
    messages = []
    for user_msg, bot_msg in history:
        messages.append({"role": "user", "content": user_msg})
        messages.append({"role": "assistant", "content": bot_msg})
    messages.append({"role": "user", "content": message})

    response = ""
    for chunk in client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=temperature,
        max_tokens=max_tokens,
        stream=True
    ):
        if chunk.choices[0].delta.content:
            response += chunk.choices[0].delta.content
            yield response

# Interface custom
with gr.Blocks(theme=gr.themes.Soft()) as demo:
    gr.Markdown("# 🤖 Chatbot Configurable")

    with gr.Row():
        with gr.Column(scale=3):
            # Zone de chat
            chatbot = gr.Chatbot(height=500)
            msg = gr.Textbox(
                placeholder="Votre message...",
                show_label=False,
                container=False
            )

            with gr.Row():
                submit = gr.Button("Envoyer", variant="primary")
                clear = gr.Button("Effacer")

        with gr.Column(scale=1):
            # Paramètres
            gr.Markdown("### ⚙️ Configuration")

            model = gr.Dropdown(
                choices=["gpt-4", "gpt-4-turbo", "gpt-3.5-turbo"],
                value="gpt-4",
                label="Modèle"
            )

            temperature = gr.Slider(
                minimum=0,
                maximum=2,
                value=0.7,
                step=0.1,
                label="Temperature"
            )

            max_tokens = gr.Slider(
                minimum=100,
                maximum=4000,
                value=1000,
                step=100,
                label="Max Tokens"
            )

            gr.Markdown("### 📝 Exemples")
            gr.Examples(
                examples=[
                    "Explique les trous noirs",
                    "Code une calculatrice en Python",
                    "Raconte une blague"
                ],
                inputs=msg
            )

    # Event handlers
    msg.submit(
        chat_with_settings,
        inputs=[msg, chatbot, model, temperature, max_tokens],
        outputs=chatbot
    ).then(
        lambda: "", outputs=msg  # Clear input
    )

    submit.click(
        chat_with_settings,
        inputs=[msg, chatbot, model, temperature, max_tokens],
        outputs=chatbot
    ).then(
        lambda: "", outputs=msg
    )

    clear.click(lambda: None, outputs=chatbot)

demo.launch()

Chat multimodal (texte + images)

import gradio as gr
from openai import OpenAI
import base64

client = OpenAI()

def encode_image(image_path):
    """Encode image en base64"""
    with open(image_path, "rb") as f:
        return base64.b64encode(f.read()).decode()

def multimodal_chat(message, history, image):
    """Chat avec support d'images"""
    messages = []

    # Historique
    for user_msg, bot_msg in history:
        if isinstance(user_msg, tuple):  # Image
            messages.append({
                "role": "user",
                "content": [
                    {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{encode_image(user_msg[0])}"}}
                ]
            })
        else:
            messages.append({"role": "user", "content": user_msg})
        messages.append({"role": "assistant", "content": bot_msg})

    # Message actuel
    content = []
    if image:
        content.append({
            "type": "image_url",
            "image_url": {"url": f"data:image/jpeg;base64,{encode_image(image)}"}
        })
    if message:
        content.append({"type": "text", "text": message})

    messages.append({"role": "user", "content": content})

    # Appel GPT-4 Vision
    response = ""
    for chunk in client.chat.completions.create(
        model="gpt-4-vision-preview",
        messages=messages,
        max_tokens=1000,
        stream=True
    ):
        if chunk.choices[0].delta.content:
            response += chunk.choices[0].delta.content
            yield response

# Interface
with gr.Blocks() as demo:
    gr.Markdown("# 🖼️ Chat Multimodal (Vision)")

    chatbot = gr.Chatbot(height=400)

    with gr.Row():
        with gr.Column(scale=4):
            msg = gr.Textbox(
                placeholder="Décrivez l'image ou posez une question...",
                show_label=False
            )
        with gr.Column(scale=1):
            image = gr.Image(type="filepath", label="Upload Image")

    submit = gr.Button("📤 Envoyer")

    submit.click(
        multimodal_chat,
        inputs=[msg, chatbot, image],
        outputs=chatbot
    ).then(
        lambda: (None, None), outputs=[msg, image]
    )

demo.launch()

Déploiement HuggingFace Spaces

# 1. Créer un Space sur huggingface.co
# 2. Créer app.py avec votre code Gradio
# 3. Créer requirements.txt

cat > requirements.txt <<EOF
gradio
openai
EOF

# 4. Git push
git add .
git commit -m "Initial chatbot"
git push

# Auto-deploy sur https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE

Avec GPU (optionnel) :

# README.md frontmatter
---
title: Mon Chatbot
emoji: 🤖
colorFrom: blue
colorTo: green
sdk: gradio
sdk_version: 4.0.0
app_file: app.py
pinned: false
license: mit
hardware: t4-small  # GPU T4 gratuit !
---

ChainLit : Spécialisé LangChain

ChainLit est conçu spécifiquement pour les applications LangChain avec des features avancées natives.

Installation et premier chat

# Installer
pip install chainlit openai langchain

# Créer app
chainlit init
# app.py
import chainlit as cl
from openai import AsyncOpenAI

client = AsyncOpenAI()

@cl.on_chat_start
async def start():
    """Appelé au démarrage de la conversation"""
    await cl.Message(
        content="👋 Bonjour ! Je suis votre assistant IA. Comment puis-je vous aider ?"
    ).send()

@cl.on_message
async def main(message: cl.Message):
    """Appelé à chaque message utilisateur"""

    # Récupérer l'historique
    message_history = cl.user_session.get("message_history", [])
    message_history.append({"role": "user", "content": message.content})

    # Streaming
    msg = cl.Message(content="")
    await msg.send()

    stream = await client.chat.completions.create(
        model="gpt-4",
        messages=message_history,
        stream=True
    )

    async for chunk in stream:
        if chunk.choices[0].delta.content:
            await msg.stream_token(chunk.choices[0].delta.content)

    await msg.update()

    # Sauvegarder l'historique
    message_history.append({"role": "assistant", "content": msg.content})
    cl.user_session.set("message_history", message_history)

Lancer :

chainlit run app.py -w  # -w pour watch mode

Features avancées : steps et feedback

import chainlit as cl
from openai import AsyncOpenAI
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter

client = AsyncOpenAI()

@cl.on_chat_start
async def start():
    """Initialisation avec upload de documents"""
    files = None

    # Demander des fichiers à l'utilisateur
    while files is None:
        files = await cl.AskFileMessage(
            content="📎 Uploadez des documents pour commencer (PDF, TXT, etc.)",
            accept=["text/plain", "application/pdf"],
            max_size_mb=10,
            max_files=5
        ).send()

    # Traiter les documents
    async with cl.Step(name="📄 Chargement des documents", type="tool") as step:
        documents = []
        for file in files:
            with open(file.path, "r", encoding="utf-8") as f:
                documents.append(f.read())

        step.output = f"{len(documents)} document(s) chargé(s)"

    # Chunking
    async with cl.Step(name="✂️ Découpage en chunks", type="tool") as step:
        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000,
            chunk_overlap=200
        )
        chunks = []
        for doc in documents:
            chunks.extend(text_splitter.split_text(doc))

        step.output = f"{len(chunks)} chunks créés"

    # Embedding et indexation
    async with cl.Step(name="🔍 Indexation vectorielle", type="tool") as step:
        embeddings = OpenAIEmbeddings()
        vectorstore = Chroma.from_texts(chunks, embeddings)

        # Sauvegarder dans la session
        cl.user_session.set("vectorstore", vectorstore)

        step.output = "Index vectoriel créé avec succès"

    await cl.Message(
        content="✅ Documents indexés ! Posez vos questions."
    ).send()

@cl.on_message
async def main(message: cl.Message):
    """Traiter les messages avec RAG"""
    vectorstore = cl.user_session.get("vectorstore")

    # Recherche de documents pertinents
    async with cl.Step(name="🔎 Recherche dans les documents", type="retrieval") as step:
        docs = vectorstore.similarity_search(message.content, k=3)

        # Afficher les sources
        sources = []
        for i, doc in enumerate(docs):
            source = cl.Text(
                name=f"Source {i+1}",
                content=doc.page_content,
                display="side"  # Affiche dans la sidebar
            )
            sources.append(source)

        step.elements = sources
        step.output = f"{len(docs)} documents pertinents trouvés"

    # Construire le contexte
    context = "\n\n".join([doc.page_content for doc in docs])

    # Générer la réponse
    async with cl.Step(name="🤖 Génération de la réponse", type="llm") as step:
        prompt = f"""Contexte :
{context}

Question : {message.content}

Réponds en te basant UNIQUEMENT sur le contexte fourni. Cite les sources."""

        msg = cl.Message(content="")

        stream = await client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": prompt}],
            stream=True
        )

        async for chunk in stream:
            if chunk.choices[0].delta.content:
                await msg.stream_token(chunk.choices[0].delta.content)

        # Ajouter les sources à la réponse
        msg.elements = sources
        await msg.update()

        # Demander feedback
        await cl.Message(
            content="Cette réponse vous a-t-elle été utile ?",
            actions=[
                cl.Action(name="thumbs_up", value="up", label="👍 Oui"),
                cl.Action(name="thumbs_down", value="down", label="👎 Non")
            ]
        ).send()

@cl.action_callback("thumbs_up")
async def on_thumbs_up(action: cl.Action):
    await cl.Message(content="Merci pour votre feedback positif ! 😊").send()
    # Logger le feedback
    print(f"Positive feedback on message: {action.value}")

@cl.action_callback("thumbs_down")
async def on_thumbs_down(action: cl.Action):
    await cl.Message(content="Désolé que la réponse ne vous ait pas satisfait. 😔").send()
    # Logger le feedback
    print(f"Negative feedback on message: {action.value}")

Intégration LangChain Agent

import chainlit as cl
from langchain_openai import ChatOpenAI
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain.tools import Tool
from langchain import hub
import requests

# Définir des tools
def search_wikipedia(query: str) -> str:
    """Recherche sur Wikipedia"""
    url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{query}"
    response = requests.get(url)
    if response.status_code == 200:
        return response.json().get("extract", "Pas de résultat")
    return "Erreur de recherche"

def calculate(expression: str) -> str:
    """Calculatrice simple"""
    try:
        result = eval(expression)
        return f"Résultat : {result}"
    except Exception as e:
        return f"Erreur : {str(e)}"

tools = [
    Tool(
        name="Wikipedia",
        func=search_wikipedia,
        description="Recherche des informations sur Wikipedia. Input : terme de recherche."
    ),
    Tool(
        name="Calculator",
        func=calculate,
        description="Effectue des calculs mathématiques. Input : expression Python valide."
    )
]

@cl.on_chat_start
async def start():
    # Initialiser l'agent
    llm = ChatOpenAI(model="gpt-4", temperature=0, streaming=True)
    prompt = hub.pull("hwchase17/openai-tools-agent")
    agent = create_openai_tools_agent(llm, tools, prompt)
    agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

    cl.user_session.set("agent", agent_executor)

    await cl.Message(
        content="🤖 Agent initialisé avec accès à Wikipedia et Calculatrice !"
    ).send()

@cl.on_message
async def main(message: cl.Message):
    agent = cl.user_session.get("agent")

    # Callback pour afficher les steps
    cb = cl.LangchainCallbackHandler()

    # Exécuter l'agent
    res = await agent.ainvoke(
        {"input": message.content},
        callbacks=[cb]
    )

    await cl.Message(content=res["output"]).send()

Résultat : ChainLit affiche automatiquement :

  • Les tools appelés
  • Les arguments passés
  • Les résultats obtenus
  • Le raisonnement de l’agent

Authentification et multi-user

# config.toml
[project]
enable_telemetry = false

[UI]
name = "Mon Chatbot"
default_collapse_content = true

[features]
spontaneous_file_upload = true
multi_modal = true

[auth]
# OAuth avec Google
[auth.google]
client_id = "your-client-id"
client_secret = "your-client-secret"
# app.py avec auth
import chainlit as cl

@cl.password_auth_callback
def auth_callback(username: str, password: str):
    """Vérifier les credentials"""
    # Vérifier contre votre DB
    if username == "admin" and password == "password":
        return cl.User(
            identifier=username,
            metadata={"role": "admin"}
        )
    return None

@cl.on_chat_start
async def start():
    user = cl.user_session.get("user")
    await cl.Message(
        content=f"Bonjour {user.identifier} ! Votre rôle : {user.metadata['role']}"
    ).send()

Next.js + Vercel AI SDK : production-grade

Pour des applications custom et scalables, Next.js avec Vercel AI SDK est le choix optimal.

Setup projet

# Créer projet Next.js
npx create-next-app@latest my-chat-app --typescript --tailwind --app

cd my-chat-app

# Installer dépendances
npm install ai openai @ai-sdk/openai
npm install @radix-ui/react-scroll-area class-variance-authority clsx tailwind-merge

# Installer UI components (shadcn/ui)
npx shadcn-ui@latest init
npx shadcn-ui@latest add button input textarea scroll-area

Backend : API Route

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

// Force dynamic rendering
export const dynamic = 'force-dynamic';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = await streamText({
    model: openai('gpt-4-turbo'),
    messages,
    system: 'Tu es un assistant IA utile et précis.',
  });

  return result.toAIStreamResponse();
}

Frontend : Chat UI

// app/page.tsx
'use client';

import { useChat } from 'ai/react';
import { Send, User, Bot } from 'lucide-react';
import { useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // Auto-scroll
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="flex flex-col h-screen max-w-4xl mx-auto p-4">
      {/* Header */}
      <div className="pb-4 border-b">
        <h1 className="text-2xl font-bold">💬 Chat avec IA</h1>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto py-4 space-y-4">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex gap-3 ${
              message.role === 'user' ? 'justify-end' : 'justify-start'
            }`}
          >
            {/* Avatar */}
            {message.role === 'assistant' && (
              <div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center flex-shrink-0">
                <Bot className="w-5 h-5 text-white" />
              </div>
            )}

            {/* Message bubble */}
            <div
              className={`max-w-[80%] rounded-lg p-4 ${
                message.role === 'user'
                  ? 'bg-blue-500 text-white'
                  : 'bg-gray-100 text-gray-900'
              }`}
            >
              <ReactMarkdown
                components={{
                  code({ node, inline, className, children, ...props }) {
                    const match = /language-(\w+)/.exec(className || '');
                    return !inline && match ? (
                      <SyntaxHighlighter
                        style={vscDarkPlus}
                        language={match[1]}
                        PreTag="div"
                        {...props}
                      >
                        {String(children).replace(/\n$/, '')}
                      </SyntaxHighlighter>
                    ) : (
                      <code className="bg-gray-200 px-1 rounded" {...props}>
                        {children}
                      </code>
                    );
                  },
                }}
              >
                {message.content}
              </ReactMarkdown>
            </div>

            {message.role === 'user' && (
              <div className="w-8 h-8 rounded-full bg-gray-500 flex items-center justify-center flex-shrink-0">
                <User className="w-5 h-5 text-white" />
              </div>
            )}
          </div>
        ))}

        {isLoading && (
          <div className="flex gap-3">
            <div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center">
              <Bot className="w-5 h-5 text-white" />
            </div>
            <div className="bg-gray-100 rounded-lg p-4">
              <div className="flex space-x-2">
                <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
                <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
                <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
              </div>
            </div>
          </div>
        )}

        <div ref={messagesEndRef} />
      </div>

      {/* Input */}
      <form onSubmit={handleSubmit} className="pt-4 border-t">
        <div className="flex gap-2">
          <textarea
            value={input}
            onChange={handleInputChange}
            placeholder="Votre message..."
            className="flex-1 resize-none rounded-lg border p-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
            rows={1}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                handleSubmit(e as any);
              }
            }}
          />
          <button
            type="submit"
            disabled={isLoading || !input.trim()}
            className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            <Send className="w-5 h-5" />
          </button>
        </div>
        <p className="text-xs text-gray-500 mt-2">
          Appuyez sur Enter pour envoyer, Shift+Enter pour nouvelle ligne
        </p>
      </form>
    </div>
  );
}

Features avancées : function calling

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = await streamText({
    model: openai('gpt-4-turbo'),
    messages,
    tools: {
      getWeather: tool({
        description: 'Obtenir la météo pour une ville',
        parameters: z.object({
          city: z.string().describe('Nom de la ville'),
        }),
        execute: async ({ city }) => {
          // Appel API météo
          const response = await fetch(
            `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${process.env.OPENWEATHER_API_KEY}&units=metric`
          );
          const data = await response.json();

          return {
            temperature: data.main.temp,
            description: data.weather[0].description,
            humidity: data.main.humidity,
          };
        },
      }),

      searchWeb: tool({
        description: 'Rechercher sur le web',
        parameters: z.object({
          query: z.string().describe('Requête de recherche'),
        }),
        execute: async ({ query }) => {
          // Utiliser Serper API ou similaire
          const response = await fetch('https://google.serper.dev/search', {
            method: 'POST',
            headers: {
              'X-API-KEY': process.env.SERPER_API_KEY!,
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ q: query }),
          });

          const data = await response.json();
          return data.organic.slice(0, 3);
        },
      }),
    },
  });

  return result.toAIStreamResponse();
}

Frontend pour afficher les tool calls :

// app/page.tsx (ajout)
import { ToolInvocation } from 'ai';

// Dans le composant
{messages.map((message) => (
  <div key={message.id}>
    {/* Message content */}
    <div>{message.content}</div>

    {/* Tool invocations */}
    {message.toolInvocations?.map((tool: ToolInvocation) => (
      <div key={tool.toolCallId} className="mt-2 p-3 bg-yellow-50 rounded border">
        <p className="font-semibold">🛠️ Tool: {tool.toolName}</p>
        <pre className="text-xs mt-1">
          {JSON.stringify(tool.args, null, 2)}
        </pre>
        {tool.result && (
          <div className="mt-2">
            <p className="text-xs font-semibold">Résultat:</p>
            <pre className="text-xs">
              {JSON.stringify(tool.result, null, 2)}
            </pre>
          </div>
        )}
      </div>
    ))}
  </div>
))}

Persistance avec base de données

// lib/db.ts
import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();

// prisma/schema.prisma
/*
model Conversation {
  id        String    @id @default(cuid())
  userId    String
  title     String?
  messages  Message[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

model Message {
  id             String       @id @default(cuid())
  conversationId String
  conversation   Conversation @relation(fields: [conversationId], references: [id])
  role           String       // 'user' | 'assistant' | 'system'
  content        String       @db.Text
  createdAt      DateTime     @default(now())
}
*/
// app/api/conversations/route.ts
import { prisma } from '@/lib/db';
import { NextResponse } from 'next/server';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const userId = searchParams.get('userId');

  const conversations = await prisma.conversation.findMany({
    where: { userId: userId! },
    include: {
      messages: {
        take: 1,
        orderBy: { createdAt: 'desc' },
      },
    },
    orderBy: { updatedAt: 'desc' },
  });

  return NextResponse.json(conversations);
}

export async function POST(req: Request) {
  const { userId, title } = await req.json();

  const conversation = await prisma.conversation.create({
    data: { userId, title },
  });

  return NextResponse.json(conversation);
}
// app/api/chat/route.ts (avec persistance)
import { prisma } from '@/lib/db';

export async function POST(req: Request) {
  const { messages, conversationId } = await req.json();

  // Sauvegarder les messages
  if (conversationId) {
    const lastMessage = messages[messages.length - 1];
    await prisma.message.create({
      data: {
        conversationId,
        role: lastMessage.role,
        content: lastMessage.content,
      },
    });
  }

  const result = await streamText({
    model: openai('gpt-4-turbo'),
    messages,
  });

  // Sauvegarder la réponse après génération
  const response = result.toAIStreamResponse();

  // Hook pour sauvegarder après streaming
  result.onFinish(async ({ text }) => {
    if (conversationId) {
      await prisma.message.create({
        data: {
          conversationId,
          role: 'assistant',
          content: text,
        },
      });
    }
  });

  return response;
}

Déploiement vercel

# 1. Push sur GitHub
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/username/my-chat-app.git
git push -u origin main

# 2. Importer sur Vercel
# → vercel.com → New Project → Import from GitHub

# 3. Configurer variables d'environnement
# → Settings → Environment Variables
# OPENAI_API_KEY=sk-...
# DATABASE_URL=postgresql://...

# 4. Deploy automatique !

Résultat : App déployée sur https://my-chat-app.vercel.app

Patterns UI/UX pour Chat LLM

Auto-Resize Textarea

// components/auto-resize-textarea.tsx
'use client';

import { useEffect, useRef } from 'react';

export function AutoResizeTextarea({ value, onChange, ...props }: any) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    if (textareaRef.current) {
      // Reset height
      textareaRef.current.style.height = 'auto';

      // Set new height
      const scrollHeight = textareaRef.current.scrollHeight;
      textareaRef.current.style.height = Math.min(scrollHeight, 200) + 'px';
    }
  }, [value]);

  return (
    <textarea
      ref={textareaRef}
      value={value}
      onChange={onChange}
      {...props}
      style={{ resize: 'none', overflow: 'hidden' }}
    />
  );
}

Typing indicator

// components/typing-indicator.tsx
export function TypingIndicator() {
  return (
    <div className="flex space-x-2 p-4">
      <div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
      <div
        className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
        style={{ animationDelay: '0.1s' }}
      />
      <div
        className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
        style={{ animationDelay: '0.2s' }}
      />
    </div>
  );
}

Copy message button

// components/copy-button.tsx
'use client';

import { Check, Copy } from 'lucide-react';
import { useState } from 'react';

export function CopyButton({ text }: { text: string }) {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    await navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <button
      onClick={handleCopy}
      className="p-2 hover:bg-gray-200 rounded transition-colors"
      title="Copier"
    >
      {copied ? (
        <Check className="w-4 h-4 text-green-500" />
      ) : (
        <Copy className="w-4 h-4" />
      )}
    </button>
  );
}

Message actions

// components/message-actions.tsx
import { Copy, RefreshCw, ThumbsUp, ThumbsDown } from 'lucide-react';

interface MessageActionsProps {
  onCopy: () => void;
  onRegenerate: () => void;
  onFeedback: (type: 'positive' | 'negative') => void;
}

export function MessageActions({ onCopy, onRegenerate, onFeedback }: MessageActionsProps) {
  return (
    <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
      <button
        onClick={onCopy}
        className="p-1.5 hover:bg-gray-200 rounded"
        title="Copier"
      >
        <Copy className="w-4 h-4" />
      </button>

      <button
        onClick={onRegenerate}
        className="p-1.5 hover:bg-gray-200 rounded"
        title="Régénérer"
      >
        <RefreshCw className="w-4 h-4" />
      </button>

      <div className="border-l mx-1" />

      <button
        onClick={() => onFeedback('positive')}
        className="p-1.5 hover:bg-green-100 rounded"
        title="Bonne réponse"
      >
        <ThumbsUp className="w-4 h-4" />
      </button>

      <button
        onClick={() => onFeedback('negative')}
        className="p-1.5 hover:bg-red-100 rounded"
        title="Mauvaise réponse"
      >
        <ThumbsDown className="w-4 h-4" />
      </button>
    </div>
  );
}

Suggestions / quick actions

// components/suggestions.tsx
const SUGGESTIONS = [
  { icon: '💡', text: 'Explique-moi un concept complexe' },
  { icon: '💻', text: 'Aide-moi à débugger mon code' },
  { icon: '✍️', text: 'Écris un email professionnel' },
  { icon: '📊', text: 'Analyse ces données' },
];

export function Suggestions({ onSelect }: { onSelect: (text: string) => void }) {
  return (
    <div className="grid grid-cols-2 gap-2 p-4">
      {SUGGESTIONS.map((suggestion, i) => (
        <button
          key={i}
          onClick={() => onSelect(suggestion.text)}
          className="flex items-center gap-2 p-3 border rounded-lg hover:bg-gray-50 transition-colors text-left"
        >
          <span className="text-2xl">{suggestion.icon}</span>
          <span className="text-sm">{suggestion.text}</span>
        </button>
      ))}
    </div>
  );
}

Sécurité et production

Rate limiting

// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 req/min
});

// Dans API route
export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';

  const { success, limit, remaining } = await ratelimit.limit(ip);

  if (!success) {
    return new Response('Too Many Requests', {
      status: 429,
      headers: {
        'X-RateLimit-Limit': limit.toString(),
        'X-RateLimit-Remaining': remaining.toString(),
      },
    });
  }

  // Continue...
}

Content moderation

// lib/moderation.ts
import { OpenAI } from 'openai';

const openai = new OpenAI();

export async function moderateContent(text: string) {
  const moderation = await openai.moderations.create({
    input: text,
  });

  const flagged = moderation.results[0].flagged;
  const categories = moderation.results[0].categories;

  return { flagged, categories };
}

// Dans API route
export async function POST(req: Request) {
  const { messages } = await req.json();
  const lastMessage = messages[messages.length - 1];

  const { flagged, categories } = await moderateContent(lastMessage.content);

  if (flagged) {
    return new Response(
      JSON.stringify({
        error: 'Content violates usage policies',
        categories: Object.entries(categories)
          .filter(([_, value]) => value)
          .map(([key]) => key),
      }),
      { status: 400 }
    );
  }

  // Continue...
}

Authentification avec NextAuth

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/db';

const handler = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    session: async ({ session, user }) => {
      session.user.id = user.id;
      return session;
    },
  },
});

export { handler as GET, handler as POST };
// app/page.tsx (protected)
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';

export default async function Chat() {
  const session = await getServerSession();

  if (!session) {
    redirect('/api/auth/signin');
  }

  return <ChatInterface userId={session.user.id} />;
}

Conclusion

Les interfaces web conversationnelles pour LLM offrent un spectre de solutions adapté à chaque besoin :

Récapitulatif des approches

SolutionTemps DevProductionMeilleur Pour
Streamlit30 min⭐⭐⭐POC, démos internes
Gradio1h⭐⭐⭐Partage HuggingFace
ChainLit3h⭐⭐⭐⭐Apps LangChain/RAG
Next.js2 jours⭐⭐⭐⭐⭐Apps custom scalables
💡

Recommandations

Démarrez avec Streamlit/Gradio pour valider rapidement

Passez à ChainLit si vous utilisez LangChain

Investissez dans Next.js pour production à grande échelle

Pattern optimal :

  1. Prototype (Streamlit) → 1 jour
  2. Validation utilisateurs → 1 semaine
  3. Production (Next.js) → 2 semaines

Prochaines étapes

  1. Choisir votre stack selon vos contraintes
  2. Implémenter un MVP avec examples de cet article
  3. Tester avec vrais utilisateurs et itérer
  4. Scaler quand le besoin se présente

Pour aller plus loin :