0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[SwiftUI] 公式ドキュメントの FoundationModel を使った CoffeeShop ゲームを再現してみる - その3

Last updated at Posted at 2025-09-25

公式ドキュメントからダウンロードできる CoffeeShop ゲームのサンプルコードを読み解いて、自分でも実際に作ってどの程度正確に動作するかも確認してみたいと思います。最終的にはそれを使って類似するゲームをリリースするところまで実践していければと思います。

独学・初心者のため、コードの見解など間違っている箇所があれば勉強になりますのでコメントをいただけると幸いです。

前回までの内容↓

その都度、ファイルごとに勉強しまとめていきます。

DialogEngine.swift ファイル

character と Tool を使ってコーヒーショップゲームの会話管理システムのためのファイル。

セッションの作成。ますは状態管理プロパティ↓

@MainActor
@Observable class DialogEngine {
    var talkingTo: (any Character)?   // 現在話している相手
    var nextUtterance: String?        // 次に表示するNPCのセリフ
    var isGenerating = false          // AI生成中フラグ
    
    private var session: LanguageModelSession?                    // 現在の会話セッション
    private var currentTask: Task<Void, Never>?                   // 非同期処理管理
    private var conversations: [UUID: LanguageModelSession] = [:] // キャラクター別会話履歴

@MainActor でUI更新の安全性確保しつつ、@Observable で SwiftUI との自動バインディングをおこないキャラクター別セッション管理で会話履歴を維持させる。

会話システムの関数としては↓

// NPC がコーヒー好きゆえに話したくない言葉のリスト
    private var blookWords: [String] = ["お茶", "スムージー", "ミルクセーキ"]
    // NPC の気を散らすべきではないフレーズのリスト
    private var blockPhrases: [String] = ["私たちは夢を見ているのか"]
    
    // 会話開始システム
    func talkTo(_ character: any Character) {
        talkingTo = character
        // ログ
        Logging.general.log("Taking to: \(self.talkingTo?.displayName ?? "no one")")
        if conversations[character.id] == nil {
            // 初回会話
            nextUtterance = character.firstLine
            resetSession(character, startWith: character.firstLine)
        } else {
            // 継続会話
            nextUtterance = character.resumeConversationLine
        }
        
        self.session = conversations[character.id]
    }

