Costruiamo una chat web in streaming (Proxy Express + JS puro)
In questo tutorial costruirai una piccola pagina di chat in circa 100 righe. Un piccolo server Express conserva la tua chiave segreta e inoltra le richieste a Uncensored Chatbot API, mentre un front-end in JavaScript puro legge la risposta in streaming parola per parola. Nessun framework, nessun passaggio di build e la tua chiave non tocca mai il browser.
Aggiornato
Punti chiave
- Non chiamare mai l'API dal codice del browser; un endpoint server mantiene la chiave privata e ti permette di convalidare l'input.
- Il proxy deve solo far passare gli eventi inviati dal server; la pagina analizza le righe di dati fino a [DONE].
- Usa textContent durante lo streaming e sanitizza prima di renderizzare il markdown.
- Limita la lunghezza della cronologia e max_tokens sul server così un utente non può spendere tutto il tuo saldo.
Cosa stiamo costruendo e perché un proxy
Immagina una singola pagina con una casella di testo e un registro scorrevole. Digiti una riga, la pagina invia la conversazione a /api/chat sul tuo server, e il server la inoltra al server remoto con la chiave reale allegata. La risposta arriva in streaming e ogni frammento appare istantaneamente. Daremo al bot una piccola personalità, un guardiano del faro di nome Wren, così la demo sembrerà viva.
Ti servono Node 18 o superiore (per fetch integrato) e una chiave API. Se non ne hai ancora una, ottieni il credito di prova gratuito dalla pagina chiave: i nuovi account ricevono $0,50 di credito per 7 giorni, senza bisogno di dettagli di pagamento. L'URL di base è https://api.uncensoredchatbotapi.com/v1 e l'ID del modello è uncensored.
Perché ti serve il proxy
È allettante incollare la chiave nel codice front-end e chiamare l'API direttamente. Non farlo. Tutto ciò che viene inviato a un browser può essere letto da chiunque apra gli strumenti di sviluppo e una chiave rubata significa un saldo svuotato. Un proxy risolve questo problema e ti offre tre vantaggi aggiuntivi.
- Segretezza. La chiave vive in una variabile d'ambiente sul server.
- Controllo. Decidi tu il prompt di sistema, la lunghezza della cronologia e
max_tokens, così gli utenti non possono sovrascriverli. - Un luogo per le regole. Limiti per utente, controlli dell'età e policy di logging appartengono tutti a questo livello. La guida alla sicurezza si basa su questo stesso endpoint.
Passo 1: configura il progetto
Crea una cartella, inizializzala come progetto ES-module e installa Express. Tutto il resto è integrato.
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir publicFinirai con due cose: server.js alla radice e una cartella public che contiene la pagina e il suo script.
Passo 2: scrivi il proxy Express
Il server serve file statici ed espone un solo endpoint POST. Leggilo in tre parti. Prima sanitizza la cronologia in arrivo: solo i ruoli user e assistant passano, ogni messaggio è tagliato a 4.000 caratteri e si mantengono solo gli ultimi 30 turni. Secondo, aggiunge il tuo messaggio di sistema, che il browser non può mai cambiare. Terzo, chiama l'endpoint upstream con stream: true e trasmette i byte direttamente indietro.
import express from "express";
const app = express();
app.use(express.json({ limit: "256kb" }));
app.use(express.static("public"));
const UPSTREAM = "https://api.uncensoredchatbotapi.com/v1/chat/completions";
const SYSTEM = "You are Wren, a dry-witted night-shift lighthouse keeper. Stay in character.";
app.post("/api/chat", async (req, res) => {
const history = Array.isArray(req.body.messages) ? req.body.messages : [];
// Keep only well-formed turns and cap the history we forward.
const turns = history
.filter((m) => ["user", "assistant"].includes(m.role) && typeof m.content === "string")
.slice(-30)
.map((m) => ({ role: m.role, content: m.content.slice(0, 4000) }));
if (turns.length === 0) return res.status(400).json({ error: "no messages" });
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "uncensored",
messages: [{ role: "system", content: SYSTEM }, ...turns],
stream: true,
max_tokens: 500,
temperature: 0.8,
}),
});
if (!upstream.ok) {
const info = await upstream.json().catch(() => ({}));
return res.status(upstream.status).json(info);
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
for await (const chunk of upstream.body) res.write(chunk);
res.end();
});
app.listen(3000, () => console.log("http://localhost:3000"));Due scelte valgono la pena di essere spiegate. Passare i byte grezzi significa che non devi capire il formato degli eventi sul server. E restituire lo stato del server remoto per i errori permette alla pagina di reagire in modo sensato; ad esempio, un 402 significa che il tuo saldo è vuoto e un 429 significa che qualcuno sta andando troppo veloce.
Passo 3: la pagina e il lettore di stream
Ora il front-end. Salva questo come public/index.html; è deliberatamente spoglio così puoi ridecorarlo più tardi.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lantern Chat</title>
<style>
body { font: 16px system-ui; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
#log { min-height: 320px; border: 1px solid #ccc; padding: 1rem; white-space: pre-wrap; }
.me { color: #234; font-weight: 600; }
form { display: flex; gap: .5rem; margin-top: 1rem; }
input { flex: 1; padding: .6rem; }
</style>
</head>
<body>
<h1>Lantern Chat</h1>
<div id="log"></div>
<form id="form">
<input id="text" autocomplete="off" placeholder="Say something...">
<button>Send</button>
</form>
<script src="chat.js"></script>
</body>
</html>Successivamente, public/chat.js. La parte interessante è il ciclo di lettura. I blocchi di rete non rispettano i limiti di riga, quindi manteniamo un buffer, dividiamo per newline e tratteniamo l'ultima riga parziale finché non arrivano più dati. Ogni riga completa che inizia con data: è JSON, tranne il marcatore finale [DONE].
const log = document.getElementById("log");
const form = document.getElementById("form");
const input = document.getElementById("text");
const history = [];
function addLine(cls, text) {
const div = document.createElement("div");
div.className = cls;
div.textContent = text; // textContent, never innerHTML, for untrusted text
log.appendChild(div);
return div;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
history.push({ role: "user", content: text });
addLine("me", "You: " + text);
const bubble = addLine("bot", "");
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
});
if (!res.ok) {
bubble.textContent = "(the chat is unavailable right now)";
history.pop();
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", reply = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep a partial line for the next read
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
const json = JSON.parse(data);
const piece = json.choices?.[0]?.delta?.content;
if (piece) {
reply += piece;
bubble.textContent = reply;
}
}
}
history.push({ role: "assistant", content: reply });
});Nota l'array history. L'API non conserva memoria tra le chiamate, quindi la pagina reinvia l'intera conversazione ogni volta e il server la taglia. Avvia l'app e aprila in un browser:
export API_KEY="paste-your-key-here"
node server.js
Una nota sul rendering del markdown
I modelli di chat amano gli asterischi, le liste e le occasionali barriere di codice. La nostra demo mostra testo grezzo, che è sicuro. Quando vuoi un output bello, renderizza il markdown con una libreria, ma segui due regole. Esegui l'HTML attraverso un sanitizer prima di inserirlo con innerHTML, poiché un modello, o un utente che lo inganna, può emettere tag e attributi che non hai intenzione. E renderizza incrementalmente con cura: riprocessare l'intera risposta su ogni frammento va bene per messaggi brevi, ma il markdown incompleto può sfarfallare, quindi alcuni blocchi di codice mostrano testo grezzo durante lo streaming e passano all'output formattato quando lo streaming termina.
La formattazione del gioco di ruolo è una stranezza separata. Molti personaggi racchiudono le azioni tra asterischi, come *aggiusta la lampada*. Decidi se la tua app stili quelli come corsivi, e indica al personaggio nel prompt di sistema quale convenzione seguire. La nostra guida al design della persona mostra il wording del prompt per quello.
Passo 4: test di fumo dell'endpoint
Prima di dare la colpa al browser, prova il proxy direttamente. Se il terminale funziona, il server è a posto e qualsiasi bug rimanente vive nella pagina.
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'Il flag -N disabilita il buffering di curl, quindi dovresti vedere le righe data: apparire gradualmente, terminando con data: [DONE]. Se ottieni un errore JSON invece, leggi il suo stato: 401 significa che la chiave nel tuo ambiente è sbagliata, 402 significa che il saldo è vuoto, e un 404 significa che l'URL del server remoto ha un errore di battitura.
| Sintomo | Causa probabile | Soluzione |
|---|---|---|
| La pagina mostra il messaggio non disponibile | Il server ha restituito un codice di stato diverso da 200 | Esegui il test curl e leggi lo status |
| Il testo appare solo alla fine | Uno strato proxy sta facendo buffering alla risposta | Disabilita il buffering per l'endpoint |
| Caratteri corrotti | Decoder usato senza stream: true | Mantieni l'opzione impostata su TextDecoder.decode |
| La risposta si interrompe a metà frase | raggiunto max_tokens | Aumenta il limite sul server |
Con quelle quattro correzioni puoi diagnosticare quasi ogni problema di primo avvio in meno di un minuto. Tieni il comando curl negli appunti; è anche un utile controllo di salute dopo che avrai modificato il proxy in seguito.
Piccoli dettagli che lo fanno sembrare completo
Una chat in streaming è già gradevole, ma pochi dettagli separano una demo da un prodotto a cui si torna.
- Disabilita il pulsante di invio mentre arriva una risposta. I doppi invii creano cronologie interlacciate che confondono sia l'utente che il modello.
- Aggiungi un pulsante di arresto. Crea un
AbortController, passa il suo segnale afetche chiamaabort()al click. Salva tutto il testo arrivato finora come turno dell'assistente. - Persisti il registro. Mantieni la cronologia in
sessionStoragecosì un refresh non cancella la conversazione, e offri un pulsante svuota chat che lo svuota. - Scorrimento automatico sensato. Segui il fondo solo quando l'utente è già vicino ad esso; altrimenti lascia che leggano le righe più vecchie in pace.
- Mostra un errore gentile. Sostituisci la bolla vuota con un link di retry che reinvia l'ultimo messaggio dell'utente.
Ognuna di queste è una decina di righe di JavaScript puro, quindi resisti alla tentazione di ricorrere a un framework finché la tua interfaccia non ha davvero bisogno di componenti, routing o stato condiviso.
Rafforzamento e prossimi passi
Ora hai una chat funzionante. Prima che arrivino gli utenti reali, aggiungi alcune salvaguardie in server.js. Limita le richieste per IP o sessione, perché una singola chiave è consentita 300 richieste al minuto in totale, e un singolo utente entusiasta potrebbe usarle tutte. Gestisci gli errori del server remoto esplicitamente: un 503 con upstream_busy merita un pulsante di retry amichevole, e un 403 content_blocked merita un messaggio chiaro piuttosto che una bolla vuota. Mantieni max_tokens modesto; 500 è più che sufficiente per la chat, mentre il massimo consentito è 32.000 per richiesta.
Poi pensa al costo. Come illustrazione, supponi che ogni turno invii 1.200 token di prompt e riceva 250 token di completamento. Questo è circa $0,0003 per l'input e $0,00025 per l'output, circa $0,00055 a turno. Questi conteggi di token sono assunzioni, quindi misura i tuoi con il blocco usage. La documentazione copre quel campo, e la guida ai LLM ospitati senza censura spiega cosa aspettarsi da questo tipo di servizio.
Domande e risposte
Posso chiamare l'API direttamente dal browser?
Tecnicamente sì, ma esporresti la tua chiave a ogni visitatore. Usa una route del server come quella in questo tutorial così la chiave rimane in una variabile d'ambiente.
Il server deve analizzare lo stream?
No. Può inoltrare i byte invariati e la pagina legge le righe di dati. Analizza sul server solo se vuoi registrare il testo o filtrare l'output.
Perché la mia risposta arriva tutta insieme?
C'è un buffering in corso. Controlla che la richiesta imposti stream a true e che qualsiasi proxy inverso o strato di compressione non stia trattenendo la risposta.
Come dovrei dare memoria al bot?
Reinvia la conversazione in messaggi a ogni chiamata, tagliando i turni più vecchi quando cresce. La finestra di contesto di 100.000 token è condivisa con la risposta.
La tua chiave è a un modulo di distanza
Crea un account, copia la chiave, cambia l'URL di base. È tutta la configurazione.