Фреймворк Foundation Models великолепен! Он позволяет нам выполнять интеллектуальные(?) задачи, используя большую языковую модель Apple на устройстве.
Однако давайте будем честны. Это как ChatGPT двухлетней (или трёхлетней?) давности.
Он не принимает многомодальные входные данные и не позволяет определять многомодальный вывод, ни для вызовов инструментов, ни для типа ответа при управляемой генерации. Кстати, когда я говорю «мультимодальные», я имею в виду обычный текст вместе с некоторыми бинарными артефактами.
Надеюсь, мои эти небольшие жалобы послужат небольшой мотивацией для того, что мы будем делать в этой статье.
В любом случае, вот чего я хочу добиться. Я хочу генерировать изображения с помощью Foundation Models. Думаю, как только вы это увидели, основной подход тоже стал довольно очевиден.
Определим инструмент для генерации изображений — ImageCreator из фреймворка Image Playground.
Звучит очень просто, правда? Тогда зачем я вообще пишу эту статью? Потому что cуществует много вещей, которые мы не можем возвращать как выходные данные инструмента или определять как тип управляемого ответа модели. Конечно, можно просто предоставить несколько отдельных пользовательских интерфейсов для каждой цели, например: один для чата, один для генерации изображений, и использовать либо Foundation Models, либо Image Playground соответственно.
Возможно, так было два года назад, но в конце 2025 года это точно не то, что нужно делать. Что, если вам нужно генерировать и другие типы артефактов, помимо изображений? Звуковые дорожки. Файлы кода. И бла-бла-бла. Вы собираетесь создавать отдельный пользовательский интерфейс для каждого из них и ожидать, что пользователь будет переключаться между ними?
В этой статье давайте рассмотрим:
- мой подход к этому
- пару других неработающих подходов, которые я пробовал
- некоторые другие возможные подходы и потенциальные проблемы, связанные с ними
Что я пробовал, но не сработало
Позвольте мне начать эту статью с пары подходов, которые я пробовал, и которые, кажется, должны работать, но не работают. Чтобы вам не пришлось тратить время на размышления, почему я этого не сделал, или тратить время на попытки самостоятельно.
Возврат изображения напрямую
Это должно быть первым, что приходит на ум. Если наши инструменты предназначены для генерации изображений, то, конечно же, вызов инструмента должен возвращать изображение. Вроде такого:
private struct NotWorkingTool: Tool {
// ...
func call(arguments: Arguments) async throws -> CGImage {
return CGImage(...)
}
}
К сожалению, это не разрешено.
Та же ошибка для других типов изображений или Data.
Обернем изображения в Generable
Почему бы нам не обернуть наш CGImage в какие-нибудь кастомные Generable структуры?
@Generable
private struct NotWorkingGeneratedImages {
@Guide(description: "...")
var images: [CGImage]
}
private struct NotWorkingTool: Tool {
// ...
func call(arguments: Arguments) async throws -> NotWorkingGeneratedImages {
return NotWorkingGeneratedImages(images: [])
}
}
Это не приведет к немедленному возникновению какой-либо ошибки, подобной указанной выше, но если мы выполним код, то получим ошибку «Type ‘CGImage’ does not conform to protocol ‘Generable’».
И снова та же ошибка для других типов изображений или Data.
Строка Base64
Строка соответствует PromptRepresentable. Мы все это знаем.
Так почему бы нам просто не вернуть строку данных изображения, закодированную в base64?
private struct NotWorkingTool: Tool {
// ...
func call(arguments: Arguments) async throws -> String {
return "..."
}
}
Всё отлично скомпилируется (конечно, ведь это всего лишь строка).
Давайте запустим несколько промптов:
let session = LanguageModelSession(tools: [NotWorkingTool(...)]) let response = try await session.respond(to: "give me an image of a banana.")
Угадайте, что? Конечно, это не сработает! Если бы это сработало, я бы даже не стал писать эту статью.
Вот что мы получаем — guardrailViolation:
Error Domain=FoundationModels.LanguageModelSession.GenerationError Code=-1 "(null)" UserInfo={NSMultipleUnderlyingErrorsKey=(
"Error Domain=FoundationModels.LanguageModelSession.GenerationError Code=-1 \"(null)\" UserInfo={NSMultipleUnderlyingErrorsKey=(\n \"Error Domain=com.apple.SensitiveContentAnalysisML Code=15 \\\"(null)\\\" UserInfo={NSMultipleUnderlyingErrorsKey=(\\n \\\"Error Domain=ModelManagerServices.ModelManagerError Code=1013 \\\\\\\"(null)\\\\\\\" UserInfo={NSMultipleUnderlyingErrorsKey=(\\\\n)}\\\"\\n)}\"\n)}"
)}
guardrailViolation(FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: "May contain unsafe content", underlyingErrors: []))
Решение
Сохраните данные изображения и верните URL-адрес. На самом деле, строку пути, поскольку URL-адрес не соответствует ни PromptRepresentable, ни ConvertibleFromGeneratedContent.
Отправная точка
Давайте начнём с простого, чтобы подтвердить предлагаемый подход. В нашем кастомном Tool мы:
- Определим
Arguments, которые должна генерировать модель. Здесь у нас будет три параметра: контент, описывающий, что пользователь хочет использовать для генерации, стиль генерируемого изображения и ограничение на количество генерируемых изображений. - Реализуем
call(arguments:). В этой функции мы вызовемImageCreator.images(for:style:limit:),for try awaitдля возвращаемогоAsyncSequence, чтобы получитьCGImages, преобразовать их вDataи записать во временные URL-адреса.
private struct GenerateImageTool: Tool {
private let creator: ImageCreator
init(creator: ImageCreator) {
self.creator = creator
}
let name = "GenerateImage"
let description = "Generate images when requested by the user."
private let tempDirectory = FileManager.default.temporaryDirectory
@Generable
struct Arguments {
@Guide(description: "Text describing the expected contents of the image.")
let content: String
// have to use id, ie: String here, because ImagePlaygroundStyle cannot be used as Guided type
@Guide(description: "Style for image generation. Available ones: \(ImagePlaygroundStyle.animation.id), \(ImagePlaygroundStyle.illustration.id), \(ImagePlaygroundStyle.sketch.id)")
let style: String
@Guide(description: "Number of images to generate.")
let limit: Int
}
func call(arguments: Arguments) async throws -> GeneratedImages {
var count = 0
var urls:[URL] = []
let style = ImagePlaygroundStyle(id: arguments.style) ?? .illustration
let images = creator.images(
for: [.text(arguments.content)],
style: style,
limit: arguments.limit
)
for try await image in images {
if let data = image.cgImage.nsImage.data {
let url = tempDirectory.appending(path: "\(UUID()).png")
try data.write(to: url)
urls.append(url)
}
count = count + 1
if count >= arguments.limit {
break
}
}
return GeneratedImages(images: urls.map({$0.absolutePath}))
}
}
@Generable
private struct GeneratedImages {
@Guide(description: "A list of url for the generated images")
var images: [String]
}
private extension NSImage {
nonisolated
var data: Data? {
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let rep = NSBitmapImageRep(cgImage: cgImage)
rep.size = self.size
return rep.representation(using: .png, properties: [:])
}
}
private extension URL {
nonisolated var absolutePath: String {
return self.path(percentEncoded: false)
}
}
private extension CGImage {
nonisolated
var nsImage: NSImage {
NSImage(cgImage: self, size: .init(width: CGFloat(self.width), height: CGFloat(self.height)))
}
}
extension ImagePlaygroundStyle {
nonisolated
init?(id: String) {
switch id {
case ImagePlaygroundStyle.animation.id:
self = .animation
case ImagePlaygroundStyle.illustration.id:
self = .illustration
case ImagePlaygroundStyle.sketch.id:
self = .sketch
default:
return nil
}
}
}
Если вы ориентируетесь на iOS, вы можете просто использовать UIImage.pngData() для получения данных изображения.
Кроме того, есть несколько моментов, на которые следует обратить внимание при конвертации NSImage в PNG. Если вам интересно, ознакомьтесь с одной из моих предыдущих статей: «Swift/MacOS: NSImage в данные PNG».
Попробуйте
import Playgrounds
#Playground {
do {
let imageCreator = try await ImageCreator()
let session = LanguageModelSession(tools: [GenerateImageTool(creator: imageCreator)])
let response = try await session.respond(to: "give me an image of a banana.")
print(response.content)
print(session.transcript)
} catch(let error) {
print(error)
}
}
Вот что мы получаем для content:
Here is an image of a banana: [](/var/folders/9j/_lzmzx0953n0zxsx21gvbn_00000gn/T/15FB1AFF-E6E0-4B06-A45D-4FE772C93167.png)
Если мы откроем URL вручную, конечно же, наш банан будет там:
А вот полный transcript сессии, просто для подтверждения параметров вызова инструмента и его выходных данных.
Transcript(entries: [(Instructions) , (Prompt) give me an image of a banana.
Response Format: <nil>,
(ToolCalls) GenerateImage: {"prompt": "banana", "style": "illustration", "limit": 1},
(ToolOutput GenerateImage) {"images": ["/var/folders/9j/_lzmzx0953n0zxsx21gvbn_00000gn/T/15FB1AFF-E6E0-4B06-A45D-4FE772C93167.png"]},
(Response) Here is an image of a banana: [](/var/folders/9j/_lzmzx0953n0zxsx21gvbn_00000gn/T/15FB1AFF-E6E0-4B06-A45D-4FE772C93167.png)])
Проблемы
Выше выглядит хорошо? И да, и нет. Хорошая новость — мы действительно получаем URL-адреса изображений, как и ожидалось. Плохая новость — URL-адреса скрыты в какой-то markdown разметке.
Казалось бы, как это может быть проблемой? У нас есть разметка, мы можем легко отобразить её текстом! Как это было в моём предыдущей статье «SwiftUI: рендерим маркдаун».
К сожалению, нет. Блочная разметка не отображается в Text! Это значит, что выше будет просто текст Banana.
Но, по крайней мере, мы получаем ссылку. Пользователь может нажать на неё, чтобы открыть изображение. К сожалению, тоже нет.
URL выше — это URL-адрес пути, и если нам нужно открыть какие-либо локальные файлы, он должен быть в файловой схеме, вроде file://.
Конечно, мы могли бы попробовать возвращать абсолютный URL вместо пути из наших инструментов, но всё равно мы понятия не имеем, что на самом деле сделает модель, встроив этот URL в маркдаун.
Улучшение с помощью управляемой генерации
Например, если мы попытаемся заставить сессию напрямую реагировать с помощью нашего типа GeneratedImages, который, по сути, идентичен выводу инструмента, вот что мы получим для content:
let response = try await session.respond( to: "give me an image of a banana.", generating: GeneratedImages.self ) print(response.content) // GeneratedImages( // images: ["/var/folders/9j/_lzmzx0953n0zxsx21gvbn_00000gn/T/13078D5D-4919-44CF-A122-DB545F8EDDE2.png"] // )
Круто! Мы получаем URL напрямую, а не обрабатываем их не таким уж умным искусственным интеллектом.
С другой стороны, очевидно, мы не можем заранее знать, общается ли пользователь с моделью обычным образом или действительно запрашивает изображения. Значит, мы не можем просто использовать GeneratedImages.
Чтобы решить нашу проблему, давайте создадим отдельный структурированный тип вывода:
@Generable
private struct ResponseType {
@Guide(description: "A list of urls for the generated binaries. Empty if no binaries generated.")
var urls: [String]
@Guide(description: "Text response.")
var textResponse: String
}
Теперь мы можем использовать это как тип ответа независимо от того, просто ли мы общаемся в чате или просим прислать какие-то изображения:
let response = try await session.respond( to: "give me an image of a banana.", generating: ResponseType.self ) // ResponseType( // urls: ["/var/folders/9j/_lzmzx0953n0zxsx21gvbn_00000gn/T/113A4826-4C2C-4B35-8474-B0D8D903E274.png"], // response: "Image of a banana has been generated successfully." // )
Собираем всё вместе
То, что у нас получилось выше, выглядит великолепно. Когда мы тестируем его по отдельности. Но будет ли оно работать, если использовать его, например, в чат-приложении? Давайте проверим!
Некоторые расширения
private extension NSImage {
nonisolated
var data: Data? {
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let rep = NSBitmapImageRep(cgImage: cgImage)
rep.size = self.size
return rep.representation(using: .png, properties: [:])
}
}
private extension URL {
nonisolated
var absolutePath: String {
return self.path(percentEncoded: false)
}
static func resolvedPathURL(string: String) -> URL? {
guard let url = URL(string: string) else {
return nil
}
if url.isFileURL {
return url
}
return URL(filePath: string)
}
}
private extension CGImage {
nonisolated
var nsImage: NSImage {
NSImage(cgImage: self, size: .init(width: CGFloat(self.width), height: CGFloat(self.height)))
}
}
private extension ImagePlaygroundStyle {
nonisolated
init?(id: String) {
switch id {
case ImagePlaygroundStyle.animation.id:
self = .animation
case ImagePlaygroundStyle.illustration.id:
self = .illustration
case ImagePlaygroundStyle.sketch.id:
self = .sketch
default:
return nil
}
}
}
Generable
@Generable
private struct GeneratedImages {
@Guide(description: "A list of url for the generated images")
var images: [String]
}
@Generable
private struct ResponseType {
@Guide(description: "A list of urls for the generated binaries. Empty if no binaries generated.")
var urls: [String]
@Guide(description: "Text response.")
var textResponse: String
}
Инструмент
Точно такой же, как и выше:
private struct GenerateImageTool: Tool {
private let creator: ImageCreator
init(creator: ImageCreator) {
self.creator = creator
}
let name = "GenerateImage"
let description = "Generate images when requested by the user."
private let tempDirectory = FileManager.default.temporaryDirectory
@Generable
struct Arguments {
@Guide(description: "Text describing the expected contents of the image.")
let content: String
// have to use id, ie: String here, because ImagePlaygroundStyle cannot be used as Guided type
@Guide(description: "Style for image generation. Available ones: \(ImagePlaygroundStyle.animation.id), \(ImagePlaygroundStyle.illustration.id), \(ImagePlaygroundStyle.sketch.id)")
let style: String
@Guide(description: "Number of images to generate.")
let limit: Int
}
func call(arguments: Arguments) async throws -> GeneratedImages {
var count = 0
var urls:[URL] = []
let style = ImagePlaygroundStyle(id: arguments.style) ?? .illustration
let images = creator.images(
for: [.text(arguments.content)],
style: style,
limit: arguments.limit
)
for try await image in images {
if let data = image.cgImage.nsImage.data {
let url = tempDirectory.appending(path: "\(UUID()).png")
try data.write(to: url)
urls.append(url)
}
count = count + 1
if count >= arguments.limit {
break
}
}
return GeneratedImages(images: urls.map({$0.absolutePath}))
}
}
Менеджер чата
Минимальная версия, но для наших целей достаточно:
@Observable
private class ChatManager {
private(set) var messages: [MessageType] = []
var isResponding: Bool {
self.session?.isResponding ?? false
}
var generatedBinaries: [URL] {
var urls: [URL] = []
for message in messages {
if case .response(_, let response) = message {
let urlStrings = response.urls
let urlsTemp = urlStrings.map({URL.resolvedPathURL(string: $0)}).filter({$0 != nil}).map({$0!})
urls.append(contentsOf: urlsTemp)
}
}
return urls
}
var error: (any Error)? = nil {
didSet {
if let error = error {
print(error)
}
}
}
enum _Error: Error {
case modelUnavailable(String)
case initializationFailed
var message: String {
switch self {
case .modelUnavailable(let string):
return string
case .initializationFailed:
return "initialization failed"
}
}
}
enum MessageType: Identifiable, Equatable {
case userPrompt(UUID, String)
case response(UUID, ResponseType)
var id: UUID {
switch self {
case .userPrompt(let id, _):
return id
case .response(let id, _):
return id
}
}
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
}
}
private var generateImageTool: GenerateImageTool?
private var session: LanguageModelSession?
private let model = SystemLanguageModel.default
init() {
Task {
do {
try self.checkAvailability()
let imageCreator = try await ImageCreator()
let tool = GenerateImageTool(creator: imageCreator)
self.generateImageTool = tool
self.session = .init(
model: self.model,
tools: [tool],
instructions: Instructions {
"You are a helpful assistant."
"Your job is to fulfill user's requests."
"""
You have access to the following tools.
- \(tool.name): \(tool.description)
You should strictly follow the given rules:
- You should only use tools if necessary.
"""
}
)
} catch (let error) {
self.error = error
}
}
}
func respond(to prompt: String) async throws {
print(#function)
print(prompt)
guard let session else {
throw _Error.initializationFailed
}
if session.isResponding {
return
}
self.messages.append(.userPrompt(UUID(), prompt))
let response = try await session.respond(to: prompt, generating: ResponseType.self)
self.messages.append(.response(UUID(), response.content))
}
private func checkAvailability() throws {
let availability = model.availability
if case .unavailable(let reason) = availability {
switch reason {
case .appleIntelligenceNotEnabled:
throw _Error.modelUnavailable("Apple Intelligence is not enabled.")
case .deviceNotEligible:
throw _Error.modelUnavailable("This device is not eligible.")
case .modelNotReady:
throw _Error.modelUnavailable("Model is not ready.")
@unknown default:
throw _Error.modelUnavailable("Unknown reason.")
}
}
}
}
Представление
import QuickLook
struct FoundationModelWithImageGeneration: View {
@State private var chatManager: ChatManager = .init()
@State private var showSettingPopup: Bool = true
@State private var entry: String = ""
@State private var scrollPosition: ScrollPosition = .init()
@State private var selectedURL: URL?
@State private var entryHeight: CGFloat = 24
var body: some View {
ScrollViewReader { proxy in
List {
Text("FoundationModel + ImageGeneration")
.font(.title2)
.fontWeight(.bold)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(.white)
if let error = chatManager.error {
Text(String("\(error)"))
.foregroundStyle(.red)
.listRowSeparator(.hidden)
}
ForEach(chatManager.messages) { message in
let isUser: Bool = if case .userPrompt(_, _) = message {
true
} else {
false
}
Group {
switch message {
case .response(_, let response):
VStack(alignment: .leading) {
Text(response.textResponse)
if !response.urls.isEmpty {
Divider()
.padding(.vertical, 8)
Text("URL for the Generated Contents")
.font(.headline)
ForEach(0..<response.urls.count, id:\.self) { index in
let urlString: String = response.urls[index]
if let url = URL.resolvedPathURL(string: urlString) {
Button(action: {
selectedURL = url
}, label: {
Text(url.lastPathComponent.isEmpty ? urlString : url.lastPathComponent )
})
.quickLookPreview($selectedURL, in: self.chatManager.generatedBinaries)
}
}
}
}
.listRowBackground(Color.clear)
case .userPrompt(_, let prompt):
Text(prompt)
.listRowBackground(Color.clear)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.all, 16)
.background(RoundedRectangle(cornerRadius: 24).fill(isUser ? .yellow : .green))
.padding(isUser ? .leading: .trailing, 64)
.listRowInsets(.all, 0)
.padding(.vertical, 16)
.listRowSeparator(.hidden)
}
}
.foregroundStyle(.black)
.font(.headline)
.scrollTargetLayout()
.frame(maxWidth: .infinity)
.scrollPosition($scrollPosition, anchor: .bottom)
.defaultScrollAnchor(.bottom, for: .alignment)
.defaultScrollAnchor(.bottom, for: .initialOffset)
.onChange(of: self.chatManager.messages, initial: true, {
if let last = chatManager.messages.last {
proxy.scrollTo(last.id)
}
})
}
.frame(minWidth: 480, minHeight: 400)
.padding(.bottom, self.entryHeight)
.overlay(alignment: .bottom, content: {
HStack(spacing: 12) {
TextEditor(text: $entry)
.onSubmit({
self.sendPrompt()
})
.textEditorStyle(.plain)
.font(.system(size: 16))
.foregroundStyle(.background.opacity(0.8))
.padding(.all, 4)
.background(RoundedRectangle(cornerRadius: 4)
.stroke(.gray, style: .init(lineWidth: 1))
.fill(.white)
)
.frame(maxHeight: 120)
.fixedSize(horizontal: false, vertical: true)
Button(action: {
self.sendPrompt()
}, label: {
Image(systemName: "paperplane.fill")
})
.buttonStyle(.glass)
.foregroundStyle(.blue)
.disabled(self.chatManager.isResponding)
}
.padding(.vertical, 16)
.padding(.horizontal, 24)
.background(.yellow.opacity(0.2))
.background(.white)
.onGeometryChange(for: CGFloat.self, of: {
$0.size.height
}, action: { old, new in
self.entryHeight = new
})
})
}
private func sendPrompt() {
let entry = self.entry.trimmingCharacters(in: .whitespacesAndNewlines)
guard !entry.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}
guard !chatManager.isResponding else {
return
}
self.entry = ""
Task {
do {
try await self.chatManager.respond(to: entry)
} catch(let error) {
self.chatManager.error = error
}
}
}
}
Здесь я использую QuickLook для предпросмотра изображения. Если хотите узнать больше об этом небольшом фреймворке, пожалуйста, ознакомьтесь с другой моей статьёй: «SwiftUI + QuickLook: предпросмотр и редактирование файлов в приложении. Генерация миниатюр для файлов. На лету.»
Погнали:
Бонус: отображение хода выполнения
Генерация изображений (или любых других артефактов) может занять некоторое время, особенно если нам нужно несколько изображений. А что, если мы хотим отслеживать ход выполнения?
Просто!
- Сделайте инструмент классом
Observable. - Добавьте несколько опубликованных переменных для обновления хода выполнения, например, последнее сгенерированное изображение или даже простую строку, сообщающую пользователю, сколько изображений сгенерировано.
- Наблюдайте за переменной в представлении и отображайте некий UI-элемент на её основе.
На самом деле, я уже рассказывал вам, как этого добиться, в своей предыдущей статье.
Зачем вообще возвращать GeneratedImages?
Небольшой бонус выше может заставить вас задуматься: почему бы просто не установить AsyncSequence для ImageCreator.CreatedImage как опубликованную переменную?
Если мы это сделаем, мы можем просто выполнить for try await для переменной напрямую, чтобы получить изображение. Либо из ChatManager, либо из нашего представления. Это значит, что нам не нужно будет беспокоиться о том, что инструмент не сможет вернуть изображения, данные или URL, нам не нужно будет определять собственный ResponseType для управления ответом.
Разве это не сильно облегчит нам жизнь? Да! Безусловно! Однако вот один очень важный момент, который следует учитывать, если вы решите использовать этот подход.
Сгенерированные изображения не будут частью transcript сеанса.
- Вы сами должны будете отслеживать, какие изображения генерируются в какой части разговора.
- Вам придётся сохранять их отдельно от транскрипции (если вы действительно храните сеанс пользователя в какой-либо базе данных и пытаетесь загрузить их в следующий раз).
- Вам придётся повторить два вышеуказанных действия для любых других инструментов, используемых для генерации бинарных артефактов.
Спасибо за прочтение. На этом всё в этой статье. Вы также можете скачать фрагмент кода с моего GitHub.
Удачной генерации артефактов!

