Créer des interfaces web conversationnelles pour LLM
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.

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
| Approche | Temps Dev | Flexibilité | 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
| Solution | Temps Dev | Production | Meilleur Pour |
|---|---|---|---|
| Streamlit | 30 min | ⭐⭐⭐ | POC, démos internes |
| Gradio | 1h | ⭐⭐⭐ | Partage HuggingFace |
| ChainLit | 3h | ⭐⭐⭐⭐ | Apps LangChain/RAG |
| Next.js | 2 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 :
- Prototype (Streamlit) → 1 jour
- Validation utilisateurs → 1 semaine
- Production (Next.js) → 2 semaines
Prochaines étapes
- Choisir votre stack selon vos contraintes
- Implémenter un MVP avec examples de cet article
- Tester avec vrais utilisateurs et itérer
- Scaler quand le besoin se présente
Pour aller plus loin :