    // コンテンツフィルタリング
    func textIsOK(_ input: String) -> Bool {
        // 単語レベルのチェック
        if input.lowercased().split(separator: " ").allSatisfy({
            !blookWords.contains($0.lowercased())
        }) {
            // フレーズレベルのチェック
            return blockPhrases.allSatisfy({
                !input.lowercased().contains($0.lowercased())
            })
        }
        return false
    }

ここでは禁止フレーズを設定しておく↓

// NPC がコーヒー好きゆえに話したくない言葉のリスト
private var blookWords: [String] = ["お茶", "スムージー", "ミルクセーキ"]
// NPC の気を散らすべきではないフレーズのリスト
private var blockPhrases: [String] = ["私たちは夢を見ているのか"]

理由としてコーヒーショップの設定を守るため(茶やスムージーは禁止)。
メタ的な質問を避けて("ここは夢の中?"など)、functalkTo() の関数で、初回または継続会話の自動判定をしキャラクター固有のセリフ使用やセッション履歴の復元を管理する。

次に新規セッション作成の関数↓

private func resetSession(_ character: any Character, startWith: String) {
        let instructions = """
            ゲームキャラクターとこのゲームのプレイヤーとの間の複数ターンの会話。\
            あなたは \(character.displayName)です。 一人称で \(character.displayName) を参照してください。 \
            ("" または "私自身")。 あなたは\(character.persona)。あなたの声で応答しなければなりません。\
            これは夢の世界なので、短くポジティブな返答を心がけましょう。\
            このコーヒーショップではすべてが無料で、バリスタは創造的なインスピレーションで報酬を得ています。あなたは今こう言いました: "\(startWith)"
            """
        
        var newSession: LanguageModelSession
        // ツールを含める必要があるかどうかをチェックする
        if let customer = character as? GenerableCustomer {
            newSession = LanguageModelSession(
                tools: [CalendarTool(contactName: customer.displayName)],
                instructions: instructions
            )
        } else {
            newSession = LanguageModelSession(instructions:  instructions)
        }
        newSession.prewarm() // セッション開始前の準備処理
        conversations[character.id] = newSession
    }

ここでの指示文の構成要素としては キャラクター人格の設定 / 一人称使用の指示 / ゲーム世界観の説明 / 前回セリフの文脈提供 といったところ。

ここは Foundation Models Framework の勉強を始める上でもっとも最初に学んだ箇所で、セッションを作成するための基本的な構造に基づいている感じ↓

さらにこの箇所↓

newSession = LanguageModelSession(
            tools: [CalendarTool(contactName: customer.displayName)],
            instructions: instructions
        )

ここ、LanguageModelSession 内の tool で CalendarTool(contactName: customer.displayName) を呼び出すことで予定内容に基づいたセクションの生成をできるようにしている。
プロンプトを直接なげる以外にも Tool を使ったこんな書き方もできるのね。

さらに他のセッション管理機能として↓

private func resetSession(_ character: any Character, previousSession: LanguageModelSession) {
        let allEntries = previousSession.transcript
        var condensedEntries = [Transcript.Entry]()
        if let firstEntry = allEntries.first {
            condensedEntries.append(firstEntry)
            if allEntries.count > 1, let lastEntry = allEntries.last {
                condensedEntries.append(lastEntry)
            }
        }
        let condensedTranscript = Transcript(entries: condensedEntries)
        /*
         注: トランスクリプトには手順が含まれています。
         ツールを含める必要があるかどうかを確認してください
         */
        var newSession: LanguageModelSession
        if let customer = character as? GenerableCustomer {
            newSession = LanguageModelSession(
                tools: [CalendarTool(contactName: customer.displayName)],
                transcript: condensedTranscript
            )
        } else {
            newSession = LanguageModelSession(transcript: condensedTranscript)
        }
        newSession.prewarm() // セッション開始前の準備処理
        conversations[character.id] = newSession
    }

このセッション作成の構造としては、メモリ効率化を目的に長い会話履歴を要約し重要な文脈のみ保持させるようにしている。つまりパフォーマンス維持を目的とした構造。

初回に勉強したAIとのセッションのトランスクリプト(会話)が長くなりすぎてメモリ限界に到達してしまうという問題に対するエラーハンドリングの応用的な感じかな。

そしてこちらも同じく↓

newSession = LanguageModelSession(
                tools: [CalendarTool(contactName: customer.displayName)],
                transcript: condensedTranscript
            )

ここでも同じく CalendarTool(contactName: customer.displayName) を呼び出すことで予定内容に基づいたセクションの生成をできるようにしている。

これらに加え会話終了時の関数も一応用意しておく↓

