📱

Foundation Models Frameworkを用いてチャットアプリを実装する

に公開

はじめに

https://developer.apple.com/documentation/foundationmodels

iOS 26で導入された Foundation Models Frameworkは、Apple の大規模言語モデル(LLM)をオンデバイスで直接利用できる画期的な機能です。
これにより、OpenAIなどの外部のAPIに依存することなく、プライバシーを保護しながらローカルで言語タスクを実行できるようになりました。

本記事では、SwiftUIとFoundation Models Frameworkを用いて、オンデバイスで動作するチャットアプリの実装方法を解説します。

実装の全体像

今回実装するチャットアプリは、以下の主要な要素で構成されます。

  1. メッセージモデル: チャットのメッセージの構造を定義します。
  2. ChatViewModel: Foundation Models との連携、メッセージ管理、ストリーミングレスポンスの処理を行います。
  3. ContentView (SwiftUI): ViewModel のデータを監視しチャットUIを表示・更新、ユーザー操作を処理します。

1. メッセージモデルの定義

チャットメッセージを表現するための基本的なデータ構造を定義します。
各メッセージが一意になるようIdentifiableに準拠させています。

enum Role {
    case user
    case assistant
}

struct Message: Identifiable {
    let id = UUID()
    let role: Role
    var content: String
}

Role列挙型でメッセージの送信者を区別し、Message構造体で各メッセージの情報を管理します。
contentプロパティをvarで宣言することで、ストリーミング中にリアルタイムで更新できるようにしています。

2. ChatViewModel の実装

ChatViewModelは、Foundation Models フレームワークとの連携とチャットの状態管理を担います。
ObservableObjectとして定義し、SwiftUIとのデータバインディングができるようにします。

import SwiftUI
import Combine
import FoundationModels

class ChatViewModel: ObservableObject {
    @Published var isStreaming: Bool = false
    @Published var messages: [Message] = []
    
    private var session: LanguageModelSession? // LLMセッション
    private var instructions = "親しみやすい絵文字と分かりやすい言葉遣いで、日本語で会話を楽しく、心地よく進めていきます"
    private var currentTask: Task<Void, Never>?
    
    // メッセージをクリア
    func clearMessages() {
        cancelCurrentTask()
        messages.removeAll()
    }
    
    // taskをキャンセル
    func cancelCurrentTask() {
        currentTask?.cancel()
        currentTask = nil
        finishResponse()
    }
    
    // メッセージを送信
    func sendMessage(content: String) {
        // taskをキャンセル
        cancelCurrentTask()
        
        // メッセージを作成
        let message = Message(role: .user, content: content)
        messages.append(message)
        
        // リクエストを送信
        isStreaming = true
        
        currentTask = Task {
            do {
                // ストリーミングレスポンスを処理
                try await handleStreamingResponse(message)
                
                // レスポンスの処理を終了
                if !Task.isCancelled {
                    finishResponse()
                }
            } catch {
                if !Task.isCancelled {
                    finishResponse()
                }
            }
        }
    }
    
    // ストリーミングレスポンスを処理
    private func handleStreamingResponse(_ message: Message) async throws {
        if self.session == nil {
            self.session = LanguageModelSession(instructions: self.instructions)
        }
        
        guard let currentSession = self.session else {
            throw NSError(domain: "ChatViewModel", code: -1, userInfo: [NSLocalizedDescriptionKey: "LanguageModelSession is not initialized"])
        }
        
        let stream = currentSession.streamResponse(to: message.content)
        for try await response in stream {
            if Task.isCancelled { break }
            let content = response.content
            await MainActor.run {
                // 最後のメッセージがアシスタントの場合は更新、そうでなければ追加
                if let lastMessage = messages.last, lastMessage.role == .assistant {
                    messages[messages.count - 1].content = content
                } else {
                    messages.append(Message(role: .assistant, content: content))
                }
            }
        }
    }
    
    // レスポンスの処理を終了
    @MainActor
    private func finishResponse() {
        isStreaming = false
    }
}

LanguageModelSessionは Foundation Models フレームワークの中核となるクラスで、AIモデルとの対話セッションを管理します。

instructionsでLLMの振る舞いを指定できます。今回は「日本語での親しみやすい会話」をするよう指定しています。

Foundation Models フレームワークのstreamResponse(to:)メソッドは、非同期ストリームを返します。この非同期ストリームをfor try awaitループで処理することで、生成されるレスポンスをリアルタイムに受信できます。

ポイント:

  • ストリーミング中は既存のアシスタントメッセージを更新し、新しいメッセージの場合は追加します
  • UI 更新はMainActor.runを使用してメインスレッドで実行します
  • キャンセル処理により、ユーザーがいつでもレスポンス生成を中断できます

