iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
生成式 AI

iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線系列 第 22

Day 22 - 【AI 專案 II】AI 夢境視覺化師:呼叫 Gemini 圖像生成模型

  • 分享至 

  • xImage
  •  

昨天,將使用者輸入的夢境描述,從輸入頁傳遞到了結果頁。文字的橋樑已經搭建完成,但我們的最終目標,是將這段文字轉化為一幅真正的視覺圖像。

今天,我們就要來實現這個專案最核心的魔法:串接一個強大的外部圖像生成 API。我們要讓 AI 不僅僅是理解我們的夢境,更是要將它畫出來。

  • 困惑點:這次的挑戰是雙重的。首先,我如何讓一個語言模型,去生成另一段給圖像模型看的Prompt?我需要的不是夢境的分析,而是一段能精準描述畫面的英文提示詞。其次,API 回傳的不是一個簡單的圖片網址,而是Base64,我要如何在 App 中將它變回一張圖片?

  • AI 協作

    • 我的 Prompt我正在打造一個「AI 夢境視覺化師」。我需要分兩步完成任務:第一步,讓 Gemini 的語言模型根據使用者的夢境描述,回傳一段「夢境的中文解析」和一段圖像模型使用的「英文圖像生成提示詞」。我該如何設計 Prompt 才能讓 AI 一次完成這兩件任務,並以固定的 JSON 格式回傳?第二,圖像 API 回傳的資料是 Base64 編碼的字串,請問在 Swift 中,我該如何將這個字串轉換成 UIImage?

    AI 的回答:

    這是一個非常棒的專案設計,完美地展現了「AI 鏈 (AI Chaining)」的概念!要實現這個流程,關鍵在於 Prompt 的結構化設計以及對回傳資料的正確解碼。

    1. 如何設計「一體兩用」的 Prompt

    要讓語言模型同時扮演「解夢師」和「圖像提示詞工程師」兩個角色,您需要在 Prompt 中下達非常明確的多任務指令,並強制它使用 JSON 格式輸出,以便您的 App 可以穩定地解析。

    Prompt 設計原則:

    1. 賦予雙重角色:在 Prompt 開頭就明確指出 AI 的雙重身份,例如:「你是一個專業的夢境解析助理和圖像生成提示詞專家。」
    2. 下達多個任務:清楚地列出它需要完成的所有任務。例如:「1. 提供一句話的繁體中文夢境文字解讀。 2. 生成一段適合圖像生成模型使用的英文提示詞。」
    3. 強制 JSON 輸出:這是最關鍵的一步。您必須提供一個嚴格的 JSON 結構範本,並命令 AI 只能回傳這個格式。這能確保您總是可以透過固定的鍵 (key) 來取得對應的值。

    範例 Prompt 結構:

    { "interpretation": "這裡放置繁體中文的夢境解讀內容", "imagePrompt": "這裡放置英文的圖像生成提示詞,要能捕捉整個夢境的精髓和氛圍..." }

    透過這種方式,您的一次 API 請求就能同時獲得給使用者看的「分析」,以及給下一階段 AI 使用的「原料」。

    2. 如何在 Swift 中處理 Base64 圖片資料

    API 為了傳輸的穩定性,通常會將圖片這種二進位檔案,透過 Base64 編碼轉換成純文字字串。在 Swift 中還原這個過程非常直接。

    核心步驟:

    1. 取得 Base64 字串:從 API 回傳的 JSON 中,解析出代表圖片數據的那一長串 Base64 字串。
    2. 轉換為 Data 物件:建立一個 Data 物件。Data 是 Swift 中用來處理任何二進位數據的通用型別。您可以使用它的初始化方法 Data(base64Encoded: options:),將 Base64 字串還原為原始的圖片二進位資料。
    3. Data 建立 UIImage:最後,使用 UIImage 的初始化方法 UIImage(data:),將這個 Data 物件直接轉譯成您的 App 可以顯示的圖片物件。

    整個流程就像是「解壓縮」:Base64 String -> Data -> UIImage。只要確保 Base64 字串是完整且有效的,這個轉換過程就非常可靠。

  • 根據AI的建議,我把詳細的實作步驟整理如下:

    1. 升級 Prompt:改造 PromptFactory,讓它生成一個能要求 AI 同時回傳「中文解析」和「英文圖像提示詞」的 JSON 結構。
    2. 升級數據模型:建立新的 Codable 結構來解析這個更複雜的 JSON 回應。
    3. 打造圖像生成服務:在 GeminiAPIService 中,新增一個 generateImage 方法,專門負責呼叫圖像生成 API,並處理回傳的 Base64 數據。
    4. 串連完整流程:在 InputViewController 中,將整個流程串起來:先呼叫文字 API 取得解析和提示詞,再用提示詞去呼叫圖像 API 取得圖片,最後將兩者一起傳遞到下一頁。

