1
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?

Spring AI検証:会話履歴の保存機能を実装する

1
Last updated at Posted at 2025-12-03

前回はストリーミング応答とマルチモーダル入力を実装しました。
今回は会話履歴を保存して、文脈を理解した応答ができるようにします。どすこい。

私の環境

  • macOS 14.6
  • JDK 21
  • Spring Boot 3.5.7
  • Spring AI 1.1.0

やること

  • なぜ会話履歴が必要か理解する
  • Chat Memory の仕組みを学ぶ
  • 会話履歴を保存する機能を実装する
  • 動作確認
  • 前回作ったコントローラーにも機能を適用する

使用するモデル

  • amazon.nova-lite-v1:0

なぜ会話履歴が必要か

こちらは理解している方も多いですが、自分の中での理解として改めて言語化してみたいと思います。

基本的にLLMとの会話はChatGPTなどとの会話では裏で実装されているだけで、ステートレス(状態を持たない)です。
なので、毎回のリクエストが独立していて、前の会話は覚えていません。

前回まで実装してきた機能だけだと下記のようなケースになります。

  1. ユーザー「私の名前はどすこいはんぺんです」→ AI「素敵なお名前ですね、どすこいはんぺんさん!」

  2. ユーザー「私の名前は何ですか?」→ AI「すみません、まだお名前を伺っていないようです」

せっかく名乗ったのに忘れられるの悲しすぎますね。どすこい。
会話履歴を保存し、毎回のリクエストに含めることで、文脈を理解した応答ができるようになります。そして悲しみを乗り越えられます(?)

Chat Memory の仕組み

Spring AIではChatMemoryとMessageChatMemoryAdvisorを使って会話履歴を管理できるようです。

ChatMemory: https://spring.pleiades.io/spring-ai/reference/api/chat-memory.html

仕組みについては下記です:

  1. ユーザーのメッセージとAIの応答を履歴として保存
  2. 次のリクエスト時に保存した履歴を取得してプロンプトに含める
  3. AIは履歴を含めたプロンプトの文脈を見て、理解した応答を返す

ChatMemory主要なコンポーネントは2つあります:

  • ChatMemory - 会話履歴を保存・取得するインターフェース
    実装には MessageWindowChatMemory(メモリ保存)や JdbcChatMemoryRepository(DB保存)などがあります。
  • MessageChatMemoryAdvisor
    ChatClient に設定するアドバイザー
    リクエスト時、自動で履歴を取得してプロンプトに含め、レスポンス後、履歴を保存してくれます。

今回はシンプルに実装しちゃいたいので、MessageWindowChatMemoryを使い、メモリ上に会話履歴を保存する形で実装していきます。

会話履歴を保存する機能を実装する

src/main/java/com/example/spring_ai_sample/controllerにMemoryChatController.javaを作成します。

package com.example.spring_ai_sample.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * 会話履歴を保持するコントローラー
 */
@RestController
public class MemoryChatController {

    private final ChatClient chatClient;

    @Autowired
    public MemoryChatController(ChatClient.Builder chatClientBuilder) {
        // MessageWindowChatMemory で会話履歴をメモリに保存
        ChatMemory chatMemory = MessageWindowChatMemory.builder().build();
        
        this.chatClient = chatClientBuilder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }

    /**
     * /memory-chat エンドポイント
     * 会話履歴を保持しながら応答を返す
     */
    @GetMapping("/memory-chat")
    public String memoryChat(
            @RequestParam String prompt,
            @RequestParam(defaultValue = "default") String conversationId) {
        
        return chatClient.prompt()
                .user(prompt)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .call()
                .content();
    }
}

ポイントは以下の3つです:

  • MessageWindowChatMemory
    会話履歴をメモリに保存
    デフォルトで最新20件を保持し、超えると古いメッセージから削除されます。
    保存件数は.maxMessages(任意の数値)で変更可能です。
  • MessageChatMemoryAdvisor
    リクエスト時に自動で履歴を取得・保存してくれます。
  • conversationId
    会話を識別するID(ユーザーごとで分けたい場合に使用)

MessageWindowChatMemoryの注意点
今回使用したMessageWindowChatMemoryは、シンプルに実装できるのですが、メモリ上に保存することから以下のデメリットがあります。

  • アプリ再起動で消える
  • 複数のインスタンス(サーバー)で共有できない
  • メモリを消費し続ける※
    ※ 会話ID(conversationId)ごとに履歴を20件(デフォルトの場合)保持するので、ユーザーが増え続けると会話IDの数だけメモリを消費し続けます。

