Perché un chatbot su un portfolio

Un portfolio classico è passivo: il visitatore legge, poi se ne va. Ho voluto renderlo conversazionale — un assistente che risponde alle domande sul mio percorso, i miei progetti e le mie competenze, in linguaggio naturale. Il recruiter chiede «ha già fatto pagamenti mobile?», l'assistente risponde con il contesto dei miei progetti reali.

L'architettura: mai la chiave API lato client

Regola assoluta: la chiave API del LLM non deve mai essere esposta nel bundle React. Tutto passa attraverso un endpoint server che fa da proxy.

React (front)  ──POST /api/chat──▶  Symfony (proxy)  ──▶  API LLM
     ▲                                     │
     └──────── stream SSE ◀────────────────┘

Il front chiama solo il mio backend; è il backend che detiene la chiave e parla con il LLM.

L'endpoint Symfony con streaming

Lo streaming (mostrare la risposta token per token) cambia tutto per l'UX: invece di aspettare 5 secondi una risposta completa, il testo appare in diretta. Si usa una StreamedResponse e la modalità stream dell'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;
}

L'header X-Accel-Buffering: no è cruciale su un VPS dietro Apache/nginx: senza di esso, il proxy mette in buffer tutta la risposta e lo streaming non si vede.

Il servizio: contesto + guardrail

Il prompt di sistema ancora l'assistente al MIO contenuto e gli impedisce di divagare.

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();
    }
}

Lato React: consumare il flusso 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
        }
    }
}

Cosa mi porto a casa

  • La chiave API resta sempre lato server, mai nel bundle front.
  • Lo streaming (SSE) trasforma l'UX: risposta percepita come istantanea.
  • X-Accel-Buffering: no altrimenti il proxy ingoia lo streaming.
  • Un prompt di sistema preciso + un contesto iniettato = un assistente che resta nel suo ruolo.