Créons un chat web en streaming (proxy Express + JS pur)
Dans ce tutoriel, vous allez créer une petite page de chat en environ 100 lignes. Un petit serveur Express détient votre clé secrète et relaie les requêtes vers le Uncensored Chatbot API, tandis qu'un front-end en JavaScript brut lit la réponse en streaming mot par mot. Aucun framework, aucune étape de build, et votre clé ne touche jamais le navigateur.
Mis à jour le
Points clés
- N'appellez jamais l'API depuis le code du navigateur ; un point de terminaison serveur garde la clé privée et vous permet de valider l'entrée.
- Le proxy doit seulement transmettre les événements envoyés par le serveur ; la page analyse les lignes de données jusqu'à [DONE].
- Utilisez textContent pendant le streaming et nettoyez avant de rendre le markdown.
- Limitez la longueur de l'historique et max_tokens sur le serveur pour qu'un utilisateur ne puisse pas dépenser tout votre solde.
Ce que nous construisons, et pourquoi un proxy
Imaginez une seule page avec une zone de texte et un journal défilant. Vous tapez une ligne, la page envoie la conversation à /api/chat sur votre propre serveur, et votre serveur la transmet en amont avec la vraie clé attachée. La réponse est diffusée, et chaque fragment apparaît dès son arrivée. Nous donnerons au bot un peu de personnalité, un gardien de phare nommé Wren, pour que la démo semble vivante.
Vous avez besoin de Node 18 ou plus récent (pour fetch intégré) et d'une clé API. Si vous n'en avez pas encore, récupérez le crédit d'essai sur la page clé : les nouveaux comptes reçoivent 0,50 $ de crédit pendant 7 jours, sans besoin de carte bancaire. L'URL de base est https://api.uncensoredchatbotapi.com/v1 et l'identifiant du modèle est uncensored.
Pourquoi vous avez besoin du proxy
Il est tentant de coller la clé dans le code front-end et d'appeler l'API directement. Ne le faites pas. Tout ce qui est livré à un navigateur peut être lu par quiconque ouvre les outils de développement, et une clé fuite signifie un solde vidé. Un proxy corrige cela et vous donne trois avantages supplémentaires.
- Secret. La clé est stockée dans une variable d'environnement sur le serveur.
- Contrôle. Vous décidez du prompt système, de la longueur de l'historique et de
max_tokens, donc les utilisateurs ne peuvent pas les modifier. - Un endroit pour les règles. Les limites par utilisateur, les filtres d'âge et la politique de journalisation appartiennent tous à cette couche. Le guide de sécurité s'appuie sur cette route.
Étape 1 : configurer le projet
Créez un dossier, initialisez-le en tant que projet ES-module et installez Express. Tout le reste est intégré.
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir publicVous obtiendrez deux choses : server.js à la racine et un dossier public contenant la page et son script.
Étape 2 : écrire le proxy Express
Le serveur sert des fichiers statiques et expose une seule route POST. Lisez-le en trois parties. D'abord, il nettoie l'historique entrant : seuls les rôles user et assistant passent, chaque message est tronqué à 4 000 caractères, et seuls les 30 derniers tours sont conservés. Deuxièmement, il ajoute votre propre message système, que le navigateur ne peut jamais modifier. Troisièmement, il appelle le endpoint en amont avec stream: true et transfère les octets directement.
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"));Deux choix méritent une explication. Transmettre les octets bruts signifie que vous n'avez pas besoin de comprendre le format des événements sur le serveur. Et retourner le statut en amont pour les erreurs permet à la page de réagir de manière sensée ; par exemple, un 402 signifie que votre solde est vide et un 429 signifie que quelqu'un va trop vite.
Étape 3 : la page et le lecteur de flux
Maintenant le front-end. Enregistrez ceci sous public/index.html ; il est délibérément nu pour que vous puissiez le restyliser plus tard.
<!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>Ensuite, public/chat.js. La partie intéressante est la boucle de lecture. Les chunks réseau ne respectent pas les limites de ligne, donc nous gardons un buffer, divisons par les nouvelles lignes, et retenons la dernière ligne partielle jusqu'à ce que plus de données arrivent. Chaque ligne complète commençant par data: est du JSON, sauf le marqueur final [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 });
});Notez le tableau history. L'API ne conserve aucune mémoire entre les appels, donc la page renvoie toute la conversation à chaque fois, et le serveur la tronque. Démarrez l'application et ouvrez-la dans un navigateur :
export API_KEY="paste-your-key-here"
node server.js
Une note sur le rendu du markdown
Les modèles de chat aiment les astérisques, les listes et les blocs de code occasionnels. Notre démo affiche du texte brut, ce qui est sûr. Lorsque vous voulez une sortie jolie, utilisez une bibliothèque pour rendre le markdown, mais suivez deux règles. Passez le HTML dans un sanitizeur avant de l'insérer avec innerHTML, car un modèle, ou un utilisateur qui le trompe, peut émettre des balises et des attributs que vous n'aviez pas prévus. Et rendez de manière incrémentale avec soin : repasser tout la réponse à chaque fragment est acceptable pour les courts messages, mais un markdown inachevé peut clignoter, donc certains créateurs affichent du texte brut pendant le streaming et basculent vers une sortie formatée à la fin du flux.
Le formatage de jeu de rôle est une particularité séparée. De nombreux personnages entourent les actions d'astérisques, comme *ajuste la lampe*. Décidez si votre app les met en italique, et indiquez au personnage dans le prompt système quelle convention suivre. Notre guide de conception de persona montre le libellé du prompt pour cela.
Étape 4 : smoke-test de la route
Avant de blâmer le navigateur, testez le proxy directement. Si le terminal fonctionne, le serveur est correct et tout bug restant se trouve dans la page.
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'Le drapeau -N désactive le propre tamponnage de curl, vous devriez donc voir les lignes data: apparaître progressivement, se terminant par data: [DONE]. Si vous obtenez une erreur JSON à la place, lisez son statut : 401 signifie que la clé dans votre environnement est incorrecte, 402 signifie que le solde est vide, et un 404 signifie que l'URL distante contient une faute de frappe.
| Symptôme | Cause probable | Correction |
|---|---|---|
| La page affiche le message indisponible | Le serveur a retourné un statut non-200 | Exécutez le test curl et lisez le statut |
| Le texte n'apparaît qu'à la fin | Une couche proxy met en mémoire taminaire la réponse | Désactivez la mise en mémoire taminaire pour la route |
| Caractères corrompus | Décodeur utilisé sans stream: true | Gardez l'option définie sur TextDecoder.decode |
| La réponse s'arrête en plein milieu d'une phrase | max_tokens atteint | Augmentez le plafond sur le serveur |
Avec ces quatre correctifs, vous pouvez diagnostiquer presque tous les problèmes de première exécution en moins d’une minute. Conservez la commande curl dans vos notes ; elle constitue également un test de santé pratique après avoir modifié le proxy plus tard.
Les petites touches qui rendent le produit abouti
Une conversation en streaming est déjà agréable, mais quelques détails distinguent une démo d’un produit auquel les utilisateurs reviennent.
- Désactivez le bouton d’envoi pendant la réception d’une réponse. Les doubles soumissions créent des historiques entrelacés qui confondent à la fois l’utilisateur et le modèle.
- Ajoutez un bouton d'arrêt. Créez un
AbortController, passez son signal àfetch, et appelezabort()au clic. Enregistrez tout texte arrivé jusqu'à présent comme le tour de l'assistant. - Enregistrez le journal. Conservez l'historique dans
sessionStoragepour qu'un rafraîchissement n'efface pas la conversation, et offrez un bouton vider le chat qui le vide. - Défilement automatique raisonnable. Suivez le bas uniquement lorsque l'utilisateur est déjà près de celui-ci ; sinon, laissez-le lire les lignes plus anciennes en paix.
- Affichez une erreur douce. Remplacez la bulle vide par un lien de renvoi qui renvoie le dernier message utilisateur.
Chacune de ces fonctionnalités représente une douzaine de lignes de JavaScript pur, alors résistez à l’envie d’utiliser un framework tant que votre interface n’a pas réellement besoin de composants, de routage ou d’état partagé.
Durcissement et prochaines étapes
Vous avez maintenant un chat fonctionnel. Avant l'arrivée des vrais utilisateurs, ajoutez quelques mesures de protection dans server.js. Limitez les requêtes par IP ou session, car une seule clé est autorisée à 300 requêtes par minute au total, et un utilisateur enthousiaste pourrait tout utiliser. Gérez explicitement les erreurs en amont : un 503 avec upstream_busy mérite un bouton de nouvelle tentative amical, et un 403 content_blocked mérite un message clair plutôt qu'une bulle vide. Gardez max_tokens modeste ; 500 est largement suffisant pour le chat, tandis que le maximum autorisé est de 32 000 par requête.
Pensez ensuite au coût. À titre d'illustration, supposons que chaque tour envoie 1 200 tokens de prompt et reçoit 250 tokens de complétion. Cela représente environ $0.0003 pour l'entrée et $0.00025 pour la sortie, soit environ $0.00055 par tour. Ces nombres de tokens sont des hypothèses, mesurez les vôtres avec le chunk usage. La documentation couvre ce champ, et le guide des LLM hébergés sans censure explique à quoi s'attendre de ce type de service.
Questions et réponses
Puis-je appeler l’API directement depuis le navigateur ?
Techniquement oui, mais vous exposeriez votre clé à tous les visiteurs. Utilisez un route de serveur comme celui dans ce tutoriel afin que la clé reste dans une variable d’environnement.
Le serveur doit-il analyser le flux ?
Non. Il peut transmettre les octets inchangés, et la page lit les lignes de données. Analysez sur le serveur uniquement si vous souhaitez enregistrer du texte ou filtrer la sortie.
Pourquoi ma réponse arrive-t-elle d’un coup ?
Un phénomène intermédiaire est le tamponnage. Vérifiez que la requête définit stream à true et qu'aucun proxy inverse ou couche de compression ne retient la réponse.
Comment donner de la mémoire au bot ?
Renvoyez la conversation dans les messages à chaque appel, en supprimant les tours les plus anciens lorsqu’elle s’allonge. La fenêtre de contexte de 100 000 tokens est partagée avec la réponse.
Votre clé est à un formulaire de vous
Créez un compte, copiez la clé, modifiez l’URL de base. C’est toute la configuration.