AI
AI Chat
ShipThatApp ships with a dual-engine chat experience most starter kits can't match: the same ChatView runs on Apple's on-device Foundation Models or cloud ChatGPT, and the user flips between them with a single toggle. On-device chat is private, works offline, and costs nothing per message; the cloud path adds reach and a guaranteed fallback. Both support multi-turn conversations with persisted history and one-off prompts — and every cloud request travels through a secure backend proxy, so your OpenAI keys never ship inside the app.
Architecture Overview
The chat feature follows the project's MVVM conventions and is built from a few small, focused pieces:
ChatView— the SwiftUI chat interface, complete with message bubbles, an input field, and a provider toggle.ChatViewModel— an@Observable@MainActorview model that owns the chat state and orchestrates the services.ChatMessage— a SwiftData@Modelthat persists each message (id,isUser,messageContent,timestamp).ChatRepository— the SwiftData persistence layer that stores and fetches messages.ChatGPTService— sends full conversation history for context-aware, multi-turn chat.ChatGPTSimpleService— sends a single prompt for one-off requests.ApiClient— the shared networking layer that signs every request before it hits the backend proxy (see API Client & Security).LocalLLMService— wraps Apple'sFoundationModelsfor fully on-device, offline generation: streaming, availability state, and session management.LocalLLMServiceFallbackcovers devices below iOS 26.
On-Device AI with Apple Foundation Models
This is the part most kits don't have. On a capable device (iOS 26+ with Apple Intelligence enabled), ChatView answers entirely on-device with Apple's Foundation Models — no network, no API key, no per-message cost, and nothing leaves the phone.
LocalLLMService wraps the framework behind a small protocol:
@available(iOS 26.0, *)
@Observable @MainActor
final class LocalLLMService: LocalLLMServiceProtocol {
private let model = SystemLanguageModel.default
private var session: LanguageModelSession?
func streamResponse(prompt: String) async throws -> AsyncThrowingStream<String, Error> { /* ... */ }
}
ChatViewModel picks the engine per message from a user toggle and live availability:
if useOnDeviceAI && isOnDeviceAIAvailable {
responseContent = try await sendOnDeviceChat(prompt: userMessage.messageContent) // streams on-device
} else {
responseContent = try await sendCloudChat() // cloud ChatGPT
}
Availability, handled for you
On-device models aren't on every device, so the kit treats availability as first-class. ModelAvailabilityState maps the framework's status into something your UI can render directly — .available, .deviceNotEligible, .appleIntelligenceNotEnabled, .modelNotReady (still downloading) — each with a statusMessage and iconName. toggleAIProvider() only switches to on-device when it's actually available, and prewarms the model on switch for a faster first token.
Graceful by design
- Streaming first. On-device generation streams tokens into
currentStreamingContentfor live UI updates. - Self-healing sessions. A context-overflow error transparently resets the
LanguageModelSession; guardrail violations surface as a friendly "content blocked" message. - Always a fallback. Below iOS 26,
LocalLLMServiceFactoryreturnsLocalLLMServiceFallback, and the cloud route is always available — so chat works on every device, with on-device as a privacy/performance upgrade where the hardware supports it.
Why this matters
On-device "Foundation Models chat" is becoming table stakes — but a robust one isn't. ShipThatApp ships streaming, an availability state machine, session recovery, and graceful fallback, wired to the same UI as the cloud behind one toggle. Private, offline, and free per message wherever the hardware allows.
Multi-Turn Chat with Context
The primary chat experience keeps the full conversation in memory and on disk, so the model always answers with the previous turns in mind.
When the user sends a message, ChatViewModel.sendChat() appends the user message, then builds the request from the entire message history:
/// Sends a chat message using the cloud AI service.
private func sendCloudChat() async throws -> String {
let chatGPTMessages: [ChatGPTMessage] = messages.map { chatMessage in
let role = chatMessage.isUser ? "user" : "assistant"
return ChatGPTMessage(role: role, content: chatMessage.messageContent)
}
let cloudResponse = try await chatService.sendMessages(chatGPTMessages)
return cloudResponse.content
}
Each stored ChatMessage is mapped to a ChatGPTMessage with a role of either "user" or "assistant", preserving the back-and-forth that gives the model its context. ChatGPTService.sendMessages(_:) wraps the array in a ChatGPTRequest and posts it to the chatgpt endpoint, returning a ChatGPTResponse (a role and content pair).
Persistence with SwiftData
Conversations survive app launches because every message is written through ChatRepository, which is backed by a SwiftData ModelContainer for the ChatMessage model:
func appendItem(message: ChatMessage) {
dataSource.appendMessage(
message: ChatMessage(
id: UUID().uuidString,
isUser: message.isUser,
messageContent: message.messageContent,
timestamp: Date()
)
)
messages = dataSource.fetchMessages()
}
The view model loads existing messages from the repository in its initializer (messages = dataSource.fetchMessages()), so the chat is restored automatically. The repository also exposes removeItem(message:) and removeAll() for deleting individual messages (via swipe-to-delete) or clearing the whole thread.
If a request fails, sendChat() removes the just-added user message, restores the text to the input field, and surfaces the error — leaving the persisted history clean.
One-Off Single Requests
Not every AI feature needs a conversation. For single-shot prompts — generating a tagline, summarizing text, answering a one-time question — use ChatGPTSimpleService, which sends just one prompt string and returns one response:
final class ChatGPTSimpleService {
func sendPrompt(_ prompt: String) async throws -> ChatGPTResponse {
let requestModel = ChatGPTSimpleRequest(prompt: prompt)
let result = await ApiClient.shared.sendRequest(
endpoint: Endpoints.chatgptSimple,
body: try JSONEncoder().encode(requestModel),
responseModel: ChatGPTResponse.self
)
switch result {
case .success(let response):
return response
case .failure(let failure):
throw failure
}
}
}
ChatViewModel.sendPrompt() demonstrates the call site: it sends userInput, stores the result in response, and toggles isLoading/finished for the UI. Unlike the multi-turn path, this carries no conversation history — it hits the dedicated chatgpt-simple endpoint instead.
The Secure Backend Proxy
The most important detail: OpenAI API keys are never bundled with the app. Both ChatGPTService and ChatGPTSimpleService go through ApiClient.shared.sendRequest(...), which forwards the call to your own backend proxy. The proxy holds the OpenAI key and talks to OpenAI on the app's behalf.
HMAC-Signed Requests
To stop anyone from abusing your proxy, every request is signed with HMAC before it leaves the device. ApiClient builds a canonical string from the HTTP method, path, a timestamp, and a SHA-256 hash of the body, then signs it:
// HMAC request signing. The signature covers
// METHOD\nPATH\nTIMESTAMP\nSHA256(body) so a captured request cannot be
// replayed or tampered with. The backend verifies the identical string.
if let signingKey = endpoint.signingKey {
var signedPath = "/" + endpoint.path
if let pathExtension { signedPath.append("/\(pathExtension)") }
let timestamp = String(Int(Date().timeIntervalSince1970))
let bodyHash = CryptoUtils.shared.sha256Hex(body ?? Data())
let canonical = "\(endpoint.method.rawValue)\n\(signedPath)\n\(timestamp)\n\(bodyHash)"
let signature = CryptoUtils.shared.createHmac(key: signingKey, phrase: canonical)
request.setValue(signature, forHTTPHeaderField: "x-signature")
request.setValue(timestamp, forHTTPHeaderField: "x-timestamp")
}
The signature and its x-timestamp ride along as headers. Because the timestamp and body hash are part of the signed payload, a captured request cannot be replayed or tampered with — the backend recomputes the identical string and verifies the signature before doing any work.
Endpoints and Signing Keys
The chat services target two endpoints defined in Endpoints.swift, both POST:
Endpoints.chatgpt→ pathchatgpt(multi-turn)Endpoints.chatgptSimple→ pathchatgpt-simple(one-off)
These endpoints don't sign with an app-embedded secret. Instead, signingKey returns a per-session token that was issued by the /auth_token endpoint and stored in the Keychain:
var signingKey: String? {
switch self {
case .auth:
return Config.Api.authKey
case .chatgptSimple, .chatgpt, .dalle, .generateDinner, .generateDinnerImage, .recipe, .coverImage, .vision:
return KeychainSwift().get(Config.Keychain.tokenKey)
}
}
ApiClient also retries once on a 401 after a short delay, giving the session a chance to refresh before the call ultimately fails.
Keep secrets out of the app
The app-embedded Config.Api.authKey lives in a gitignored Config.xcconfig — never in Info.plist or hardcoded source. It only bootstraps the /auth_token exchange; all AI traffic is signed with the short-lived session token from the Keychain. Your OpenAI key stays exclusively on the backend.
Using ChatView
Dropping the full chat experience into your app is a one-liner — ChatView creates and owns its own view model:
struct ChatView: View {
/// View model for the chat view
@State private var viewModel = ChatViewModel()
var body: some View {
// Message list, input field, and provider toggle
}
}
Present it inside a NavigationStack:
NavigationStack {
ChatView()
}
Out of the box, ChatView gives you:
- A scrolling, animated message list with user/assistant bubbles and timestamps.
- A multiline input field wired to
viewModel.sendChat()on submit. - Swipe-to-delete on individual messages and a toolbar button to clear the whole conversation.
- A loading indicator while a response is in flight.
Calling the View Model Directly
If you want to build your own UI, talk to ChatViewModel directly. Send a multi-turn message:
viewModel.userInput = "What's a good name for a productivity app?"
await viewModel.sendChat()
// viewModel.messages now holds the full conversation,
// persisted via ChatRepository.
Or fire a one-off prompt without touching the conversation history:
viewModel.userInput = "Summarize this paragraph in one sentence."
await viewModel.sendPrompt()
// viewModel.response holds the single ChatGPTResponse.
Both methods are @MainActor and update observable state, so any SwiftUI view bound to the view model refreshes automatically.
Summary
ShipThatApp's chat is dual-engine: private, offline, zero-cost on-device generation via Apple Foundation Models where the hardware supports it, and cloud ChatGPT everywhere else — switchable with one toggle, behind a single ChatView. Both engines support context-aware multi-turn chat (persisted with SwiftData) and one-off prompts, and every cloud call runs through the same HMAC-signed ApiClient, so your OpenAI keys stay off the device.