🦔

useStreamで作るInertia.jsでのAI Chatシステム(1) - 通常のFormとSSEの違いを確認する

に公開


作りたいもの

最近では一般的になりつつある(かもしれない)のがAIとチャットするシステムであると思う。
これは少なくとも自作するのはともかく利用者は極端に増えている。一昔前ではほとんど見られなかったシステムであるが、最近のものは「タイプライター」のように文字が溢れるように送信されるものを少なくともどこかで一度は見たことがあると思う。あれはSSEという手法でテキストを分割して逐次送信されたものをクライアントがこちらも小刻みに受信しては出力していくという流れで構成されている。

ここでは従来の一般的なPOST → Redirect → 表示というInertia.jsでは極めて一般的なものからSSEを使ったチャットシステム、また2025年に肝入りで現れたUseStreamを用いた手法でこのチャットシステムをInertia.jsで構築する。なお、ここではReactを使う、VueJSのユーザーはどうぞご容赦頂きたい。

一般的なInertia.jsの <Form /> で送る方法


POSTで送ったらリプライが付いて戻ってくる

このような状態は、一般的なInertia.jsでのPOSTの(あるいは従前のHTTPの)流れとしてはよく見られるものの、特にAIを用いたチャットシステムなどでは一度に応答されるのと、その応答への反応を待つ時間などがありUXがよくないものとなっている。

この状態においては余計なものを取り除いて重要な部分だけを残したら以下のようなフォームで説明できる

resources/js/components/chat/chat-panel.tsx
<Form
    action={message()}
    preserveScroll
    onSuccess={() => setDraftMessage('')}
>
    <Textarea
        name="message"
        value={draftMessage}
        onChange={(e) => setDraftMessage(e.target.value)}
        placeholder="メッセージを入力..."
    />
    <Button type="submit" disabled={!draftMessage.trim()} >
        送信
    </Button>
</Form>

一般的なPOSTということでInertia.js<Form>を用いて送信した。

ここで、バックエンドでは以下のようになっている

    Route::get('dashboard', function (Request $request) {
        // ログインユーザーの最新の会話を取得
        $conversation = $request->user()
            ->conversations()
            ->with('messages')
            ->latest('updated_at')
            ->first();

        // メッセージをフォーマット
        $messages = $conversation->messages->map(function ($message) {
            return [
                'id' => (string) $message->id,
                'role' => $message->role,
                'content' => $message->content,
                'timestamp' => $message->created_at->format('H:i'),
            ];
        })->values()->all();

        return Inertia::render('dashboard', [
            'conversationId' => $conversation->id,
            'messages' => $messages,
        ]);
    })->name('dashboard');

    Route::post('chat/message', function (Request $request) {
        $validated = $request->validate([
            'conversation_id' => 'required|integer|exists:conversations,id',
            'message' => 'required|string|max:5000',
        ]);

        $conversation = $request->user()
            ->conversations()
            ->findOrFail($validated['conversation_id']);

        // ユーザーメッセージを保存
        Message::create([
            'conversation_id' => $conversation->id,
            'role' => 'user',
            'model' => null,
            'content' => $validated['message'],
        ]);

        // 固定のAI応答を保存
        Message::create([
            'conversation_id' => $conversation->id,
            'role' => 'assistant',
            'model' => null,
            'content' => 'かしこまりました。プロジェクトの進捗報告書を作成いたします。以下の情報をお聞かせください:'."\n\n".'1. プロジェクト名'."\n".'2. 報告期間'."\n".'3. 主な成果'."\n".'4. 課題や問題点'."\n\n".'これらの情報をもとに報告書を作成いたします。',
        ]);

        // 会話のupdated_atを更新
        $conversation->touch();

        // ダッシュボードにリダイレクト
        return redirect()->route('dashboard');
    })->name('chat.message');

つまり、ユーザーのメッセージを付け、その後でAI(風)の固定メッセージ(ダミー)を付けてリダイレクトし、ログインユーザーの最新の会話を取得しているだけだ。これでも一応要件は満たせてはいる。ただ、ChatというにはあまりにもUXに乏しいので以下で改良していく。

Chatっぽい応答のためのSSEストリーミング

ここでSSEストリーミングをInertia.jsを用いて実装する。

受信側の変更

まずはrouteを以下のようにする

