はじめに

こんにちは、システム開発部のT.Nです。
前回の記事、SwiftUIとUIKitで Foundation Models を使ってみた:オンデバイス要約の最小実装 では、Foundation Models の基本的な使い方を最小構成で紹介しました。

今回はその続編として、Foundation Models の @Generable を使い、生成結果をただの文字列ではなく Swift の型(struct)として受け取るところまで踏み込んでみます。
題材として「料理名を入力すると、材料リスト(必要なら簡単なレシピ)を出力する」ミニアプリを作ります。

文字列で返すだけでも動きますが、材料をUIで一覧表示したり、材料の分量を扱ったりすることを考えると、任意の型で扱えることがかなり効いてきます。この記事では、まず「料理名 → 材料リスト」を型で受け取り、UIに表示してみることをゴールにします。

今回やること(完成イメージ)

今回作るのは、料理名を入力すると材料リストを生成して表示するシンプルなSwiftUIアプリです。

まずは前回の延長として、Foundation Models に問い合わせて 文字列として材料を返すところから始めます。ここまでなら実装は簡単ですが、返ってくる内容が文章だとUI側で扱いづらく、材料を一覧表示しにくいという課題があります。

そこで次に、Foundation Models の @Generable を使って、生成結果を Swiftの型(struct)として受け取り、材料を List で表示できる形に作り替えます。これにより、UI側での整形や拡張がしやすくなります。

最後に発展編として、任意で レシピ手順も同じく型として追加し、「材料+手順」まで返せるようにしてみます。

⚠️ 注意:Foundation Models は iOS 26+(iPadOS/macOS/visionOS も同様)かつ Apple Intelligence 有効化が前提です。
詳細は前回の記事をご参照ください。

文字列で材料を返す

最初に、前回の記事と同じ流れで 「モデルに問い合わせて、結果を文字列として受け取る」 ところまでを作ります。
この段階の目的は、とにかく 最小構成で動かすことです。UIは「入力欄+送信ボタン+結果表示」だけに絞ります。

コード全体
import SwiftUI
import FoundationModels

struct ContentView: View {
    @State private var text: String = ""
    @State private var result: String = ""
    @State private var isLoading: Bool = false

    let model = SystemLanguageModel.default

    private func send(text: String) async throws {
        let instructions = """
        あなたは料理の材料を教えてくれるアシスタントです。
        ユーザーが入力した料理に必要な材料を返してください。
        """

        let session = LanguageModelSession(model: model, instructions: instructions)

        let prompt = """
        ユーザーの入力は\(text)です。
        この料理を作るのに必要な材料を教えてください。
        """

        isLoading = true
        let response = try await session.respond(to: prompt)
        isLoading = false

        self.result = response.content
    }

    var body: some View {
        if model.isAvailable {
            VStack {
                Text("""
                こんにちは!

                今から作りたい料理を教えてください!
                材料を教えます!
                """)
                .padding()
                .fixedSize(horizontal: false, vertical: true)

                TextField("作りたいものはなんですか?", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .padding()

                Button(action: {
                    Task {
                        do {
                            try await send(text: text)
                        } catch {
                            result = "エラーが発生しました: \(error.localizedDescription)"
                        }
                    }
                }) {
                    Text("送る")
                }
                .buttonStyle(.borderedProminent)
                .tint(.black)

                if isLoading {
                    ProgressView()
                } else {
                    ScrollView {
                        Text(result)
                            .padding()
                    }
                }
            }
        } else {
            Text("Not available")
        }
    }
}
出来上がったアプリ
A mobile app interface displaying a greeting in Japanese and a prompt asking what dish the user wants to make, along with a text input field and a send button.

実装としては LanguageModelSession を作り、respond(to:) を呼び出して返ってきた response.content(文字列)を画面に表示するだけです。

ただし、この方法だと返ってくるのはあくまで文章なので、例えば「材料を1件ずつ List に表示したい」「量だけ取り出したい」といった用途では、あとからパースや整形が必要になります。
次のセクションでは、この課題を @Generable で解決していきます。

@Generable を導入して、材料をListで扱える形にする

ここからは、Foundation Models の @Generable を使って、生成結果を「文章」ではなくSwift の型として受け取るようにします。

材料のクラスを @Generable で定義する

まず、モデルに生成してほしい材料の形を @Generable で定義します。今回は「材料名」と「目安の量」だけに絞ってシンプルにしました。

@Generable
public struct Ingredient {
    @Guide(description: "材料の名前")
    let name: String

    @Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)")
    let amount: String
}

生成結果を「Ingredient配列」として受け取る

