Pourquoi un chatbot sur un portfolio
Un portfolio classique est passif : le visiteur lit, puis part. J'ai voulu le rendre conversationnel — un assistant qui répond aux questions sur mon parcours, mes projets et mes compétences, en langage naturel. Le recruteur demande « il a déjà fait du paiement mobile ? », l'assistant répond avec le contexte de mes vrais projets.
L'architecture : jamais la clé API côté client
Règle absolue : la clé API du LLM ne doit jamais être exposée dans le bundle React. Tout passe par un endpoint serveur qui fait proxy.
React (front) ──POST /api/chat──▶ Symfony (proxy) ──▶ API LLM
▲ │
└──────── stream SSE ◀────────────────┘
Le front n'appelle que mon backend ; c'est le backend qui détient la clé et parle au LLM.
Le endpoint Symfony avec streaming
Le streaming (afficher la réponse token par token) change tout pour l'UX : au lieu d'attendre 5 secondes une réponse complète, le texte apparaît en direct. On utilise un StreamedResponse et le mode stream de l'API.
#[Route('/api/chat', name: 'api_chat', methods: ['POST'])]
public function chat(Request $request, ChatService $chat): StreamedResponse
{
$payload = json_decode($request->getContent(), true);
$message = trim($payload['message'] ?? '');
if ($message === '') {
throw new BadRequestHttpException('Message vide.');
}
$response = new StreamedResponse(function () use ($chat, $message) {
foreach ($chat->streamReply($message) as $chunk) {
echo 'data: ' . json_encode(['delta' => $chunk]) . "\n\n";
ob_flush();
flush();
}
echo "data: [DONE]\n\n";
ob_flush();
flush();
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('X-Accel-Buffering', 'no'); // désactive le buffer nginx/apache
$response->headers->set('Cache-Control', 'no-cache');
return $response;
}
Le header X-Accel-Buffering: no est crucial sur un VPS derrière Apache/nginx : sans lui, le proxy met en tampon toute la réponse et le streaming ne se voit pas.
Le service : contexte + garde-fous
Le prompt système ancre l'assistant sur MON contenu et l'empêche de dériver.
final class ChatService
{
private const SYSTEM_PROMPT = << 'system', 'content' => self::SYSTEM_PROMPT],
['role' => 'system', 'content' => "CONTEXTE:\n" . $this->buildContext()],
['role' => 'user', 'content' => $userMessage],
];
// appel API en mode stream -> yield chaque fragment
yield from $this->client->stream($messages);
}
private function buildContext(): string
{
// projets, stack, experiences : injectes comme contexte
return $this->contextRepository->getPortfolioContext();
}
}
Côté React : consommer le flux SSE
async function sendMessage(text, onDelta) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\n\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') return;
const { delta } = JSON.parse(data);
onDelta(delta); // ajoute le fragment a l'affichage
}
}
}
Ce que je retiens
- La clé API reste toujours côté serveur, jamais dans le bundle front.
- Le streaming (SSE) transforme l'UX : réponse perçue comme instantanée.
X-Accel-Buffering: nosinon le proxy avale le streaming.- Un prompt système précis + un contexte injecté = un assistant qui reste dans son rôle.