上記の理由から、本番環境で使用する場合はJdbcChatMemoryRepository(PostgreSQL、MySQL など)や CassandraChatMemoryRepository、Neo4jChatMemoryRepositoryを使って永続化することをおすすめします。

DynamoDBなど組み込みで提供されていないストレージを使いたい場合は、ChatMemoryRepositoryインターフェースを実装することで実現できそうです。

動作確認

SpringAiSampleApplicationを実行してアプリケーションを起動しましょう。

image.png

会話履歴が保持されるかcurlコマンドで確認してみましょう

# 1回目:名前を伝える
curl -G "http://localhost:8080/memory-chat" \
  --data-urlencode "prompt=私の名前はどすこいはんぺんです" \
  --data-urlencode "conversationId=user1"

# 2回目:名前を聞く
curl -G "http://localhost:8080/memory-chat" \
  --data-urlencode "prompt=私の名前は何ですか?" \
  --data-urlencode "conversationId=user1"

名前を覚えてくれていました!

あなたの名前は「どすこいはんぺん」さんです。このやり取りの中で、あなたが「どすこいはんぺん」と名乗りましたよね。何か他にお手伝いできることはありますか?

次に会話IDを変えると履歴が共有されないことを確認してみましょう

# 別の会話IDで名前を聞く
curl -G "http://localhost:8080/memory-chat" \
  --data-urlencode "prompt=私の名前は何ですか?" \
  --data-urlencode "conversationId=user2"

下記のような回答になりました!
会話IDで履歴管理ができていますね!

申し訳ありませんが、あなたの名前は私にはわかりません。あなたがどのような名前を希望しているのか、または特定のニックネームや名前の候補について話し合いたい場合は、ぜひ教えてください。また、あなたが興味を持っている話題や質問があれば、それについてもお話しできます

前回作ったコントローラーにも機能を適用する

問題なく機能していることを確認できたので、前回作ったテキストチャット、画像分析、動画分析のコントローラーにもChat Memoryを適用していきましょう!

StreamingChatController(テキストチャット)

Chat Memoryを適用するために以下を追加します:

  • MessageWindowChatMemoryとMessageChatMemoryAdvisorの設定
  • conversationIdパラメータの追加
package com.example.spring_ai_sample.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; // 追加
import org.springframework.ai.chat.memory.ChatMemory; // 追加
import org.springframework.ai.chat.memory.MessageWindowChatMemory; // 追加
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

/**
 * ストリーミング応答を返すコントローラー
 */
@RestController
public class StreamingChatController {

    private final ChatClient chatClient;

    @Autowired
    public StreamingChatController(ChatClient.Builder chatClientBuilder) {
        ChatMemory chatMemory = MessageWindowChatMemory.builder().build(); // 追加

        this.chatClient = chatClientBuilder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // 追加
                .build();
    }

    /**
     * /stream-chat エンドポイント
     * Server-Sent Events (SSE) でストリーミング応答を返す
     */
    @GetMapping(value = "/stream-chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamingChat(
            @RequestParam String prompt,
            @RequestParam(defaultValue = "default") String conversationId) { // 追加

        return chatClient.prompt()
                .user(prompt)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) // 追加
                .stream()
                .content();
    }
}

ImageChatController(画像分析)

Chat Memoryを適用するために以下を追加します:

  • MessageWindowChatMemoryとMessageChatMemoryAdvisorの設定
  • conversationIdパラメータの追加
  • imageパラメータをrequired = falseに変更(画像なしでも会話継続できるようにするため)
package com.example.spring_ai_sample.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; // 追加
import org.springframework.ai.chat.memory.ChatMemory; // 追加
import org.springframework.ai.chat.memory.MessageWindowChatMemory; // 追加
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

/**
 * 画像分析用コントローラー
 */
@RestController
public class ImageChatController {

    private final ChatClient chatClient;

    @Autowired
    public ImageChatController(ChatClient.Builder chatClientBuilder) {
        ChatMemory chatMemory = MessageWindowChatMemory.builder().build(); // 追加

        this.chatClient = chatClientBuilder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // 追加
                .build();
    }

