Zbuduj strumieniowy czat webowy (proxy Express + czysty JS)
W tym samouczku zbudujesz małą stronę czatu w około 100 liniach. Mały serwer Express przechowuje Twój tajny klucz i przesyła zapytania do Uncensored Chatbot API, podczas gdy prosty front-end w JavaScript odczytuje strumieniowaną odpowiedź słowo po słowie. Bez frameworków, bez etapu budowania, a Twój klucz nigdy nie trafia do przeglądarki.
Zaktualizowano
Kluczowe punkty
- Nigdy nie wywołuj API z kodu przeglądarki; trasa serwerowa utrzymuje klucz prywatnym i pozwala zwalidować dane wejściowe.
- Proxy musi tylko przepuszczać zdarzenia wysyłane przez serwer; strona parsuje linie danych do momentu [DONE].
- Używaj textContent podczas strumieniowania i sanitizuj przed renderowaniem markdown.
- Ogranicz długość historii i max_tokens na serwerze, aby jeden użytkownik nie mógł wydać całego salda.
Co budujemy i dlaczego proxy
Wyobraź sobie pojedynczą stronę z polem tekstowym i przewijanym logiem. Wpisujesz linię, strona wysyła rozmowę do /api/chat na Twoim serwerze, a Twój serwer przesyła ją dalej z prawdziwym kluczem. Odpowiedź strumieniuje z powrotem, a każdy fragment pojawia się natychmiast po przybyciu. Nadamy botowi odrobinę osobowości, strażnika latarni o imieniu Wren, aby demo wyglądało na żywe.
Potrzebujesz Node 18 lub nowszego (dla wbudowanego fetch) oraz klucza API. Jeśli jeszcze go nie masz, pobierz darmowy kredyt próbny ze strony klucz: nowe konta otrzymują $0,50 kredytu na 7 dni bez podawania danych płatniczych. Base URL to https://api.uncensoredchatbotapi.com/v1, a identyfikator modelu to uncensored.
Dlaczego potrzebujesz proxy
Pokusą jest wklejenie klucza do kodu front-end i bezpośrednie wywołanie API. Proszę nie rób tego. Wszystko dostarczone do przeglądarki może zostać odczytane przez każdego, kto otworzy narzędzia deweloperskie, a wyciek klucza oznacza opróżnione saldo. Proxy rozwiązuje ten problem i daje trzy dodatkowe korzyści.
- Poufność. Klucz znajduje się w zmiennej środowiskowej na serwerze.
- Kontrola. Ty decydujesz o systemowym prompcie, długości historii i
max_tokens, więc użytkownicy nie mogą ich nadpisać. - Miejsce na zasady. Limity dla każdego użytkownika, bariery wiekowe i polityka logowania należą do tej warstwy. Przewodnik po bezpieczeństwie opiera się na tej właśnie trasie.
Krok 1: skonfiguruj projekt
Utwórz folder, zainicjuj go jako projekt ES-module i zainstaluj Express. Wszystko inne jest wbudowane.
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir publicKońcowo otrzymasz dwie rzeczy: server.js w katalogu głównym i folder public zawierający stronę i jej skrypt.
Krok 2: napisz proxy Express
Serwer obsługuje pliki statyczne i udostępnia jedną trasę POST. Przeczytaj ją w trzech częściach. Po pierwsze, czyści on przychodzącą historię: przechodzą tylko role user i assistant, każda wiadomość jest obcinana do 4000 znaków, a zachowywane są tylko ostatnie 30 zwrotów. Po drugie, dołącza Twój własny prompt systemowy, którego przeglądarka nie może zmienić. Po trzecie, wywołuje endpoint upstream z stream: true i przesyła bajty prosto z powrotem.
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"));Warte wyjaśnienia są dwa wybory. Przekazywanie surowych bajtów oznacza, że w ogóle nie musisz rozumieć formatu zdarzeń po stronie serwera. A zwracanie statusu upstream dla błędów pozwala stronie reagować rozsądnie; na przykład 402 oznacza, że Twoje saldo jest puste, a 429 oznacza, że ktoś działa zbyt szybko.
Krok 3: strona i czytnik strumienia
Teraz front-end. Zapisz to jako public/index.html; jest celowo ubogi, abyś mógł go później dostosować.
<!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>Następnie public/chat.js. Najciekawszą częścią jest pętla odczytu. Fragmenty sieciowe nie respektują granic linii, więc utrzymujemy buffer, dzielimy go po znakach nowej linii i wstrzymujemy ostatnią niekompletną linię, aż przybędą więcej danych. Każda kompletna linia zaczynająca się od data: to JSON, z wyjątkiem końcowego znacznika [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 });
});Zwróć uwagę na tablicę history. API nie przechowuje pamięci między wywołaniami, więc strona przesyła całą konwersację za każdym razem, a serwer ją obcina. Uruchom aplikację i otwórz ją w przeglądarce:
export API_KEY="paste-your-key-here"
node server.js
Uwaga dotycząca renderowania markdown
Modele czatu uwielbiają gwiazdki, listy i od czasu do czasu bloki kodu. Nasz demo pokazuje surowy tekst, który jest bezpieczny. Kiedy chcesz ładny output, renderuj markdown za pomocą biblioteki, ale przestrzegaj dwóch zasad. Uruchom HTML przez sanitizer przed wstawieniem go za pomocą innerHTML, ponieważ model lub użytkownik, który go oszuka, może wygenerować tagi i atrybuty, których nie przewidziałeś. I renderuj inkrementalnie z ostrożnością: ponowne parsowanie całej odpowiedzi przy każdym fragmencie jest OK dla krótkich wiadomości, ale niedokończony markdown może mrugać, więc niektórzy twórcy pokazują tekst zwykły podczas strumieniowania i przełączają się na sformatowany output, gdy strumień się kończy.
Formatowanie do odgrywania ról to osobna ciekawostka. Wielu bohaterów owija akcje gwiazdkami, np. *reguluje lampę*. Zdecyduj, czy Twoja aplikacja stylizuje je jako kursywę, i wskaż bohaterowi w promptie systemowym, której konwencji ma przestrzegać. Nasz przewodnik po projektowaniu persony pokazuje sformułowania promptów dla tego celu.
Krok 4: test dymny trasy
Zanim obwinisz przeglądarkę, sprawdź proxy bezpośrednio. Jeśli terminal działa, serwer jest OK, a każdy pozostały błąd żyje na stronie.
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'Flaga -N wyłącza buforowanie curla, więc powinieneś widzieć linie data: pojawiające się stopniowo, kończąc się na data: [DONE]. Jeśli zamiast tego otrzymasz błąd JSON, przeczytaj jego status: 401 oznacza, że klucz w Twoim środowisku jest nieprawidłowy, 402 oznacza, że saldo jest puste, a 404 oznacza literówkę w URL upstream.
| Objaw | Prawdopodobna przyczyna | Naprawa |
|---|---|---|
| Strona pokazuje komunikat o niedostępności | Serwer zwrócił status inny niż 200 | Uruchom test curl i przeczytaj status |
| Tekst pojawia się tylko na końcu | Warstwa proxy buforuje odpowiedź | Wyłącz buforowanie dla trasy |
| Zniekształcone znaki | Dekoder użyty bez stream: true | Ustaw opcję na TextDecoder.decode |
| Odpowiedź urywa się w połowie zdania | Osiągnięto limit max_tokens | Podnieś limit na serwerze |
Dzięki tym czterem poprawkom możesz zdiagnozować prawie każdy problem przy pierwszym uruchomieniu w mniej niż minutę. Zapisz polecenie curl w notatkach; przyda się też jako prosty test dostępności po późniejszej zmianie proxy.
Drobne szlify, które nadają produktowi kompletny wygląd
Czat ze strumieniowaniem jest już przyjemny, ale kilka szczegółów odróżnia demo od produktu, do którego użytkownicy wracają.
- Wyłącz przycisk wysyłania, gdy przychodzi odpowiedź. Podwójne wysłanie tworzy przeplatane historie, które mylą zarówno użytkownika, jak i model.
- Dodaj przycisk stop. Stwórz
AbortController, przekaż jego sygnał dofetchi wywołajabort()po kliknięciu. Zapisz cały tekst, który dotarł do tego momentu, jako zwrot asystenta. - Zapisuj log. Przechowuj historię w
sessionStorage, aby odświeżenie nie wymazało konwersacji, i zaoferuj przycisk wyczyszczenia czatu, który ją opróżni. - Auto-przewijaj rozsądnie. Podążaj za dolną krawędzią tylko wtedy, gdy użytkownik jest już przy niej; w przeciwnym razie pozwól mu czytać starsze linie w spokoju.
- Pokaż łagodną awarię. Zastąp pustą bańkę linkiem do ponownej próby, który ponownie wyśle ostatnią wiadomość użytkownika.
Każda z tych funkcji to zaledwie kilkanaście linijek czystego JavaScript, więc nie sięgaj po framework, dopóki interfejs naprawdę nie będzie potrzebował komponentów, routingu czy współdzielonego stanu.
Utrwalanie i kolejne kroki
Masz teraz działający czat. Przed przybyciem prawdziwych użytkowników dodaj kilka zabezpieczeń w server.js. Ogranicz zapytania na IP lub sesję, ponieważ jeden klucz pozwala na 300 zapytań na minutę łącznie, a jeden zmotywowany użytkownik mógłby zużyć wszystko. Obsłuż błędy upstream wyraźnie: 503 z upstream_busy zasługuja na przyjazny przycisk ponownej próby, a 403 content_blocked zasługuje na jasną wiadomość zamiast pustej bańki. Utrzymuj max_tokens na rozsądnym poziomie; 500 wystarczy do czatu, podczas gdy dozwolony maksimum to 32 000 na zapytanie.
Następnie pomyśl o koszcie. Dla ilustracji załóżmy, że każdy zwrot wysyła 1200 tokenów promptu i otrzymuje 250 tokenów uzupełnienia. To około $0,0003 za wejście i $0,00025 za wyjście, około $0,00055 za zwrot. Te liczebności tokenów to założenia, więc zmierz własne za pomocą fragmentu usage. Dokumentacja opisuje to pole, a przewodnik po hostowanych LLM bez cenzury wyjaśnia, czego się spodziewać po tego rodzaju usłudze.
Pytania i odpowiedzi
Czy mogę wywoływać API bezpośrednio z przeglądarki?
Technicznie tak, ale wtedy klucz API będzie widoczny dla każdego odwiedzającego. Użyj trasy serwerowej, jak w tym samouczku, aby klucz pozostał w zmiennej środowiskowej.
Czy serwer musi parsować strumień?
Nie. Może przesyłać bajty bez zmian, a strona odczytuje linie danych. Parsuj po stronie serwera tylko wtedy, gdy chcesz logować tekst lub filtrować wyjście.
Dlaczego odpowiedź przychodzi naraz?
Coś po drodze buforuje dane. Sprawdź, czy w zapytaniu ustawiono stream na true i czy żadne proxy odwrotne ani warstwa kompresji nie zatrzymuje odpowiedzi.
Jak zapewnić botowi pamięć?
Przesyłaj historię konwersacji w postaci wiadomości przy każdym wywołaniu, obcinając najstarsze zwroty, gdy rośnie. Kontekst 100 000 tokenów jest współdzielony z odpowiedzią.
Twój klucz jest o jeden formularz stąd
Utwórz konto, skopiuj klucz i zmień bazowy URL. To cały proces konfiguracji.