3. ChatUIの実装

ContentView

ContentViewは、チャットUIを構成するViewです。
新しいチャットの開始、メッセージの表示、ユーザー入力の3つの主なセクションに分かれています。

import SwiftUI

struct ContentView: View {
    @State private var prompt: String = ""
    @StateObject private var chatViewModel = ChatViewModel()
    
    var body: some View {
        VStack(spacing: 0) {
            // ヘッダー
            HStack {
                Button(action: {
                    chatViewModel.clearMessages()
                }) {
                    HStack(spacing: 8) {
                        Image(systemName: "square.and.pencil")
                        
                        Text("新しいチャット")
                    }
                }
                
                Spacer()
            }
            .padding()
            
            // メッセージ
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(alignment: .leading, spacing: 8) {
                        ForEach(chatViewModel.messages) { message in
                            MessageView(message: message)
                                .id(message.id)
                        }
                    }
                    .padding()
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .onChange(of: chatViewModel.messages.last?.content) {
                    if let lastMessage = chatViewModel.messages.last {
                        withAnimation {
                            proxy.scrollTo(lastMessage.id, anchor: .bottom)
                        }
                    }
                }
            }
            
            // 入力フィールドと送信ボタン
            HStack(spacing: 8) {
                TextField("質問してみましょう", text: $prompt)

                Spacer()
                
                Button(action: {
                    if chatViewModel.isStreaming {
                        chatViewModel.cancelCurrentTask()
                    } else {
                        sendMessageWithText(prompt)
                    }
                }) {
                    Image(systemName: chatViewModel.isStreaming ? "stop.circle.fill" : "arrow.up.circle.fill")
                        .resizable()
                        .frame(width: 32, height: 32)
                        .opacity((!chatViewModel.isStreaming && prompt.isEmpty) ? 0.5 : 1)
                }
                .disabled(!chatViewModel.isStreaming && prompt.isEmpty)
            }
            .padding()
        }
    }
    
    // メッセージ送信
    private func sendMessageWithText(_ text: String) {
        let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedText.isEmpty && !chatViewModel.isStreaming else { return }
        
        self.prompt = ""
        chatViewModel.sendMessage(content: trimmedText)
    }
}

// メッセージ表示用のView
private struct MessageView: View {
    let message: Message
    
    var body: some View {
        HStack(alignment: .top, spacing: 8) {
            switch message.role {
            case .user:
                Spacer()
                
                Text(message.content)
                    .padding()
                    .fixedSize(horizontal: false, vertical: true)
                    .frame(alignment: .trailing)
                    .background(Color(.systemGray6))
                    .cornerRadius(9999)
            case .assistant:
                Text(message.content)
                    .fixedSize(horizontal: false, vertical: true)
                    .frame(alignment: .leading)
            }
        }
    }
}

MessageViewは、ユーザが入力したメッセージとAIからのレスポンスを表示するためのViewです。

UI の特徴:

  • ストリーミング中のリアルタイム更新に対応したスクロール処理
  • ユーザーメッセージとアシスタントメッセージの視覚的な区別
  • ストリーミング状態に応じた送信/停止ボタンの動的切り替え

まとめ

本記事では、iOS 26で導入された Foundation Models フレームワークを使用して、完全にオンデバイスで動作するチャットアプリケーションの実装方法を解説しました。

主なポイント:

  • streamResponse(to:)メソッドでリアルタイムストリーミングレスポンスを受信
  • for try awaitを使用した非同期ストリーム処理
  • SwiftUI の@Publishedプロパティを介したリアルタイム UI 更新
  • Taskcancel()を使用した非同期処理の制御

Foundation Models フレームワークは、プライバシーやコストを重視しながらLLMをアプリに統合したい開発者にとって、非常に魅力的な選択肢です。
また外部 APIへの依存がないため、ユーザーのデータ保護と高速な応答、オフライン動作、費用の削減など、多くの利点があります。

iOS 26以降をターゲットとしたアプリケーションで ローカルLLMを用いたAI機能を実装する際の参考にしていただければ幸いです。

Appendix

Markdownのサポート

下記のようなライブラリを使用して、アシスタントメッセージが吐き出したMarkdownをよりみやすくすることも可能です。

https://github.com/gonzalezreal/swift-markdown-ui

Tool Callingのサポート

Foundation Models フレームワークは、外部ツールの呼び出し、いわゆるTool Callingもサポートしています。

https://developer.apple.com/documentation/foundationmodels/tool

Tool Callingを使用し、iOSの他のフレームワークや外部のAPIと連携することで、さらに高度なチャットアプリケーションを構築できます。

参考

https://github.com/PallavAg/Apple-Intelligence-Chat

https://github.com/rudrankriyam/Foundation-Models-Framework-Example

Discussion