0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LaravelでBedrockのチャット画面を作る

0
Last updated at Posted at 2026-10-05

LaravelでBedrockを使うチャット画面を作ったとき、まず確認したかったのは、ブラウザーから送った文章に対してAIの返信を返せることでした。履歴保存や検索をまとめて完成扱いにせず、入力・推論・返信表示という経路を先に揃えます。

2026年6月には、Bedrockをバックエンドにしてチャット形式で使える段階まで実装した記録があります。一方、その後に提案されたRAG、ファイル要約、履歴管理、ストリーミングは、今回取得した記録では完了を確認できていません。

この記事では、その最初の経路をLaravelの小さな例へ組み直し、応答が失敗したときの画面も含めて整理します。元の画面・コードの再現ではありません。

画面ができることと、会話が続くことを分ける

自分が当時作ったものは、チャット形式でAIを使うWebアプリです。ただ、見た目がチャットでも、過去のやり取りをモデルへ送るか、DBへ残すか、途中の回答を表示するかは別の実装になります。

ここでの例は一問一答です。入力した一つの文章だけをConverseへ送ります。画面へ過去の返信を追加しても、それだけでモデルに会話履歴が渡るわけではありません。

AWSを呼ぶ部分を画面の処理から取り出す

まず、Bedrockへ文章を渡す処理をサービスへ置きます。リージョンとモデルIDはconfig/bedrock.phpなどで管理し、実環境に合う対応モデルまたは推論プロファイルを指定します。認証情報はサーバー側のSDK取得経路へ任せ、ブラウザーへ渡しません。

// AppServiceProvider::register()内の登録例。
$this->app->singleton(\Aws\BedrockRuntime\BedrockRuntimeClient::class, function () {
    return new \Aws\BedrockRuntime\BedrockRuntimeClient([
        'version' => 'latest',
        'region' => config('bedrock.region'),
        'http' => ['connect_timeout' => 5, 'timeout' => 25],
    ]);
});
<?php
namespace App\Services;

use Aws\BedrockRuntime\BedrockRuntimeClient;
use RuntimeException;

final class BedrockChat
{
    public function __construct(private readonly BedrockRuntimeClient $client) {}

    public function ask(string $input): string
    {
        $modelId = config('bedrock.model_id');
        if (!is_string($modelId) || $modelId === '') {
            throw new RuntimeException('Model is not configured');
        }
        $result = $this->client->converse([
            'modelId' => $modelId,
            'messages' => [['role' => 'user', 'content' => [['text' => $input]]]],
            'inferenceConfig' => ['maxTokens' => 512],
        ]);
        if (($result['stopReason'] ?? null) !== 'end_turn') {
            throw new RuntimeException('Incomplete response');
        }
        $texts = [];
        foreach ($result['output']['message']['content'] ?? [] as $block) {
            if (isset($block['text'])) $texts[] = $block['text'];
        }
        $answer = implode("\n", $texts);
        if ($answer === '') throw new RuntimeException('No text response');
        return $answer;
    }
}

先頭のcontentブロックだけを見るのではなく、テキストを集めています。Converseの仕様では応答と終了理由を別に確認できます。この例では、正常に終了したテキスト応答だけを成功にしています。

max_tokensで終わった応答やツール要求などは、一律に成功表示しません。実際の製品では、途中の回答として表示するか、別の案内を出すかを決める余地があります。ここでは一問一答の確認へ範囲を絞っています。

エンドポイントでは入力と失敗を揃える

次はroutes/web.phpに置く例です。既存のログイン環境があり、webミドルウェアとCSRF検証を使う前提です。ログイン機能そのものをこの例で追加するわけではありません。

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Services\BedrockChat;
use Aws\Exception\AwsException;

Route::post('/chat', function (Request $request, BedrockChat $chat) {
    $input = $request->validate(['message' => ['required', 'string', 'max:4000']]);
    try {
        return response()->json(['answer' => $chat->ask($input['message'])]);
    } catch (AwsException | \RuntimeException $e) {
        // 認証値・入力・AWSの例外詳細を利用者の画面へ返さない。
        return response()->json(['message' => '返信を取得できませんでした。'], 502);
    }
})->middleware(['auth', 'throttle:10,1']);

入力長、送信頻度、推論の出力上限は、それぞれ別の制限です。この数値は最小例の値であり、運用の上限を検証したものではありません。SDKの再試行、プロキシやPHP側の待機時間も含めて、実環境で見直します。

タイムアウトは画面側にも関係します。画面の待機を打ち切っても、サーバーの推論処理まで必ず取り消せるとは限りません。エラー表示の後に自動で何度も再送する構成にはしていません。

返信表示にはHTMLとして解釈しない方法を使う

Bladeの画面側では、CSRFトークンとフォームを用意します。LaravelのCSRF資料に合わせて、同じセッションから送信する構成です。

<meta name="csrf-token" content="{{ csrf_token() }}">
<form id="chat-form">
  <textarea id="chat-input" maxlength="4000" required></textarea>
  <button id="chat-send" type="submit">送信</button>
</form>
<div id="chat-output" aria-live="polite"></div>
const form = document.querySelector('#chat-form');
const input = document.querySelector('#chat-input');
const button = document.querySelector('#chat-send');
const output = document.querySelector('#chat-output');
let busy = false;
form.addEventListener('submit', async (event) => {
  event.preventDefault();
  if (busy || !input.value.trim()) return;
  busy = true;
  button.disabled = true;
  output.textContent = '返信を待っています。';
  try {
    const response = await fetch('/chat', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
      },
      body: JSON.stringify({ message: input.value }),
      signal: AbortSignal.timeout(30000),
    });
    if (!response.ok) throw new Error('Request failed');
    const body = await response.json();
    if (typeof body.answer !== 'string' || !body.answer.trim()) {
      throw new Error('No answer');
    }
    output.textContent = body.answer;
  } catch {
    output.textContent = '返信を取得できませんでした。入力内容を残しています。';
  } finally {
    busy = false;
    button.disabled = false;
  }
});

入力は成功時も含めて消しません。返信を確認してから修正・再送できる、簡単な検証画面としての動きです。また、モデルの返信をinnerHTMLへ渡さず、文字列として表示しています。Markdown表示を加える場合には、レンダラーと無害化の扱いを別に確認します。

動作確認は、返信成功と画面復帰を分ける

記事用のブラウザー側コードは、DOMとfetchのモックで検証しました。成功時の文字列表示、HTTPエラー、通信例外、空の応答、処理中の二重送信抑止、失敗後の入力保持と送信ボタン復帰を確認対象にしています。

ただし、この検証はLaravelとBedrockの結合試験ではありません。PHP例の実行、AWSの認証、実際の推論、実ブラウザーの表示は今回再確認していません。

まとめ

最初のチャット画面では、文章を受け取り、推論へ渡し、返信を表示する経路を揃えます。その中でも、モデルの応答成功と、失敗後に画面が操作できることは別に試します。

履歴保存、ストリーミング、検索を加える段階では、今の最小構成で何を確認済みかを残しておくと、追加した処理の問題を追いやすくなります。公式資料の確認日は2026年10月2日です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?