次に、呼び出し側で respond(to:generating:) を使い、生成結果の型として [Ingredient] を指定します。これにより、返ってくる結果は文字列ではなく Ingredient の配列として扱えるようになります。

let response = try await session.respond(to: prompt, generating: [Ingredient].self)

この形にしておくと、SwiftUI 側は ForEach(ingredients, id: \.name) のようにそのままリスト表示できるため、コード上で扱いやすくなりました。

全体のコード
import SwiftUI
import FoundationModels

struct ContentView: View {
    @State private var text: String = ""
    @State private var result: [Ingredient] = []
    @State private var error: String? = nil
    @State private var isLoading: Bool = false

    let model = SystemLanguageModel.default

    private func send(text: String) async {
        isLoading = true
        defer { isLoading = false }

        do {
            let instructions = """
            あなたは料理の材料を教えてくれるアシスタントです。
            ユーザーが入力した料理に必要な材料を返してください。
            材料は「材料名」「目安の量」を含めてください。
            """

            let session = LanguageModelSession(model: model, instructions: instructions)

            let prompt = """
            ユーザーの入力は「\(text)」です。
            この料理を作るのに必要な材料を教えてください。
            """

            let response = try await session.respond(to: prompt, generating: [Ingredient].self)
            self.result = response.content
            self.error = nil
        } catch {
            self.error = "エラーが発生しました: \(error.localizedDescription)"
        }
    }

    var body: some View {
        if model.isAvailable {
            VStack {
                Text("""
                こんにちは!

                今から作りたい料理を教えてください!
                材料を教えます!
                """)
                .padding()
                .fixedSize(horizontal: false, vertical: true)

                TextField("作りたいものはなんですか?", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .padding()

                Button("送る") {
                    Task { await send(text: text) }
                }
                .buttonStyle(.borderedProminent)
                .tint(.black)
                .disabled(isLoading)

                if isLoading {
                    ProgressView()
                } else {
                    ScrollView {
                        if let error {
                            Text(error)
                        } else {
                            ForEach(result, id: \.name) { ingredient in
                                HStack {
                                    Text(ingredient.name)
                                        .padding(.horizontal)
                                    Text(ingredient.amount)
                                        .foregroundStyle(.secondary)
                                }
                                .padding(.vertical, 4)
                            }
                            .padding(.horizontal)
                        }
                    }
                }
            }
        } else {
            Text("Not available")
        }
    }
}

@Generable
public struct Ingredient {
    @Guide(description: "材料の名前")
    let name: String

    @Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)")
    let amount: String
}


@Generable によって生成結果を型として受け取れるため、UI側はシンプルに実装でき、拡張もしやすい構成になりました。

A mobile phone screen displaying a user interface that asks for a type of dish to be prepared. The text is in Japanese and includes a greeting and a prompt for ingredients, with a text input field and a send button.
材料とその分量を分けて表示したバージョン

応用編

@Generableと@Guideを使って材料だけでなく、調理法や難易度なども出力してみたいと思います。
@Guideには以下のように.rangeや.countをつけることによって値を制限することができます。


    @Guide(description: "何人前か", .range(1...6))
    let servings: Int


これらの機能を使って出力内容の情報を増やしてみます。

@Generable
public struct Recipe {
    @Guide(description: "料理名。料理名が読み取れない場合は「料理名を入力してください」。")
    let title: String

    @Guide(description: "何人前か", .range(1...6))
    let servings: Int

    @Guide(description: "調理時間(分)", .range(1...180))
    let cookTimeMinutes: Int

    @Guide(description: "難易度(easy/normal/hard のいずれか)")
    let difficulty: Difficulty

    @Guide(description: "材料の配列", .count(8))
    let ingredients: [Ingredient]

    @Guide(description: "手順(短文)", .count(6))
    let steps: [String]
}
コード全体はこちら
import SwiftUI
import FoundationModels

struct ContentView: View {
    @State private var text: String = ""
    @State private var result: Recipe = .init(
        title: "",
        servings: 2,
        cookTimeMinutes: 15,
        difficulty: .easy,
        ingredients: [],
        steps: []
    )
    @State private var error: String? = nil
    @State private var isLoading: Bool = false

    let model = SystemLanguageModel.default

    private func send(text: String) async {
        isLoading = true
        defer { isLoading = false }

        do {
            let instructions = """
            あなたは料理の材料と手順を教えてくれるアシスタントです。
            ユーザーが入力した料理に必要な材料と手順を返してください。
            """

            let session = LanguageModelSession(model: model, instructions: instructions)

            let prompt = """
            ユーザーの入力は「\(text)」です。
            この料理を作るのに必要な材料と手順を教えてください。
            """

            let response = try await session.respond(to: prompt, generating: Recipe.self)
            self.result = response.content
            self.error = nil
        } catch {
            self.error = "エラーが発生しました: \(error.localizedDescription)"
        }
    }