routes/web.php
// SSE streaming endpoint - Inertiaミドルウェアを除外
Route::middleware(['auth', 'verified'])->group(function () {
    Route::post('chat/message', function (Request $request) {
        $validated = $request->validate([
            'conversation_id' => 'required|integer|exists:conversations,id',
            'message' => 'required|string|max:5000',
        ]);

        $conversation = $request->user()
            ->conversations()
            ->findOrFail($validated['conversation_id']);

        // ユーザーメッセージを保存
        $userMessage = Message::create([
            'conversation_id' => $conversation->id,
            'role' => 'user',
            'model' => null,
            'content' => $validated['message'],
        ]);

        // 会話のupdated_atを更新
        $conversation->touch();

        // 固定のAI応答
        $response = 'かしこまりました。プロジェクトの進捗報告書を作成いたします。以下の情報をお聞かせください:'."\n\n".'1. プロジェクト名'."\n".'2. 報告間'."\n".'3. 主な成果'."\n".'4. 課題や問題点'."\n\n".'これらの情報をもとに報告書を作成いたします。';

        // アシスタントメッセージを作成
        $assistantMessage = Message::create([
            'conversation_id' => $conversation->id,
            'role' => 'assistant',
            'model' => null,
            'content' => $response,
        ]);

        // レスポンスを分割(文字単位でゆっくり送信)
        $chunks = mb_str_split($response, 1);

        // SSEストリーミングレスポンスを返す
        return response()->stream(function () use ($assistantMessage, $chunks) {
            // ヘッダーをコールバック内で設定(重要!)
            header('Content-Type: text/event-stream');
            header('Cache-Control: no-cache');
            header('Connection: keep-alive');
            header('X-Accel-Buffering: no');

            // チャンクをストリーミング
            foreach ($chunks as $chunk) {
                echo 'data: '.json_encode([
                    'type' => 'chunk',
                    'content' => $chunk,
                ], JSON_UNESCAPED_UNICODE)."\n";

                if (ob_get_level() > 0) {
                    ob_flush();
                }
                flush();

                // タイピング効果のための遅延(50ms)
                usleep(50000);
            }

            // 完了を送信
            echo 'data: '.json_encode([
                'type' => 'done',
                'timestamp' => $assistantMessage->created_at->format('H:i'),
            ], JSON_UNESCAPED_UNICODE)."\n";

            if (ob_get_level() > 0) {
                ob_flush();
            }
            flush();
        }, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'Connection' => 'keep-alive',
            'X-Accel-Buffering' => 'no',
        ]);
    })->name('chat.message')->withoutMiddleware([HandleInertiaRequests::class]);
});

メッセージの保存までの流れは前と同じであるが、保存された後のリダイレクトの処理からメッセージを分割送信する処理に変更されている。

        // レスポンスを分割(文字単位でゆっくり送信)
        $chunks = mb_str_split($response, 1);
        // ...

さらにheader()を用いて完全にHTTPヘッダを制御する。これは、特種系の処理でないとLaravelを書いている限りなかなか現われないものだ。

            header('Content-Type: text/event-stream');
            header('Cache-Control: no-cache');
            header('Connection: keep-alive');
            header('X-Accel-Buffering: no');

ここではContent-Type: text/event-streamというコンテントタイプで返却する決まりとなっている。

なお、Redirectを掴まえて何となくSPAにするというInertia.jsでは面倒見きれないので

    })->name('chat.message')->withoutMiddleware([HandleInertiaRequests::class]);

この withoutMiddlewareによってInertiaリクエストを完全に除外している。

送信側の変更

さらに送信側も最早Inertia.jsではないのでrouter.postあるいはrouter.visitなどは全く使えない

今回の SSE ルートは Inertia ミドルウェアも外しているため fetch Axios などの素の HTTP クライアントを使う必要がある。従って今回は以下のようにした。折り畳み前提なので必要に応じて開閉して確認いただきたい。