步驟一:升級 Prompt

我們的第一步是教 AI 如何「一心二用」。我們修改 PromptFactory.swift,打造一個全新的 Prompt,明確要求 AI 同時扮演解夢師和提示詞工程師兩個角色。

檔案:PromptFactory.swift

import Foundation

enum PromptFactory {
    static func createDreamInterpretationPrompt(from dreamDescription: String) -> String {
        return """
        你是一個專業的夢境解析助理和圖像生成提示詞專家。請根據以下夢境描述,完成兩項任務:

        1. 提供一句話的繁體中文夢境文字解讀(心理學角度分析夢境的象徵意義和可能反映的內心狀態)
        2. 生成一段適合圖像生成模型使用的英文提示詞(描述夢境中的視覺元素、場景、氛圍、色調等)

        夢境描述:「\(dreamDescription)」

        請嚴格按照以下 JSON 格式回傳結果,不要包含任何其他文字或說明:

        {
            "interpretation": "這裡放置繁體中文的夢境解讀內容",
            "imagePrompt": "這裡放置英文的圖像生成提示詞,要能捕捉整個夢境的精髓和氛圍,包含多個關鍵場景元素在同一畫面中"
        }

        注意事項:
        - interpretation 必須是繁體中文,內容要深入、專業且具有心理學見解
        - imagePrompt 必須是英文,要具體描述一個包含夢境多個重要元素的綜合場景
        - 如果夢境有明顯的時間順序,在imagePrompt中融合關鍵時刻
        - 強調氛圍、情感色調和象徵性元素
        - 嚴格遵守 JSON 格式,確保可以被程式正確解析
        - 不要在 JSON 之外添加任何額外文字
        """
    }
}

步驟二:升級數據模型

為了能解析 AI 回傳的新 JSON 格式,我們在 GeminiDataModels.swift 中,新增一個專門的 DreamInterpretationResponse 結構。

檔案:GeminiDataModels.swift (新增)

// MARK: - 用於建立請求 (Request) 的資料結構
struct GeminiRequest: Codable {
    let contents: [Content]
}

struct Content: Codable {
    let parts: [Part]
}

struct Part: Codable {
    let text: String
}

// MARK: - 用於解析回應 (Response) 的資料結構
struct GeminiResponse: Codable {
    let candidates: [Candidate]
}

struct Candidate: Codable {
    let content: ContentResponse
}

struct ContentResponse: Codable {
    let parts: [PartResponse]
}

struct PartResponse: Codable {
    let text: String
}

struct DreamInterpretationResponse: Codable {
    let interpretation: String
    let imagePrompt: String
    
    enum CodingKeys: String, CodingKey {
        case interpretation
        case imagePrompt = "imagePrompt"
    }
}

步驟三:打造圖像生成服務

這是今天的核心。我們擴充 GeminiAPIService,加入一個 generateImage 方法。這個方法會呼叫圖像生成的端點,並包含一個複雜但強健的解析器,用來從回傳的 JSON 中提取 Base64 數據並將其轉換為 UIImage

檔案:GeminiAPIService.swift

import Foundation
import UIKit

class GeminiAPIService {
    private let networkManager = NetworkManager.shared
    private let apiKey: String
    
    init() {
        // 從 API_KEY.plist 安全地讀取金鑰
        guard let path = Bundle.main.path(forResource: "API_KEY", ofType: "plist"),
              let dict = NSDictionary(contentsOfFile: path),
              let key = dict["GeminiAPIKey"] as? String else {
            fatalError("無法在 API_KEY.plist 中找到 GeminiAPIKey")
        }
        self.apiKey = key
    }
    
