昨天,將使用者輸入的夢境描述,從輸入頁傳遞到了結果頁。文字的橋樑已經搭建完成,但我們的最終目標,是將這段文字轉化為一幅真正的視覺圖像。
今天,我們就要來實現這個專案最核心的魔法:串接一個強大的外部圖像生成 API。我們要讓 AI 不僅僅是理解我們的夢境,更是要將它畫出來。
困惑點:這次的挑戰是雙重的。首先,我如何讓一個語言模型,去生成另一段給圖像模型看的Prompt?我需要的不是夢境的分析,而是一段能精準描述畫面的英文提示詞。其次,API 回傳的不是一個簡單的圖片網址,而是Base64,我要如何在 App 中將它變回一張圖片?
AI 協作
我正在打造一個「AI 夢境視覺化師」。我需要分兩步完成任務:第一步,讓 Gemini 的語言模型根據使用者的夢境描述,回傳一段「夢境的中文解析」和一段圖像模型使用的「英文圖像生成提示詞」。我該如何設計 Prompt 才能讓 AI 一次完成這兩件任務,並以固定的 JSON 格式回傳?第二,圖像 API 回傳的資料是 Base64 編碼的字串,請問在 Swift 中,我該如何將這個字串轉換成 UIImage?
AI 的回答:
這是一個非常棒的專案設計,完美地展現了「AI 鏈 (AI Chaining)」的概念!要實現這個流程,關鍵在於 Prompt 的結構化設計以及對回傳資料的正確解碼。
1. 如何設計「一體兩用」的 Prompt
要讓語言模型同時扮演「解夢師」和「圖像提示詞工程師」兩個角色,您需要在 Prompt 中下達非常明確的多任務指令,並強制它使用 JSON 格式輸出,以便您的 App 可以穩定地解析。
Prompt 設計原則:
- 賦予雙重角色:在 Prompt 開頭就明確指出 AI 的雙重身份,例如:「你是一個專業的夢境解析助理和圖像生成提示詞專家。」
- 下達多個任務:清楚地列出它需要完成的所有任務。例如:「1. 提供一句話的繁體中文夢境文字解讀。 2. 生成一段適合圖像生成模型使用的英文提示詞。」
- 強制 JSON 輸出:這是最關鍵的一步。您必須提供一個嚴格的 JSON 結構範本,並命令 AI 只能回傳這個格式。這能確保您總是可以透過固定的鍵 (key) 來取得對應的值。
範例 Prompt 結構:
{ "interpretation": "這裡放置繁體中文的夢境解讀內容", "imagePrompt": "這裡放置英文的圖像生成提示詞,要能捕捉整個夢境的精髓和氛圍..." }
透過這種方式,您的一次 API 請求就能同時獲得給使用者看的「分析」,以及給下一階段 AI 使用的「原料」。
2. 如何在 Swift 中處理 Base64 圖片資料
API 為了傳輸的穩定性,通常會將圖片這種二進位檔案,透過 Base64 編碼轉換成純文字字串。在 Swift 中還原這個過程非常直接。
核心步驟:
- 取得 Base64 字串:從 API 回傳的 JSON 中,解析出代表圖片數據的那一長串 Base64 字串。
- 轉換為
Data
物件:建立一個Data
物件。Data
是 Swift 中用來處理任何二進位數據的通用型別。您可以使用它的初始化方法Data(base64Encoded: options:)
,將 Base64 字串還原為原始的圖片二進位資料。- 從
Data
建立UIImage
:最後,使用UIImage
的初始化方法UIImage(data:)
,將這個Data
物件直接轉譯成您的 App 可以顯示的圖片物件。整個流程就像是「解壓縮」:
Base64 String
->Data
->UIImage
。只要確保 Base64 字串是完整且有效的,這個轉換過程就非常可靠。
根據AI的建議,我把詳細的實作步驟整理如下:
PromptFactory
,讓它生成一個能要求 AI 同時回傳「中文解析」和「英文圖像提示詞」的 JSON 結構。GeminiAPIService
中,新增一個 generateImage
方法,專門負責呼叫圖像生成 API,並處理回傳的 Base64 數據。InputViewController
中,將整個流程串起來:先呼叫文字 API 取得解析和提示詞,再用提示詞去呼叫圖像 API 取得圖片,最後將兩者一起傳遞到下一頁。我們的第一步是教 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 回應時,畫面是靜止的;圖片出現時,也顯得有些突兀。明天,我們將為這個專案加入更流暢的動畫與使用者體驗優化。