 func endConversation() {
        currentTask?.cancel()  // 進行中のタスクをキャンセル
        nextUtterance = nil    // UIをクリア
        if let talkingTo {
            // 会話履歴を圧縮して保存
            resetSession(talkingTo, previousSession: conversations[talkingTo.id]!)
        }
        talkingTo = nil
        isGenerating = false
    }

それらを使ってAI応答生成の中核処理の関数を書くと↓

func respond(_ userInput: String) {
        nextUtterance = "... ... ..." // ローディング表示
        
        guard let character = talkingTo, let session else {
            if let session {
                // ログ
                Logging.general.log("Session: \(String(describing: session))")
            }
            return
        }
        // 入力にブロックされた単語/フレーズが含まれているかどうかを確認する
        guard textIsOK(userInput) else {
            nextUtterance = character.errorResponse
            return
        }
        // 会話を続ける
        isGenerating = true
        currentTask = Task {
            do {
                // Foundation Models による応答生成
                let response = try await session.respond(to: userInput)
                let dialog = response.content
                // ログ
                Logging.general.log("Response: \(dialog)")
                Logging.general.log("\(String(describing: session.transcript))")
                
                // 出力にブロックされた単語/フレーズが含まれているかどうかを確認する
                if textIsOK(dialog) {
                    nextUtterance = dialog
                    isGenerating = false
                } else {
                    // ログ
                    Logging.general.log("Block list rejected response: \(dialog)")
                    nextUtterance = character.errorResponse
                    isGenerating = false
                    resetSession(character, startWith: character.resumeConversationLine)
                }
            } catch let error as LanguageModelSession.GenerationError {
                if case .exceededContextWindowSize(let context) = error {
                    // ログ
                    Logging.general.log("Context window exceeded: \(context.debugDescription)")
                    
                    resetSession(character, previousSession: session)
                    nextUtterance = character.errorResponse
                    isGenerating = false
                } else {
                    // ログ
                    Logging.general.log("Generation error: \(error)")
                    
                    nextUtterance = character.errorResponse
                    isGenerating = false
                }
            } catch let error {
                // ログ
                Logging.general.log("Other error: \(error)")
                
                nextUtterance = character.errorResponse
                isGenerating = false
            }
        }
    }

AI生成時のエラー処理は必須事項なので(もちろんクラッシュを避けるためにはAI以外でも大切ですが...)、投げられた Error 受け取りのため do- try - catch 構文を使い、 Error ハンドリングの流れとしては、入力フィルタリング → キャラクターエラー応答 → AI生成エラー → 文脈に応じた処理 → 出力フィルタリング → セッションリセット としている。

これで Apple が常に求めている Error 時のユーザー体験への支障も回避。

コード全体としてこんな感じ↓

import FoundationModels
import SwiftUI
import os
/*
 @MainActor: UI更新の安全性確保
 @Observable: SwiftUIとの自動バインディング
 */
@MainActor
@Observable class DialogEngine {
    var talkingTo: (any Character)?   // 現在話している相手
    var nextUtterance: String?        // 次に表示するNPCのセリフ
    var isGenerating = false          // AI生成中フラグ
    
    private var session: LanguageModelSession?                    // 現在の会話セッション
    private var currentTask: Task<Void, Never>?                   // 非同期処理管理
    private var conversations: [UUID: LanguageModelSession] = [:] // キャラクター別会話履歴
    
    // NPC がコーヒー好きゆえに話したくない言葉のリスト
    private var blookWords: [String] = ["お茶", "スムージー", "ミルクセーキ"]
    // NPC の気を散らすべきではないフレーズのリスト
    private var blockPhrases: [String] = ["私たちは夢を見ているのか"]
    
    // 会話開始システム
    func talkTo(_ character: any Character) {
        talkingTo = character
        // ログ
        Logging.general.log("Taking to: \(self.talkingTo?.displayName ?? "no one")")
        if conversations[character.id] == nil {
            // 初回会話
            nextUtterance = character.firstLine
            resetSession(character, startWith: character.firstLine)
        } else {
            // 継続会話
            nextUtterance = character.resumeConversationLine
        }
        
        self.session = conversations[character.id]
    }
    
    // コンテンツフィルタリング
    func textIsOK(_ input: String) -> Bool {
        // 単語レベルのチェック
        if input.lowercased().split(separator: " ").allSatisfy({
            !blookWords.contains($0.lowercased())
        }) {
            // フレーズレベルのチェック
            return blockPhrases.allSatisfy({
                !input.lowercased().contains($0.lowercased())
            })
        }
        return false
    }
    
