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: no sinon le proxy avale le streaming.
  • Un prompt système précis + un contexte injecté = un assistant qui reste dans son rôle.