届いた順に答えを見せる
流れで答えるチャットページは、チャットボットの答えを、数語ずつ表示します。サーバーが送るそばから、画面に足していきます。長い答えは数秒かかることがあり、空白のページでは、サイトが壊れたと思われるからです。
サーバーは小さな断片を送り、ページはその断片を画面に足します。ラボの端末にはブラウザーがないので、ここでは、ページと流れを文章として作ります。自分のパソコンなら、それをブラウザーで開きます。
このレッスンのゴール
メモ
私は、流れで届く答えを、届くそばから Web ページに表示できます。
キーワード
| ことば | いみ |
|---|---|
| ストリーム | 小さな断片に分けて送る答え |
| サーバー送信イベント | サーバーからページへ行を送り続ける方法 |
| ハンドラー | 一つのリクエストに答える関数 |
| エスケープ | 文字を、文章として表示される形に変えること |
| 断片 | 流れで送る答えの、小さな一部 |
学生が入力するセルは、このプレビューでは空白です。
サーバーは答えを断片で送れる
サーバー送信イベントでは、サーバーが接続を開いたままにして、行を書き続けます。イベントは data: で始まり、断片を JSON で持ち、空行で終わります。応答の種類は text/event-stream です。
最後に、event: done と data: end の2行からなるイベントを送ります。これで、ページは答えが終わったとわかります。
例: 流れの行
| 送る行 | ページがすること |
|---|---|
| data: {"piece": "こんにちは"} と空行 | 画面に「こんにちは」を足す |
| data: {"piece": "、ようこそ"} と空行 | 続きの言葉を、そのあとに足す |
| event: done、data: end、空行 | 接続を閉じる |
学生が入力するセルは、このプレビューでは空白です。
ページは足していき、止められる
ページには、小さなスクリプトが入っています。流れを開き、イベントが来るたびに、断片を段落に足します。止めるボタンは流れを閉じるので、それ以上断片は届きません。
append に文字列を渡して足すと、ふつうの文章として入ります。マークアップとしては入りません。
モデルの文章はエスケープする
モデルは、渡された言葉をそのまま繰り返すことがあります。<script> も例外ではありません。その文章を HTML としてページに入れると、ブラウザーが実行するかもしれません。
エスケープは、< を < に、ほかの特別な文字を安全な符号に変えます。HTML に入るモデルの文章は、必ずエスケープします。& を最初に変えないと、自分の符号まで変えてしまいます。
よくある間違い
注意
「プロンプトは自分で書いたから、モデルの文章は安全だ」と考える人が多いです。そうではありません。モデルは、利用者や資料の言葉を繰り返せます。返事は信用できないものとして扱い、文章として足すか、先にエスケープしましょう。
モデルの補助を書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", {' ' method: \"POST\",' ' headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' ' body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 200 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' return (await res.json()).choices[0].message.content;' > model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
モデルの補助を書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' } catch (error) {' ' console.log(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' ' const list = [].concat(sample);' ' const turn = messages.filter(function (m) { return m.role === \"assistant\"; }).length;' ' return list[Math.min(turn, list.length - 1)];' ' }' '}' 'module.exports = { ask };' >> model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページと流れを書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' '' 'function quoted(text) { return JSON.stringify(text); }' 'function escapeHtml(text) {' ' return text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\")' ' .replace(/\"/g, \""\").replace(/\x27/g, \"'\");' '}' > ui4.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページと流れを書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function pageHtml() {' ' const script = [' ' \"const out = document.getElementById(\" + quoted(\"answer\") + \");\",' ' \"const source = new EventSource(\" + quoted(\"/stream\") + \");\",' ' \"source.onmessage = function (e) { out.append(JSON.parse(e.data).piece); };\",' ' \"source.addEventListener(\" + quoted(\"done\") + \", function () { source.close(); });\",' ' \"document.getElementById(\" + quoted(\"stop\") + \").onclick = function () { source.close(); };\",' ' ].join(\"\n\");' ' return \"<html><body><p id=answer></p><button id=stop>止める</button><script>\" + script + \"</script></body></html>\";' '}' >> ui4.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページと流れを書く(3)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function sseLine(piece) { return \"data: \" + JSON.stringify({ piece: piece }) + \"\n\n\"; }' 'function streamBody(pieces, stopAfter) {' ' let body = \"\";' ' pieces.slice(0, stopAfter).forEach(function (piece) { body = body + sseLine(piece); });' ' return stopAfter >= pieces.length ? body + \"event: done\ndata: end\n\n\" : body;' '}' >> ui4.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページと流れを書く(4)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function handle(request, pieces) {' ' if (request.url === \"/\") return { status: 200, type: \"text/html\", body: pageHtml() };' ' if (request.url === \"/stream\") {' ' return { status: 200, type: \"text/event-stream\", body: streamBody(pieces, request.stopAfter || 99) };' ' }' ' return { status: 404, type: \"text/plain\", body: \"見つかりません\" };' '}' >> ui4.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページと流れを書く(5)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function show(pieces) {' ' const home = handle({ url: \"/\" }, pieces);' ' console.log(\"GET / ->\", home.status, home.type, home.body.length + \" 文字\");' ' const live = handle({ url: \"/stream\" }, pieces);' ' console.log(\"GET /stream ->\", live.status, live.type);' ' console.log(live.body.split(\"\n\n\").join(\" | \"));' ' const stopped = handle({ url: \"/stream\", stopAfter: 2 }, pieces);' ' console.log(\"2つ送ったあとに止める:\", stopped.body.split(\"\n\n\").length - 1, \"つ送信、done の行なし\");' ' console.log(\"エスケープ後:\", escapeHtml(\"<b>こんにちは</b> & ありがとう\"));' '}' >> ui4.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページと流れを書く(6)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' // 記録済みのサンプル。モデルが答えないときだけ使う。' ' const sample = \"こんにちは、ご乗車ありがとうございます。どちらまで行かれますか。\";' ' const reply = await ask([{ role: \"user\", content: \"バスの乗客に、短い一文であいさつしてください。\" }], sample, 40);' ' show(reply.match(/[\s\S]{1,6}/g) || []);' '}' 'main();' >> ui4.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ハンドラーを呼ぶ
docker exec -w /home/node/project agent node ui4.jsここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
出力の一例
| 行 | 表示されるかもしれないもの |
|---|---|
| GET / | 200 text/html と、ページの大きさ |
| GET /stream | 200 text/event-stream と、返事の断片(| で区切る) |
| 止めたとき | 2つ送信、done の行なし |
| エスケープ後 | <b>こんにちは</b> & ありがとう(いつも同じ) |
学生が入力するセルは、このプレビューでは空白です。