    var body: some View {
        if model.isAvailable {
            VStack(spacing: 12) {
                Text("""
                こんにちは!

                今から作りたい料理を教えてください!
                材料と手順を教えます!
                """)
                .padding(.top, 8)
                .fixedSize(horizontal: false, vertical: true)

                TextField("作りたいものはなんですか?(例: 親子丼)", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .padding(.horizontal)

                Button("送る") {
                    Task { await send(text: text) }
                }
                .buttonStyle(.borderedProminent)
                .tint(.black)
                .disabled(isLoading)

                if isLoading {
                    ProgressView().padding(.top, 8)
                }

                if let error {
                    Text(error).foregroundStyle(.red).padding(.horizontal)
                } else {
                    List {
                        Section("概要") {
                            Text(result.title.isEmpty ? "—" : result.title)
                            HStack {
                                Text("人数")
                                Spacer()
                                Text("\(result.servings)人前").foregroundStyle(.secondary)
                            }
                            HStack {
                                Text("調理時間")
                                Spacer()
                                Text("\(result.cookTimeMinutes)分").foregroundStyle(.secondary)
                            }
                            HStack {
                                Text("難易度")
                                Spacer()
                                Text(result.difficulty.label).foregroundStyle(.secondary)
                            }
                        }

                        Section("材料") {
                            if result.ingredients.isEmpty {
                                Text("—")
                                    .foregroundStyle(.secondary)
                            } else {
                                ForEach(result.ingredients, id: \.name) { ingredient in
                                    HStack {
                                        Text(ingredient.name)
                                        Spacer()
                                        Text(ingredient.amount)
                                            .foregroundStyle(.secondary)
                                    }
                                }
                            }
                        }

                        Section("手順") {
                            if result.steps.isEmpty {
                                Text("—")
                                    .foregroundStyle(.secondary)
                            } else {
                                ForEach(Array(result.steps.enumerated()), id: \.offset) { idx, step in
                                    HStack(alignment: .top, spacing: 8) {
                                        Text("\(idx + 1).")
                                            .foregroundStyle(.secondary)
                                        Text(step)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        } else {
            Text("Not available")
        }
    }
}

@Generable
public struct Recipe {
    @Guide(description: "料理名。料理名が読み取れない場合は「料理名を入力してください」。")
    let title: String

    @Guide(description: "何人前か", .range(1...6))
    let servings: Int

    @Guide(description: "調理時間(分)", .range(1...180))
    let cookTimeMinutes: Int

    @Guide(description: "難易度(easy/normal/hard のいずれか)")
    let difficulty: Difficulty

    @Guide(description: "材料の配列", .count(8))
    let ingredients: [Ingredient]

    @Guide(description: "手順(短文)", .count(6))
    let steps: [String]
}

@Generable
public struct Ingredient {
    @Guide(description: "材料の名前")
    let name: String

    @Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)")
    let amount: String
}

@Generable
public enum Difficulty: String, CaseIterable {
    case easy
    case normal
    case hard

    var label: String {
        switch self {
        case .easy:   return "かんたん"
        case .normal: return "ふつう"
        case .hard:   return "むずかしい"
        }
    }
}


完成したアプリ

A digital interface in Japanese asking for a recipe, including input fields for number of servings, preparation time, difficulty level, ingredients, and procedure.

少し調理手順が怪しい(カレーソースの作り方が斬新)ですが、かなり機能が充実しましたね!

終わりに

今回は、Foundation Models の @Generable を使って、生成結果を文字列ではなく Swift の型(struct)として受け取る実装を試してみました。

まずは respond(to:) で文字列として材料を返すところから始め、次に respond(to: generating:) で [Ingredient] を直接受け取るようにすることで、SwiftUI 側は ForEach でそのまま表示でき、後処理のパースが不要になりました。
さらに応用編では、Recipe(材料+手順+メタ情報)に拡張し、@Guide(.range) や @Guide(.count)、enum を組み合わせることで、出力の揺れを抑えながら情報量を増やすことができました。

生成結果の品質(今回だと調理手順の妥当性)は、プロンプトや @Guide の設計でまだ改善の余地がありますが、「生成AIの結果をアプリのデータとして扱える」という体験はかなり強力でした。
次のステップとしては、材料のカテゴリ分け(野菜/肉/調味料)を型で固定して表示を整える、ユーザー条件(人数・アレルギー・時短など)を入力として追加して精度を上げる、といった方向に拡張していくと実用度がさらに上がりそうです。

ここまで読んでいただき、ありがとうございました。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる