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: noaltrimenti il proxy ingoia lo streaming.- Un prompt di sistema preciso + un contesto iniettato = un assistente che resta nel suo ruolo.