    func generateContent(prompt: String) async throws -> GeminiResponse {
        let endpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent"
        let headers = ["Content-Type": "application/json", "x-goog-api-key": apiKey]
        let requestBody = GeminiRequest(contents: [Content(parts: [Part(text: prompt)])])
        
        do {
            let bodyData = try JSONEncoder().encode(requestBody)
            let response: GeminiResponse = try await networkManager.request(
                endpoint: endpoint,
                method: "POST",
                headers: headers,
                body: bodyData
            )
            return response
        } catch {
            throw error
        }
    }
    
    func generateImage(prompt: String) async throws -> UIImage? {
        return try await attemptImageGeneration(prompt: prompt)
    }
    
    // 嘗試生成圖像的輔助方法
    private func attemptImageGeneration(prompt: String) async throws -> UIImage? {
        let endpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent"
        let headers = ["Content-Type": "application/json", "x-goog-api-key": apiKey]
        
        let requestBody: [String: Any] = [
            "contents": [
                [
                    "parts": [
                        [
                            "text": "Generate a detailed visual representation of: \(prompt). Create a single high-quality image that captures the essence and mood of this description."
                        ]
                    ]
                ]
            ],
            "generationConfig": [
                "response_modalities": ["IMAGE"],
                "temperature": 0.7,
                "max_output_tokens": 1000
            ]
        ]
        
        do {
            let bodyData = try JSONSerialization.data(withJSONObject: requestBody)
            
            guard let url = URL(string: endpoint) else {
                throw APPError.invalidURL
            }
            
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.allHTTPHeaderFields = headers
            request.httpBody = bodyData
            request.timeoutInterval = 120.0
            
            let (data, response) = try await URLSession.shared.data(for: request)
            
            guard let httpResponse = response as? HTTPURLResponse else {
                throw APPError.serverError
            }
            
            guard (200...299).contains(httpResponse.statusCode) else {
                throw APPError.serverError
            }
            
            return try await parseImageFromJSON(data: data)
            
        } catch {
            throw error
        }
    }
    
    // 解析JSON回應中的圖像
    private func parseImageFromJSON(data: Data) async throws -> UIImage? {
        do {
            guard let jsonResponse = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
                return nil
            }
            
            guard let candidates = jsonResponse["candidates"] as? [[String: Any]] else {
                return nil
            }
            
            for candidate in candidates {
                if let image = extractImageFromCandidate(candidate) {
                    return image
                }
            }
            
            return nil
            
        } catch {
            return nil
        }
    }
    
    // 從候選結果中提取圖像
    private func extractImageFromCandidate(_ candidate: [String: Any]) -> UIImage? {
        guard let content = candidate["content"] as? [String: Any],
              let parts = content["parts"] as? [[String: Any]] else {
            return nil
        }
        
        for part in parts {
            // 檢查是否有 inlineData (駝峰命名)
            if let inlineData = part["inlineData"] as? [String: Any],
               let mimeType = inlineData["mimeType"] as? String,
               let data = inlineData["data"] as? String,
               mimeType.hasPrefix("image/") {
                
                if let image = decodeBase64ToImage(data) {
                    return image
                }
            }
            
            // 也檢查底線命名版本以防萬一
            if let inlineData = part["inline_data"] as? [String: Any],
               let mimeType = inlineData["mime_type"] as? String,
               let data = inlineData["data"] as? String,
               mimeType.hasPrefix("image/") {
                
                if let image = decodeBase64ToImage(data) {
                    return image
                }
            }
            
            // 檢查是否有text欄位包含base64數據
            if let text = part["text"] as? String,
               text.contains("base64") || text.contains("data:image") {
                
                if let image = extractImageFromText(text) {
                    return image
                }
            }
        }
        
        return nil
    }
    
    // 從文本中提取圖像
    private func extractImageFromText(_ text: String) -> UIImage? {
        let patterns = [
            "data:image/[^;]+;base64,([A-Za-z0-9+/=]+)",
            "base64:([A-Za-z0-9+/=]+)",
            "([A-Za-z0-9+/=]{100,})"
        ]
        
        for pattern in patterns {
            if let regex = try? NSRegularExpression(pattern: pattern),
               let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) {
                
                let base64Range = Range(match.range(at: 1), in: text) ?? Range(match.range(at: 0), in: text)!
                let base64String = String(text[base64Range])
                
                if let image = decodeBase64ToImage(base64String) {
                    return image
                }
            }
        }
        
        return nil
    }
    
    // Base64解碼
    private func decodeBase64ToImage(_ base64String: String) -> UIImage? {
        let cleanedBase64 = base64String
            .replacingOccurrences(of: "\n", with: "")
            .replacingOccurrences(of: "\r", with: "")
            .replacingOccurrences(of: " ", with: "")
            .replacingOccurrences(of: "\t", with: "")
        
        let decodingOptions: [Data.Base64DecodingOptions] = [
            [],
            .ignoreUnknownCharacters,
            [.ignoreUnknownCharacters]
        ]
        
        for option in decodingOptions {
            if let imageData = Data(base64Encoded: cleanedBase64, options: option) {
                if let image = processImageData(imageData) {
                    return image
                }
            }
        }
        
        return nil
    }
    
    // 處理圖像數據
    private func processImageData(_ data: Data) -> UIImage? {
        // 嘗試直接創建UIImage
        if let image = UIImage(data: data) {
            return image
        }
        
        // 使用CGImageSource作為備用方案
        if let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
           let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) {
            let image = UIImage(cgImage: cgImage)
            return image
        }
        
        return nil
    }
}

