1// AIAssistantView.swift
2// SophaxChat
3//
4// Local AI assistant powered by Apple Foundation Models.
5// Runs entirely on-device — no server, no API key, no data leaves the phone.
6// Requires Xcode 26 SDK + iOS 26 with Apple Intelligence enabled.
7
8import SwiftUI
9#if canImport(FoundationModels)
10import FoundationModels
11#endif
12
13// MARK: - Entry point
14
15struct AIAssistantView: View {
16 var seedPrompt: String? = nil
17
18 var body: some View {
19#if canImport(FoundationModels)
20 if #available(iOS 26.0, *) {
21 AIAssistantViewImpl(seedPrompt: seedPrompt)
22 } else {
23 UnavailableView()
24 }
25#else
26 UnavailableView()
27#endif
28 }
29}
30
31// MARK: - Unavailable placeholder
32
33private struct UnavailableView: View {
34 var body: some View {
35 VStack(spacing: 16) {
36 Image(systemName: "sparkles")
37 .font(.system(size: 48))
38 .foregroundStyle(.secondary)
39 Text("Requires iOS 26")
40 .font(.title3.weight(.semibold))
41 Text("Update to iOS 26 with Apple Intelligence enabled to use the on-device assistant.")
42 .font(.subheadline)
43 .foregroundStyle(.secondary)
44 .multilineTextAlignment(.center)
45 .padding(.horizontal, 40)
46 }
47 .navigationTitle("Assistant")
48 .navigationBarTitleDisplayMode(.inline)
49 }
50}
51
52// MARK: - Implementation (only compiled when FoundationModels SDK is present)
53
54#if canImport(FoundationModels)
55
56@available(iOS 26.0, *)
57private struct AIAssistantViewImpl: View {
58 var seedPrompt: String? = nil
59 @StateObject private var ai = AISession()
60 @State private var text = ""
61 @FocusState private var focused: Bool
62
63 var body: some View {
64 VStack(spacing: 0) {
65 messagesView
66 Divider()
67 inputBar
68 }
69 .navigationTitle("Assistant")
70 .navigationBarTitleDisplayMode(.inline)
71 .task {
72 if let prompt = seedPrompt {
73 await ai.send(prompt)
74 }
75 }
76 .toolbar {
77 ToolbarItem(placement: .navigationBarTrailing) {
78 if !ai.messages.isEmpty {
79 Button("Clear") { ai.reset() }
80 .font(.subheadline)
81 }
82 }
83 }
84 }
85
86 private var messagesView: some View {
87 ScrollViewReader { proxy in
88 ScrollView {
89 LazyVStack(spacing: 8) {
90 if ai.messages.isEmpty { emptyState }
91 ForEach(ai.messages) { msg in AIBubble(message: msg) }
92 if ai.isThinking {
93 HStack {
94 ThinkingBubble()
95 Spacer(minLength: 60)
96 }
97 .padding(.horizontal, 16)
98 }
99 Color.clear.frame(height: 4).id("bottom")
100 }
101 .padding(.top, 8)
102 }
103 .onChange(of: ai.messages.count) { _, _ in
104 withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom") }
105 }
106 .onChange(of: ai.isThinking) { _, _ in
107 withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom") }
108 }
109 }
110 }
111
112 private var emptyState: some View {
113 VStack(spacing: 12) {
114 Image(systemName: "sparkles")
115 .font(.system(size: 44))
116 .foregroundStyle(.secondary)
117 Text("On-device AI")
118 .font(.title3.weight(.semibold))
119 Text("Runs on your device.\nNothing leaves your phone.")
120 .font(.subheadline)
121 .foregroundStyle(.secondary)
122 .multilineTextAlignment(.center)
123 if ai.isUnavailable {
124 Text("Apple Intelligence is not enabled on this device.")
125 .font(.caption)
126 .foregroundStyle(.orange)
127 .padding(.top, 4)
128 }
129 }
130 .padding(.top, 72)
131 .padding(.horizontal, 40)
132 }
133
134 private var inputBar: some View {
135 HStack(spacing: 12) {
136 TextField("Message", text: $text, axis: .vertical)
137 .focused($focused)
138 .lineLimit(1...5)
139 .autocorrectionDisabled()
140 .textContentType(.none)
141 .padding(.horizontal, 14)
142 .padding(.vertical, 10)
143 .background(Color(.secondarySystemGroupedBackground))
144 .clipShape(RoundedRectangle(cornerRadius: 20))
145 Button { send() } label: {
146 Image(systemName: "arrow.up.circle.fill")
147 .font(.system(size: 32))
148 .foregroundStyle(canSend ? Color.accentColor : .secondary)
149 }
150 .disabled(!canSend)
151 }
152 .padding(.horizontal, 16)
153 .padding(.vertical, 10)
154 .background(.bar)
155 }
156
157 private var canSend: Bool {
158 !text.trimmingCharacters(in: .whitespaces).isEmpty && !ai.isThinking && !ai.isUnavailable
159 }
160
161 private func send() {
162 let trimmed = text.trimmingCharacters(in: .whitespaces)
163 guard !trimmed.isEmpty else { return }
164 text = ""
165 Task { await ai.send(trimmed) }
166 }
167}
168
169@available(iOS 26.0, *)
170@MainActor
171private final class AISession: ObservableObject {
172 @Published var messages: [AIMessage] = []
173 @Published var isThinking = false
174 @Published var isUnavailable = false
175
176 private var session: LanguageModelSession?
177
178 private static let instructions = """
179 You are a private local AI assistant inside SophaxChat — a secure, serverless chat app. \
180 Be concise and direct. You can help with: drafting messages, summarizing conversations, \
181 translating text, and answering questions. \
182 You run entirely on this device. No data ever leaves the phone.
183 """
184
185 init() {
186 switch SystemLanguageModel.default.availability {
187 case .available:
188 session = LanguageModelSession(instructions: Self.instructions)
189 default:
190 isUnavailable = true
191 }
192 }
193
194 func send(_ text: String) async {
195 messages.append(AIMessage(body: text, isUser: true))
196 isThinking = true
197 defer { isThinking = false }
198 guard let session else { return }
199 do {
200 let response = try await session.respond(to: text)
201 messages.append(AIMessage(body: response.content, isUser: false))
202 } catch {
203 messages.append(AIMessage(body: "Couldn't process that. Try again.", isUser: false))
204 }
205 }
206
207 func reset() {
208 messages.removeAll()
209 session = LanguageModelSession(instructions: Self.instructions)
210 }
211}
212
213private struct AIMessage: Identifiable {
214 let id = UUID()
215 let body: String
216 let isUser: Bool
217}
218
219private struct AIBubble: View {
220 let message: AIMessage
221
222 var body: some View {
223 HStack(alignment: .bottom, spacing: 0) {
224 if message.isUser { Spacer(minLength: 60) }
225 Text(message.body)
226 .font(.body)
227 .foregroundStyle(message.isUser ? .white : .primary)
228 .padding(.horizontal, 14)
229 .padding(.vertical, 10)
230 .background(message.isUser ? Color.accentColor : Color(.secondarySystemGroupedBackground))
231 .clipShape(RoundedRectangle(cornerRadius: 18))
232 if !message.isUser { Spacer(minLength: 60) }
233 }
234 .padding(.horizontal, 16)
235 }
236}
237
238private struct ThinkingBubble: View {
239 @State private var animating = false
240
241 var body: some View {
242 HStack(spacing: 5) {
243 ForEach(0..<3) { i in
244 Circle()
245 .fill(Color.secondary.opacity(0.6))
246 .frame(width: 7, height: 7)
247 .offset(y: animating ? -4 : 0)
248 .animation(
249 .easeInOut(duration: 0.45).repeatForever().delay(Double(i) * 0.15),
250 value: animating
251 )
252 }
253 }
254 .padding(.horizontal, 14)
255 .padding(.vertical, 12)
256 .background(Color(.secondarySystemGroupedBackground))
257 .clipShape(RoundedRectangle(cornerRadius: 18))
258 .onAppear { animating = true }
259 }
260}
261
262#endif
263
264#Preview {
265 NavigationStack { AIAssistantView() }
266}