Chat送信側のソースコード
resources/js/components/chat/chat-panel.tsx
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useInitials } from '@/hooks/use-initials';
import { cn } from '@/lib/utils';
import { message } from '@/routes/chat';
import type { User } from '@/types';
import { Bot, Mic, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';

export type ChatMessage = {
    id: string;
    role: 'user' | 'assistant';
    content: string;
    timestamp: string;
};

type ChatPanelProps = {
    conversationId: number;
    messages: ChatMessage[];
    user: User;
};

export default function ChatPanel({
    conversationId,
    messages: initialMessages,
    user,
}: ChatPanelProps) {
    const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
    const [draftMessage, setDraftMessage] = useState('');
    const [isStreaming, setIsStreaming] = useState(false);
    const [streamingMessageId, setStreamingMessageId] = useState<
        string | null
    >(null);
    const getInitials = useInitials();
    const messagesEndRef = useRef<HTMLDivElement>(null);

    // メッセージリストを自動スクロール
    const scrollToBottom = useCallback(() => {
        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
    }, []);

    useEffect(() => {
        scrollToBottom();
    }, [messages, scrollToBottom]);

    // SSEストリーミングでメッセージを送信
    const handleSubmit = async (event: React.FormEvent) => {
        event.preventDefault();
        if (!draftMessage.trim() || isStreaming) {
            return;
        }

        const messageContent = draftMessage.trim();

        // ユーザーメッセージを即座にUIに追加
        const userMessage: ChatMessage = {
            id: `user-${Date.now()}`,
            role: 'user',
            content: messageContent,
            timestamp: new Date().toLocaleTimeString('ja-JP', {
                hour: '2-digit',
                minute: '2-digit',
            }),
        };

        // アシスタントメッセージを準備
        const assistantId = `assistant-${Date.now()}`;
        const assistantMessage: ChatMessage = {
            id: assistantId,
            role: 'assistant',
            content: '',
            timestamp: '',
        };

        setMessages((prev) => [...prev, userMessage, assistantMessage]);
        setStreamingMessageId(assistantId);
        setDraftMessage('');
        setIsStreaming(true);

        try {
            const response = await fetch(message().url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    Accept: 'text/event-stream',
                },
                body: JSON.stringify({
                    conversation_id: conversationId,
                    message: messageContent,
                }),
            });

            if (!response.ok) {
                throw new Error('Failed to send message');
            }

            const reader = response.body?.getReader();
            const decoder = new TextDecoder();

            if (!reader) {
                throw new Error('No response body');
            }

            let assistantContent = '';

            while (true) {
                const { done, value } = await reader.read();

                if (done) {
                    break;
                }

                const chunk = decoder.decode(value, { stream: true });
                const lines = chunk.split('\n');

                for (const line of lines) {
                    if (!line.startsWith('data: ')) continue;

                    const jsonStr = line.substring(6);
                    const data = JSON.parse(jsonStr);

                    if (data.type === 'chunk') {
                        assistantContent += data.content;
                        setMessages((prev) =>
                            prev.map((msg) =>
                                msg.id === assistantId
                                    ? {
                                          ...msg,
                                          content: assistantContent,
                                      }
                                    : msg,
                            ),
                        );
                    } else if (data.type === 'done') {
                        setMessages((prev) =>
                            prev.map((msg) =>
                                msg.id === assistantId
                                    ? {
                                          ...msg,
                                          timestamp: data.timestamp,
                                      }
                                    : msg,
                            ),
                        );
                    }
                }
            }
        } catch (error) {
            console.error('Error streaming message:', error);
            setMessages((prev) =>
                prev.map((msg) =>
                    msg.id === assistantId
                        ? {
                              ...msg,
                              content:
                                  'エラーが発生しました。もう一度お試しください。',
                              timestamp: new Date().toLocaleTimeString(
                                  'ja-JP',
                                  {
                                      hour: '2-digit',
                                      minute: '2-digit',
                                  },
                              ),
                          }
                        : msg,
                ),
            );
        } finally {
            setIsStreaming(false);
            setStreamingMessageId(null);
        }
    };

    return (
        <section className="flex min-h-[70vh] flex-1 flex-col rounded-2xl border border-sidebar-border/70 bg-card shadow-sm dark:border-sidebar-border">
            {/* ヘッダー */}
            <header className="border-b border-sidebar-border/70 px-6 py-5 dark:border-sidebar-border">
                <div className="flex items-center justify-between">
                    <div>
                        <h3 className="mb-0.5 text-base font-medium">
                            チャット
                        </h3>
                        <p className="text-sm text-muted-foreground">
                            AIアシスタントと会話できます
                        </p>
                    </div>
                    <Button type="button" variant="outline" size="sm" disabled>
                        <Trash2 className="size-4" />
                        履歴クリア
                    </Button>
                </div>
            </header>

            <div className="flex flex-1 flex-col overflow-hidden">
                {/* メッセージ一覧 */}
                <div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
                    {messages.map((message) => {
                        const isUser = message.role === 'user';
                        return (
                            <div
                                key={message.id}
                                className={cn(
                                    'flex gap-3',
                                    isUser
                                        ? 'flex-row-reverse text-right'
                                        : 'text-left',
                                )}
                            >
                                <Avatar
                                    className={cn(
                                        isUser
                                            ? 'h-8 w-8 overflow-hidden rounded-full'
                                            : 'bg-muted text-foreground',
                                    )}
                                >
                                    {isUser ? (
                                        <>
                                            <AvatarImage
                                                src={user.avatar}
                                                alt={user.name}
                                            />
                                            <AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
                                                {getInitials(user.name)}
                                            </AvatarFallback>
                                        </>
                                    ) : (
                                        <AvatarFallback>
                                            <Bot className="size-4" />
                                        </AvatarFallback>
                                    )}
                                </Avatar>
                                <div className="max-w-3xl space-y-2">
                                    <div
                                        className={cn(
                                            'rounded-2xl border px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap',
                                            isUser
                                                ? 'border-primary/40 bg-primary text-primary-foreground'
                                                : 'border-transparent bg-muted text-muted-foreground dark:text-muted-foreground/90',
                                        )}
                                    >
                                        {message.content}
                                        {message.id === streamingMessageId && (
                                            <span className="ml-1 inline-block h-4 w-1 animate-pulse
bg-current" />
                                        )}
                                    </div>
                                    <div
                                        className={cn(
                                            'flex items-center gap-2 text-xs text-muted-foreground',
                                            isUser && 'justify-end',
                                        )}
                                    >
                                        <span>{message.timestamp}</span>
                                    </div>
                                </div>
                            </div>
                        );
                    })}
                    <div ref={messagesEndRef} />
                </div>

                {/* 入力フォーム */}
                <form
                    onSubmit={handleSubmit}
                    className="border-t border-sidebar-border/70 px-4 py-4 dark:border-sidebar-border"
                >
                    <div className="rounded-2xl border border-sidebar-border/70 bg-background shadow-xs focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 dark:border-sidebar-border">
                        <Textarea
                            value={draftMessage}
                            onChange={(e) => setDraftMessage(e.target.value)}
                            placeholder="メッセージを入力..."
                            disabled={isStreaming}
                            className="min-h-[96px] resize-none rounded-2xl border-0 px-4 py-3 text-sm shadow-none focus-visible:ring-0"
                        />
                        <div className="flex flex-wrap items-center gap-3 border-t border-sidebar-border/70 px-4 py-3 text-xs text-muted-foreground dark:border-sidebar-border">
                            <span>Enter キーで改行</span>
                            <div className="ml-auto flex items-center gap-2">
                                <Button
                                    type="button"
                                    variant="outline"
                                    size="sm"
                                    disabled
                                >
                                    <Mic className="size-4" />
                                    音声入力
                                </Button>
                                <Button
                                    type="submit"
                                    disabled={
                                        !draftMessage.trim() || isStreaming
                                    }
                                >
                                    {isStreaming ? '送信中...' : '送信'}
                                </Button>
                            </div>
                        </div>
                    </div>
                </form>
            </div>
        </section>
    );
}