步驟四:串連完整流程

最後,我們回到 InputViewController,將 btnSent 的動作升級為一個兩階段的非同步任務。

檔案:InputViewController.swift

// ... 接續 InputViewController.swift ...

  // MARK: - IBAcion
    @IBAction func btnSent(_ sender: UIButton) {
        dismissKeyboard()
        
        guard let dreamDescription = txfDream.text, !dreamDescription.isEmpty else {
            return
        }
        
        // 介面只顯示一個簡單的提示
        lbStatus.text = "AI 正在思考中..."
        
        Task {
            do {
                // --- 階段一:文字解析 ---
                let prompt = PromptFactory.createDreamInterpretationPrompt(from: dreamDescription)
                let geminiResponse: GeminiResponse = try await geminiService.generateContent(prompt: prompt)
                
                guard let jsonString = geminiResponse.candidates.first?.content.parts.first?.text else {
                    throw APPError.decodingError
                }
                
                let cleanJsonString = jsonString
                    .replacingOccurrences(of: "```json", with: "")
                    .replacingOccurrences(of: "```", with: "")
                    .trimmingCharacters(in: .whitespacesAndNewlines)
                
                guard let jsonData = cleanJsonString.data(using: .utf8) else {
                    throw APPError.decodingError
                }
                
                let dreamResponse = try JSONDecoder().decode(DreamInterpretationResponse.self, from: jsonData)
                
                // --- 階段二:圖像生成 ---
                let generatedImage = try await geminiService.generateImage(prompt: dreamResponse.imagePrompt)

                // --- 成功:傳遞並跳轉 ---
                await MainActor.run {
                    navigateToResult(interpretation: dreamResponse.interpretation, image: generatedImage)
                }
                
            } catch {
                // 失敗時只在控制台印出錯誤,UI 沒有任何變化
                print("發生錯誤: \(error.localizedDescription)")
                await MainActor.run {
                    lbStatus.text = "生成失敗,請查看日誌。"
                }
            }
        }
    }
    
    // MARK: - Function
    private func navigateToResult(interpretation: String, image: UIImage?) {
        let resultVC = ResultViewController()
        resultVC.interpretationText = interpretation
        resultVC.dreamImage = image
        
        navigationController?.pushViewController(resultVC, animated: true)
        
        // 跳轉後清空狀態
        lbStatus.text = ""
    }
}
// ... 接續 ...

今天,我們的 App 真正擁有了將抽象概念視覺化的能力。我們不僅學會了如何設計複雜的多任務 Prompt,更掌握了處理 Base64 圖像數據這項關鍵技能。

然而,目前的 App 在使用者體驗上還有許多可以打磨的空間。例如,在等待 AI 回應時,畫面是靜止的;圖片出現時,也顯得有些突兀。明天,我們將為這個專案加入更流暢的動畫與使用者體驗優化。


上一篇
Day 21 - 【AI 專案 II】AI 夢境視覺化師:打造輸入介面與多頁面傳值
系列文
iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言