    /**
     * /image-chat エンドポイント
     * 画像とテキストを受け取り、分析結果を返す
     * 画像なしの場合は会話履歴を使って応答
     */
    @PostMapping("/image-chat")
    public String imageChat(
            @RequestParam("prompt") String prompt,
            // 変更: required = false を追加して画像をオプショナルに
            // これにより「さっきの画像について詳しく教えて」のような追加質問が可能になる
            @RequestParam(value = "image", required = false) MultipartFile image,
            @RequestParam(defaultValue = "default") String conversationId) throws IOException { // 追加

        // 画像がある場合:画像付きでAIに問い合わせ
        if (image != null && !image.isEmpty()) {
            byte[] imageBytes = image.getBytes();

            return chatClient.prompt()
                    .user(u -> u.text(prompt)
                            .media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(imageBytes)))
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) // 追加
                    .call()
                    .content();
        }

        // 画像がない場合:テキストのみでAIに問い合わせ(会話履歴から画像の内容を参照できる)
        return chatClient.prompt()
                .user(prompt)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) // 追加
                .call()
                .content();
    }
}

VideoChatController(動画分析)

Chat Memoryを適用するために以下を追加します:

  • MessageWindowChatMemoryとMessageChatMemoryAdvisorの設定
  • conversationIdパラメータの追加
  • video パラメータをrequired = falseに変更(動画なしでも会話継続できるようにするため)
package com.example.spring_ai_sample.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; // 追加
import org.springframework.ai.chat.memory.ChatMemory; // 追加
import org.springframework.ai.chat.memory.MessageWindowChatMemory; // 追加
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

/**
 * 動画分析用コントローラー
 */
@RestController
public class VideoChatController {

    private final ChatClient chatClient;

    @Autowired
    public VideoChatController(ChatClient.Builder chatClientBuilder) {
        ChatMemory chatMemory = MessageWindowChatMemory.builder().build(); // 追加

        this.chatClient = chatClientBuilder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // 追加
                .build();
    }

    /**
     * /video-chat エンドポイント
     * 動画とテキストを受け取り、分析結果を返す
     * 動画なしの場合は会話履歴を使って応答
     */
    @PostMapping("/video-chat")
    public String videoChat(
            @RequestParam("prompt") String prompt,
            // 変更: required = false を追加して動画をオプショナルに
            // これにより「さっきの動画について詳しく教えて」のような追加質問が可能になる
            @RequestParam(value = "video", required = false) MultipartFile video,
            @RequestParam(defaultValue = "default") String conversationId) throws IOException { // 追加

        // 動画がある場合:動画付きでAIに問い合わせ
        if (video != null && !video.isEmpty()) {
            byte[] videoBytes = video.getBytes();

            return chatClient.prompt()
                    .system("あなたは日本語で回答するアシスタントです。")
                    .user(u -> u.text(prompt)
                            .media(MimeTypeUtils.parseMimeType("video/mp4"), new ByteArrayResource(videoBytes)))
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) // 追加
                    .call()
                    .content();
        }

        // 動画がない場合:テキストのみでAIに問い合わせ(会話履歴から動画の内容を参照できる)
        return chatClient.prompt()
                .system("あなたは日本語で回答するアシスタントです。")
                .user(prompt)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) // 追加
                .call()
                .content();
    }
}

HTML(フロントエンド)の修正

そのままだとやりとりしている感がなくて楽しくないので、UIを改修し、以下を追加・変更します:

  • 各タブにチャット履歴表示エリアを追加
  • 送信メッセージと返答が履歴として表示されるように変更
  • conversationIdを各リクエストに追加
  • 「履歴クリア」ボタンを追加(サーバー側の履歴もリセット)
  • 画像・動画のサムネイル表示に対応

会話IDはフロントエンド側で生成しています。
今回は検証なのでタブごとに固定値(text-session、image-session、video-session)を使い、「履歴クリア」時にタイムスタンプ付きの新しいIDを生成することでサーバー側の履歴もリセットしています。
本番では、ユーザー認証を行い、ユーザーIDを会話IDとして使用するようにする方が良いかと思います。