今回は取り敢えずCSRFに関してスルーさせる。検証を最初に行いたい為。

bootstrap/app.php
@@ -16,6 +16,10 @@
     ->withMiddleware(function (Middleware $middleware): void {
         $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);

+        $middleware->validateCsrfTokens(except: [
+            'chat/message', // chat/messageではCSRFの検証を無効にする
+        ]);
+
         $middleware->web(append: [
             HandleAppearance::class,
             HandleInertiaRequests::class,

これで大幅にAIチャットシステムっぽくなる

以下にSSEの挙動のアニGIFを貼り付けるので、まずは前のアニGIFとの違いを見ていただいた方がよいだろう。


SSEを使ったタイピングのような効果

このように一気にChatGPT的なアレになった。なお、ここでは

routes/web.php
            // チャンクをストリーミング
            foreach ($chunks as $chunk) {
                echo 'data: '.json_encode([
                    'type' => 'chunk',
                    'content' => $chunk,
                ], JSON_UNESCAPED_UNICODE)."\n";

                if (ob_get_level() > 0) {
                    ob_flush();
                }
                flush();

                // タイピング効果のための遅延(50ms)
                usleep(50000);
            }

ここでの遅延をあえてデカくとってわざとらしくタイピング効果を強調している。

まとめ

Inertia.jsでタイピング的な効果を与えるSSEストリーミングを実装する場合Inertiaミドルウェアを無効にする必要がある。ただし、その場合非常にローレベルの処理を手書きする事が多くなってくるため細かい処理の記述が増えてくる。次回はこれに対して主に送信側の負担を軽減するuseStreamについて解説する。

Discussion