ストリーミングウェブチャットの構築(Expressプロキシ+プレーンJS)
このチュートリアルでは、約100行で小さなチャットページを構築します。小さなExpressサーバーが秘密のキーを保持し、リクエストをUncensored Chatbot APIに中継します。プレーンJavaScriptのフロントエンドはストリーミングされた返信を単語ごとに読み取ります。フレームワークもビルドステップもなく、キーがブラウザに触れることはありません。
更新日:
主要ポイント
- ブラウザコードからAPIを呼び出さないでください。サーバールートはキーを非公開に保ち、入力を検証できます。
- プロキシはサーバー送信イベントを通過させるだけです。ページは[DONE]までデータ行を解析します。
- ストリーミング中はtextContentを使用し、マークダウンをレンダリングする前にサニタイズしてください。
- サーバー側で履歴の長さやmax_tokensを制限し、1人のユーザーが全体の残高を使い果たすのを防ぎます。
構築するもの、およびプロキシが必要な理由
テキスト入力欄とスクロールログ付きの単一ページを想像してください。あなたは1行入力し、ページは会話をあなたのサーバー上の /api/chat にPOSTします。サーバーは実際のキーを付与してアップストリームに転送します。レスポンスはストリーミングされ、断片は到着した瞬間に表示されます。デモを生き生きとさせるため、ボットに少し個性を持たせ、灯台守のWrenというキャラクターにします。
Node 18 以降(組み込みのfetch用)とAPI キーが必要です。まだお持ちでない場合は、キーページから無料トライアルを取得してください。新規アカウントには7日間の$0.50のクレジットが付与され、支払い情報の登録は不要です。ベースURLはhttps://api.uncensoredchatbotapi.com/v1、モデルIDはuncensoredです。
プロキシが必要な理由
キーをフロントエンドコードに貼り付け、APIを直接呼び出したくなるかもしれません。やめてください。ブラウザに配信されたものは、開発者ツールを開く誰でも読み取ることができ、キーが漏洩すると残高が枯渇します。プロキシはこれを修正し、3つの追加の恩恵をもたらします。
- 秘密性。 キーはサーバー上の環境変数に保持されます。
- 制御。 システムプロンプト、履歴の長さ、
max_tokensを決定できるため、ユーザーがそれらを上書きできません。 - ルールを置く場所。 ユーザーごとの制限、年齢制限、ログ記録ポリシーはすべてこのレイヤーに属します。safety guide はこのルートに基づいています。
ステップ1:プロジェクトのセットアップ
フォルダを作成し、ESモジュールプロジェクトとして初期化してExpressをインストールします。他はすべてビルドインです。
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir public最終的にルートにserver.js、ページとスクリプトを含むpublicフォルダの2つが完成します。
ステップ2:Expressプロキシの記述
サーバーは静的ファイルを提供し、1つのPOSTエンドポイントのみを公開します。これを3つの部分に分けて読み解きましょう。まず、受信した履歴をサニタイズします:user と assistant のロールのみが通過し、各メッセージは4,000文字に切り詰められ、最後の30ターンのみが保持されます。次に、ブラウザが変更できない独自のシステムメッセージを先頭に追加します。最後に、stream: true を付けてアップストリームエンドポイントを呼び出し、バイトをそのままパイプします。
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"));説明に値する2つの選択があります。生バイトを通過させるということは、サーバーでイベント形式を理解する必要がないことを意味します。また、失敗時にアップストリームのステータスを返すことで、ページが合理的に反応できます。例えば、402は残高が空であることを、429は誰かが速すぎることを示します。
ステップ3:ページとストリームリーダー
次にフロントエンド。これはpublic/index.htmlとして保存してください。後でスタイルを変更できるように意図的に簡素にしています。
<!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>次に、public/chat.js です。興味深いのは読み込みループです。ネットワークのチャンクは行の境界を尊重しないため、buffer を保持し、改行で分割し、最後の不完全な行はさらにデータが届くまで保留します。data: で始まる完全な行はJSONですが、最後の [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 });
});history配列に注目してください。APIは呼び出し間で記憶を保持しないため、ページは毎回会話を再送信し、サーバーがトリミングします。アプリを起動し、ブラウザで開きます:
export API_KEY="paste-your-key-here"
node server.js
マークダウンレンダリングに関するノート
チャットモデルはアスタリスク、リスト、そして時折のコードフェンスを好みます。デモでは安全な生テキストを表示します。美しい出力を得たい場合はライブラリでマークダウンをレンダリングしますが、2つのルールに従ってください。innerHTML で挿入する前にHTMLをサニタイザーに通す。モデル、またはそれをトリックするユーザーが意図しないタグや属性を出力する可能性があるためです。そして 注意深く増分的にレンダリングする:断片ごとに全文を再解析するのは短いメッセージには問題ありませんが、未完成のマークダウンは点滅する可能性があるため、ストリーミング中はプレーンテキストを表示し、ストリーム終了時にフォーマット済み出力に切り替えるビルダーもいます。
ロールプレイのフォーマットは別の癖です。多くのキャラクターはアクションをアスタリスクで囲みます。例えば *adjusts the lamp* のように。アプリでそれらを斜体としてスタイル設定するか、システムプロンプトでキャラクターにその規約に従うよう指示するかを決定してください。それ用のプロンプトの書き方は persona design guide で示しています。
ステップ4:スモークテストを実行する
ブラウザのせいにする前に、プロキシを直接叩いてみてください。ターミナルが機能すれば、サーバーは正常であり、残りのバグはページにあります。
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'-Nフラグはcurl自身のバッファリングを無効にするため、data:行が徐々に表示され、data: [DONE]で終了するはずです。代わりにJSONエラーが表示された場合は、ステータスを読んでください:401は環境変数のキーが間違っていることを、402は残高が空であることを、404はアップストリームURLにタイプミスがあることを示します。
| 症状 | 考えられる原因 | 修正方法 |
|---|---|---|
| ページに利用できないメッセージが表示される | サーバーが200以外のステータスを返した | curlテストを実行し、ステータスを読む |
| テキストは最後にのみ表示される | プロキシレイヤーが応答をバッファリングしている | ルートのバッファリングを無効にする |
| 文字化け | stream: trueなしでデコーダーを使用した | TextDecoder.decode のオプション設定を維持する |
| 返信が文の途中で切れる | max_tokensに到達 | サーバーの上限を引き上げる |
これらの4つの修正により、初回実行時の問題のほとんどを1分以内に診断できます。curlコマンドはメモに残しておいてください。後でプロキシを変更した後のヘルスチェックとしても便利です。
完成感を高める小さな改善点
ストリーミングするチャットはすでに快適ですが、デモとユーザーが戻ってくる製品の差は細部にあります。
- レスポンス受信中は送信ボタンを無効にする。 ダブルサブミットは、ユーザーとモデルの両方を混乱させるインターリーブされた履歴を作成します。
- 停止ボタンを追加します。
AbortControllerを作成し、そのシグナルをfetchに渡します。クリック時にabort()を呼び出します。これまでに受信したテキストをアシスタントの応答として保存します。 - ログを永続化する。
sessionStorageに履歴を保持し、更新で会話が消えないようにし、クリアボタンでそれを空にできるようにします。 - 合理的に自動スクロールする。 ユーザーがすでに下部に近い場合のみ下部を追跡し、それ以外の場合は古い行を落ち着いて読めるようにします。
- エラーを適切に表示します。 空のバブルを置き換え、最後のユーザーメッセージを再送信する再試行リンクを追加します。
これらすべてはプレーンなJavaScriptで数十行で実装できるため、インターフェースがコンポーネント、ルーティング、または共有状態を本当に必要とするまで、フレームワークに頼りすぎないでください。
堅牢化と次のステップ
動作するチャットが完成しました。実際のユーザーがアクセスする前に、server.js にいくつかの保護策を追加してください。IPまたはセッションあたりのリクエスト数を制限します。1つのAPIキーは1分間に合計300リクエストまで許可されているため、一人の熱心なユーザーがすべてのリクエストを使い果たす可能性があります。アップストリームのエラーを明示的に処理します:upstream_busy の503エラーには親切な再試行ボタンを、403 content_blocked には空のバブルではなく明確なメッセージを表示します。max_tokens は控えめに設定してください。チャットには500で十分であり、リクエストあたりの最大許可値は32,000です。
次にコストについて考えましょう。例として、各ターンで1,200のプロンプトトークンを送信し、250の完了トークンを受信すると仮定します。入力は約$0.0003、出力は$0.00025で、ターンあたり約$0.00055です。これらのトークン数は仮定なので、usage チャンクで実際に測定してください。docs はそのフィールドについてカバーしており、guide to hosted uncensored LLMs はこの種のサービスから何を期待すべきかを説明しています。
質問と回答
ブラウザから直接APIを呼び出せますか?
技術的には可能ですが、キーがすべての訪問者に公開されます。このチュートリアルにあるサーバールートを使用し、キーを環境変数に保持してください。
サーバーはストリームを解析する必要がありますか?
いいえ。バイトを変更せずに転送でき、ページはデータ行を読み取ります。テキストをログに記録したり出力をフィルタリングしたい場合のみ、サーバーで解析してください。
なぜ返信が一度に届くのですか?
中間でバッファリングされています。リクエストに stream: true が設定されていること、およびリバースプロキシや圧縮レイヤーが応答を保持していないことを確認してください。
ボットに記憶(コンテキスト)を持たせるには?
呼び出しごとにメッセージ形式で会話履歴を再送信し、履歴が肥大化した場合は古い会話を削除します。100,000トークンのコンテキストウィンドウはレスポンスと共有されます。