クリーム色の背景、色付き表示、高コントラスト、大きな文字のほうが読みやすいですか?配色と文字を選ぶと、すべてのページに一度で反映されます。

無料レッスン

チャットの答えを流れるように表示する

このレッスンのあとで: 私は、流れで届く答えを、届くそばから Web ページに表示できます。

60分のレッスンコマンド9件AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

届いた順に答えを見せる

流れで答えるチャットページは、チャットボットの答えを、数語ずつ表示します。サーバーが送るそばから、画面に足していきます。長い答えは数秒かかることがあり、空白のページでは、サイトが壊れたと思われるからです。

サーバーは小さな断片を送り、ページはその断片を画面に足します。ラボの端末にはブラウザーがないので、ここでは、ページと流れを文章として作ります。自分のパソコンなら、それをブラウザーで開きます。

このレッスンのゴール

メモ

私は、流れで届く答えを、届くそばから Web ページに表示できます。

キーワード

ことばいみ
ストリーム小さな断片に分けて送る答え
サーバー送信イベントサーバーからページへ行を送り続ける方法
ハンドラー一つのリクエストに答える関数
エスケープ文字を、文章として表示される形に変えること
断片流れで送る答えの、小さな一部

学生が入力するセルは、このプレビューでは空白です。

サーバーは答えを断片で送れる

サーバー送信イベントでは、サーバーが接続を開いたままにして、行を書き続けます。イベントは data: で始まり、断片を JSON で持ち、空行で終わります。応答の種類は text/event-stream です。

最後に、event: done と data: end の2行からなるイベントを送ります。これで、ページは答えが終わったとわかります。

例: 流れの行

送る行ページがすること
data: {"piece": "こんにちは"} と空行画面に「こんにちは」を足す
data: {"piece": "、ようこそ"} と空行続きの言葉を、そのあとに足す
event: done、data: end、空行接続を閉じる

学生が入力するセルは、このプレビューでは空白です。

ページは足していき、止められる

ページには、小さなスクリプトが入っています。流れを開き、イベントが来るたびに、断片を段落に足します。止めるボタンは流れを閉じるので、それ以上断片は届きません。

append に文字列を渡して足すと、ふつうの文章として入ります。マークアップとしては入りません。

モデルの文章はエスケープする

モデルは、渡された言葉をそのまま繰り返すことがあります。<script> も例外ではありません。その文章を HTML としてページに入れると、ブラウザーが実行するかもしれません。

エスケープは、< を &lt; に、ほかの特別な文字を安全な符号に変えます。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, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\")' '    .replace(/\"/g, \"&quot;\").replace(/\x27/g, \"&#39;\");' '}' > 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 /stream200 text/event-stream と、返事の断片(| で区切る)
止めたとき2つ送信、done の行なし
エスケープ後&lt;b&gt;こんにちは&lt;/b&gt; &amp; ありがとう(いつも同じ)

学生が入力するセルは、このプレビューでは空白です。

このラボとソースについて: AI コーディングエージェントのラボ:opencode でコードを説明・修正し、その結果を確かめる

授業で使いますか?このモジュールを学生に割り当てると、教員用ガイド、自動採点つきの課題、学生ごとのラボを使えます。