    // AI応答生成の中核処理
    func respond(_ userInput: String) {
        nextUtterance = "... ... ..." // ローディング表示
        
        guard let character = talkingTo, let session else {
            if let session {
                // ログ
                Logging.general.log("Session: \(String(describing: session))")
            }
            return
        }
        // 入力にブロックされた単語/フレーズが含まれているかどうかを確認する
        guard textIsOK(userInput) else {
            nextUtterance = character.errorResponse
            return
        }
        // 会話を続ける
        isGenerating = true
        currentTask = Task {
            do {
                // Foundation Models による応答生成
                let response = try await session.respond(to: userInput)
                let dialog = response.content
                // ログ
                Logging.general.log("Response: \(dialog)")
                Logging.general.log("\(String(describing: session.transcript))")
                
                // 出力にブロックされた単語/フレーズが含まれているかどうかを確認する
                if textIsOK(dialog) {
                    nextUtterance = dialog
                    isGenerating = false
                } else {
                    // ログ
                    Logging.general.log("Block list rejected response: \(dialog)")
                    nextUtterance = character.errorResponse
                    isGenerating = false
                    resetSession(character, startWith: character.resumeConversationLine)
                }
            } catch let error as LanguageModelSession.GenerationError {
                if case .exceededContextWindowSize(let context) = error {
                    // ログ
                    Logging.general.log("Context window exceeded: \(context.debugDescription)")
                    
                    resetSession(character, previousSession: session)
                    nextUtterance = character.errorResponse
                    isGenerating = false
                } else {
                    // ログ
                    Logging.general.log("Generation error: \(error)")
                    
                    nextUtterance = character.errorResponse
                    isGenerating = false
                }
            } catch let error {
                // ログ
                Logging.general.log("Other error: \(error)")
                
                nextUtterance = character.errorResponse
                isGenerating = false
            }
        }
    }
    
    private func resetSession(_ character: any Character, previousSession: LanguageModelSession) {
        let allEntries = previousSession.transcript
        var condensedEntries = [Transcript.Entry]()
        if let firstEntry = allEntries.first {
            condensedEntries.append(firstEntry)
            if allEntries.count > 1, let lastEntry = allEntries.last {
                condensedEntries.append(lastEntry)
            }
        }
        let condensedTranscript = Transcript(entries: condensedEntries)
        /*
         注: トランスクリプトには手順が含まれています。
         ツールを含める必要があるかどうかを確認してください
         */
        var newSession: LanguageModelSession
        if let customer = character as? GenerableCustomer {
            newSession = LanguageModelSession(
                tools: [CalendarTool(contactName: customer.displayName)],
                transcript: condensedTranscript
            )
        } else {
            newSession = LanguageModelSession(transcript: condensedTranscript)
        }
        newSession.prewarm() // セッション開始前の準備処理
        conversations[character.id] = newSession
    }
    
    private func resetSession(_ character: any Character, startWith: String) {
        let instructions = """
            ゲームキャラクターとこのゲームのプレイヤーとの間の複数ターンの会話。\
            あなたは \(character.displayName)です。 一人称で \(character.displayName) を参照してください。 \
            ("" または "私自身")。 あなたは\(character.persona)。あなたの声で応答しなければなりません。\
            これは夢の世界なので、短くポジティブな返答を心がけましょう。\
            このコーヒーショップではすべてが無料で、バリスタは創造的なインスピレーションで報酬を得ています。あなたは今こう言いました: "\(startWith)"
            """
        
        var newSession: LanguageModelSession
        // ツールを含める必要があるかどうかをチェックする
        if let customer = character as? GenerableCustomer {
            newSession = LanguageModelSession(
                tools: [CalendarTool(contactName: customer.displayName)],
                instructions: instructions
            )
        } else {
            newSession = LanguageModelSession(instructions:  instructions)
        }
        newSession.prewarm() // セッション開始前の準備処理
        conversations[character.id] = newSession
    }
    
    // 会話終了処理
    func endConversation() {
        currentTask?.cancel()  // 進行中のタスクをキャンセル
        nextUtterance = nil    // UIをクリア
        if let talkingTo {
            // 会話履歴を圧縮して保存
            resetSession(talkingTo, previousSession: conversations[talkingTo.id]!)
        }
        talkingTo = nil
        isGenerating = false
    }
}

とりあえず今回はここまで。
書き方・解釈に間違いがあれば勉強になりますのでぜひコメントお願いします!

引き続きAIApp開発に向けて勉強した内容をまとめつつ記録していきたいと思います。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?