コード全文
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Spring AI Chat Demo</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }

        .container {
            background: white;
            border-radius: 20px;
            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
            max-width: 800px;
            width: 100%;
            overflow: hidden;
        }

        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 30px;
            text-align: center;
        }

        .header h1 {
            font-size: 28px;
            margin-bottom: 10px;
        }

        .tabs {
            display: flex;
            background: #f8f9fa;
            border-bottom: 2px solid #e9ecef;
        }

        .tab {
            flex: 1;
            padding: 15px;
            text-align: center;
            cursor: pointer;
            background: #f8f9fa;
            border: none;
            font-size: 16px;
            transition: all 0.3s;
        }

        .tab:hover {
            background: #e9ecef;
        }

        .tab.active {
            background: white;
            border-bottom: 3px solid #667eea;
            font-weight: bold;
        }

        .tab-content {
            display: none;
            padding: 30px;
        }

        .tab-content.active {
            display: block;
        }

        .chat-container {
            height: 400px;
            overflow-y: auto;
            border: 2px solid #e9ecef;
            border-radius: 8px;
            padding: 15px;
            margin-bottom: 20px;
            background: #f8f9fa;
        }

        .message {
            margin-bottom: 15px;
            padding: 12px 16px;
            border-radius: 12px;
            max-width: 80%;
            word-wrap: break-word;
            white-space: pre-wrap;
            line-height: 1.5;
        }

        .message.user {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            margin-left: auto;
            border-bottom-right-radius: 4px;
        }

        .message.assistant {
            background: white;
            color: #333;
            border: 1px solid #e9ecef;
            border-bottom-left-radius: 4px;
        }

        .message.assistant.streaming {
            border-left: 3px solid #667eea;
        }

        .message .attachment {
            font-size: 12px;
            opacity: 0.8;
            margin-top: 5px;
        }

        .message img.thumbnail {
            max-width: 200px;
            max-height: 150px;
            border-radius: 8px;
            margin-top: 8px;
            display: block;
        }

        .message video.thumbnail {
            max-width: 200px;
            max-height: 150px;
            border-radius: 8px;
            margin-top: 8px;
            display: block;
        }

        .input-area {
            display: flex;
            gap: 10px;
            align-items: flex-end;
        }

        .input-area.with-file {
            flex-wrap: wrap;
        }

        .input-group {
            flex: 1;
        }

        .input-group input[type="text"],
        .input-group textarea {
            width: 100%;
            padding: 12px;
            border: 2px solid #e9ecef;
            border-radius: 8px;
            font-size: 16px;
            transition: border-color 0.3s;
            resize: none;
        }

        .input-group input[type="text"]:focus,
        .input-group textarea:focus {
            outline: none;
            border-color: #667eea;
        }

        .file-input {
            display: none;
        }

        .file-label {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 48px;
            height: 48px;
            background: #6c757d;
            color: white;
            border-radius: 8px;
            cursor: pointer;
            transition: background 0.3s;
            font-size: 20px;
        }

        .file-label:hover {
            background: #5a6268;
        }

        .file-label.has-file {
            background: #28a745;
        }

        .btn {
            padding: 12px 24px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            transition: transform 0.2s;
            height: 48px;
        }

        .btn:hover {
            transform: translateY(-2px);
        }

        .btn:disabled {
            background: #6c757d;
            cursor: not-allowed;
            transform: none;
        }

        .file-info {
            width: 100%;
            font-size: 12px;
            color: #6c757d;
            margin-top: 8px;
        }

        .clear-btn {
            background: #dc3545;
            padding: 8px 16px;
            font-size: 14px;
            height: auto;
            margin-bottom: 10px;
        }

        .error {
            color: #dc3545;
            background: #f8d7da;
            padding: 10px;
            border-radius: 8px;
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="header">
        <h1>🤖 Spring AI Chat Demo</h1>
        <p>Amazon Bedrock Nova Lite を使用</p>
    </div>

    <div class="tabs">
        <button class="tab active" onclick="switchTab('text')">💬 テキストチャット</button>
        <button class="tab" onclick="switchTab('image')">🖼️ 画像分析</button>
        <button class="tab" onclick="switchTab('video')">🎥 動画分析</button>
    </div>

    <!-- テキストチャット -->
    <div id="text-tab" class="tab-content active">
        <button class="btn clear-btn" onclick="clearChat('text')">🗑️ 履歴クリア</button>
        <div id="text-chat" class="chat-container"></div>
        <div class="input-area">
            <div class="input-group">
                <input type="text" id="text-prompt" placeholder="メッセージを入力..." onkeypress="handleKeyPress(event, 'text')">
            </div>
            <button class="btn" onclick="sendTextChat()">送信</button>
        </div>
    </div>

    <!-- 画像分析 -->
    <div id="image-tab" class="tab-content">
        <button class="btn clear-btn" onclick="clearChat('image')">🗑️ 履歴クリア</button>
        <div id="image-chat" class="chat-container"></div>
        <div class="input-area with-file">
            <input type="file" id="image-file" class="file-input" accept="image/*" onchange="updateFileLabel('image')">
            <label for="image-file" id="image-file-label" class="file-label">📎</label>
            <div class="input-group">
                <input type="text" id="image-prompt" placeholder="画像について質問..." onkeypress="handleKeyPress(event, 'image')">
            </div>
            <button class="btn" onclick="sendImageChat()">送信</button>
            <div id="image-file-info" class="file-info"></div>
        </div>
    </div>

    <!-- 動画分析 -->
    <div id="video-tab" class="tab-content">
        <button class="btn clear-btn" onclick="clearChat('video')">🗑️ 履歴クリア</button>
        <div id="video-chat" class="chat-container"></div>
        <div class="input-area with-file">
            <input type="file" id="video-file" class="file-input" accept="video/*" onchange="updateFileLabel('video')">
            <label for="video-file" id="video-file-label" class="file-label">📎</label>
            <div class="input-group">
                <input type="text" id="video-prompt" placeholder="動画について質問..." onkeypress="handleKeyPress(event, 'video')">
            </div>
            <button class="btn" onclick="sendVideoChat()">送信</button>
            <div id="video-file-info" class="file-info"></div>
        </div>
    </div>
</div>

<script>
    // 会話IDを生成(タブごとに固定)
    const conversationIds = {
        text: 'text-session',
        image: 'image-session',
        video: 'video-session'
    };

    // タブ切り替え
    function switchTab(tab) {
        document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
        document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));

        event.target.classList.add('active');
        document.getElementById(tab + '-tab').classList.add('active');
    }

    // Enterキーで送信
    function handleKeyPress(event, type) {
        if (event.key === 'Enter') {
            if (type === 'text') sendTextChat();
            else if (type === 'image') sendImageChat();
            else if (type === 'video') sendVideoChat();
        }
    }

    // ファイル選択時のラベル更新
    function updateFileLabel(type) {
        const fileInput = document.getElementById(type + '-file');
        const fileLabel = document.getElementById(type + '-file-label');
        const fileInfo = document.getElementById(type + '-file-info');

        if (fileInput.files[0]) {
            fileLabel.classList.add('has-file');
            fileLabel.textContent = '✓';
            fileInfo.textContent = '選択中: ' + fileInput.files[0].name;
        } else {
            fileLabel.classList.remove('has-file');
            fileLabel.textContent = '📎';
            fileInfo.textContent = '';
        }
    }

    // チャット履歴クリア
    function clearChat(type) {
        document.getElementById(type + '-chat').innerHTML = '';
        // 新しい会話IDを生成してサーバー側の履歴もリセット
        conversationIds[type] = type + '-session-' + Date.now();
    }

    // メッセージを追加
    function addMessage(chatId, content, isUser, attachment = null) {
        const chatContainer = document.getElementById(chatId);
        const messageDiv = document.createElement('div');
        messageDiv.className = 'message ' + (isUser ? 'user' : 'assistant');

        let html = content;
        if (attachment) {
            if (attachment.type === 'image') {
                html += `<img src="${attachment.url}" class="thumbnail" alt="添付画像">`;
            } else if (attachment.type === 'video') {
                html += `<video src="${attachment.url}" class="thumbnail" controls></video>`;
            }
        }

        messageDiv.innerHTML = html;
        chatContainer.appendChild(messageDiv);
        chatContainer.scrollTop = chatContainer.scrollHeight;

        return messageDiv;
    }

    // ストリーミング用のメッセージを追加
    function addStreamingMessage(chatId) {
        const chatContainer = document.getElementById(chatId);
        const messageDiv = document.createElement('div');
        messageDiv.className = 'message assistant streaming';
        messageDiv.textContent = '';
        chatContainer.appendChild(messageDiv);
        chatContainer.scrollTop = chatContainer.scrollHeight;
        return messageDiv;
    }

    // テキストチャット(ストリーミング)
    async function sendTextChat() {
        const promptInput = document.getElementById('text-prompt');
        const prompt = promptInput.value.trim();

        if (!prompt) {
            alert('メッセージを入力してください');
            return;
        }

        // ユーザーメッセージを追加
        addMessage('text-chat', prompt, true);
        promptInput.value = '';

        // AIの返答用メッセージを追加
        const responseDiv = addStreamingMessage('text-chat');

        const eventSource = new EventSource(`/stream-chat?prompt=${encodeURIComponent(prompt)}&conversationId=${conversationIds.text}`);

        eventSource.onmessage = (event) => {
            responseDiv.textContent += event.data;
            document.getElementById('text-chat').scrollTop = document.getElementById('text-chat').scrollHeight;
        };

        eventSource.onerror = () => {
            eventSource.close();
            responseDiv.classList.remove('streaming');
        };
    }

    // 画像チャット
    async function sendImageChat() {
        const promptInput = document.getElementById('image-prompt');
        const fileInput = document.getElementById('image-file');
        const prompt = promptInput.value.trim();

        if (!prompt) {
            alert('メッセージを入力してください');
            return;
        }

        // 添付ファイルの処理
        let attachment = null;
        if (fileInput.files[0]) {
            attachment = {
                type: 'image',
                url: URL.createObjectURL(fileInput.files[0])
            };
        }

        // ユーザーメッセージを追加
        addMessage('image-chat', prompt, true, attachment);

        // 入力をクリア
        promptInput.value = '';

        // AIの返答用メッセージを追加
        const responseDiv = addMessage('image-chat', '分析中...', false);

        const formData = new FormData();
        formData.append('prompt', prompt);
        if (fileInput.files[0]) {
            formData.append('image', fileInput.files[0]);
        }
        formData.append('conversationId', conversationIds.image);

        try {
            const response = await fetch('/image-chat', {
                method: 'POST',
                body: formData
            });
            const result = await response.text();
            responseDiv.textContent = result;
        } catch (error) {
            responseDiv.innerHTML = '<span class="error">エラーが発生しました: ' + error.message + '</span>';
        }

        // ファイル選択をリセット
        fileInput.value = '';
        updateFileLabel('image');
    }

    // 動画チャット
    async function sendVideoChat() {
        const promptInput = document.getElementById('video-prompt');
        const fileInput = document.getElementById('video-file');
        const prompt = promptInput.value.trim();

        if (!prompt) {
            alert('メッセージを入力してください');
            return;
        }

        // 添付ファイルの処理
        let attachment = null;
        if (fileInput.files[0]) {
            attachment = {
                type: 'video',
                url: URL.createObjectURL(fileInput.files[0])
            };
        }

        // ユーザーメッセージを追加
        addMessage('video-chat', prompt, true, attachment);

        // 入力をクリア
        promptInput.value = '';

        // AIの返答用メッセージを追加
        const responseDiv = addMessage('video-chat', '分析中...(動画処理には時間がかかる場合があります)', false);

        const formData = new FormData();
        formData.append('prompt', prompt);
        if (fileInput.files[0]) {
            formData.append('video', fileInput.files[0]);
        }
        formData.append('conversationId', conversationIds.video);

        try {
            const response = await fetch('/video-chat', {
                method: 'POST',
                body: formData
            });
            const result = await response.text();
            responseDiv.textContent = result;
        } catch (error) {
            responseDiv.innerHTML = '<span class="error">エラーが発生しました: ' + error.message + '</span>';
        }

        // ファイル選択をリセット
        fileInput.value = '';
        updateFileLabel('video');
    }
</script>
</body>
</html>

ためしてみる

まずはテキストチャットでためしてみましょう。
問題なさそうですね。
料理のアイデア教えてくれるらしいです...!
image.png

どうやら私自身を加工して料理するようです。。。
image.png

では次に画像を試してみましょう
相変わらずおにぎり判定されるし、「超好き」とも書いていないですがまあよしとしましょう...。
image.png

怒っているかどうか聞いてみました。
問題なく記憶できてそうですね。
頭のまげはおにぎりとして認識されてますけど。。

image.png

動画はご自身でためしてみてください!

おわりに

今回はChat Memoryを使って会話履歴を保存する機能を実装しました。
MessageChatMemoryAdvisorを使うだけで、会話履歴の保存・取得を自動でやってくれるので、実装はとてもシンプルですね。

次回はRAG(Retrieval Augmented Generation)を試して、外部ドキュメントを参照した応答ができるようにしてみます!

では、ここで一旦まわしを締め直して終わりとします。どすこい。

1
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
1
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?