Raw
1 //
2 // ContentView.swift
3 // Siti AI visionOS
4 //
5
6 import SwiftUI
7 import Onde
8
9 // ─────────────────────────────────────────────────────────────────────────────
10 // MARK: - Root view
11 // ─────────────────────────────────────────────────────────────────────────────
12
13 struct ContentView: View {
14
15 @StateObject private var viewModel = ChatViewModel()
16 @State private var showingSettings = false
17
18 @AppStorage("ai.siti.vision.systemPrompt")
19 private var systemPrompt: String = SitiDefaults.systemPrompt
20
21 @AppStorage("ai.siti.vision.samplingPreset")
22 private var samplingPresetRawValue: String = SamplingPreset.balanced.rawValue
23
24 private var samplingPreset: SamplingPreset {
25 SamplingPreset(rawValue: samplingPresetRawValue) ?? .balanced
26 }
27
28 var body: some View {
29 NavigationStack {
30 VStack(spacing: 0) {
31 HeaderView(
32 info: viewModel.engineInfo,
33 isLoading: viewModel.isModelLoading,
34 isSending: viewModel.isSending
35 )
36
37 Divider()
38
39 ZStack {
40 if viewModel.messages.isEmpty {
41 EmptyStateView(
42 isModelReady: viewModel.isModelReady,
43 isModelLoading: viewModel.isModelLoading,
44 loadingProgress: viewModel.loadingProgress,
45 promptSuggestions: SitiDefaults.promptSuggestions,
46 onPromptTap: { suggestion in
47 viewModel.sendSuggestion(suggestion)
48 },
49 onRetry: {
50 Task {
51 await viewModel.retryLoadingModel(
52 systemPrompt: systemPrompt,
53 samplingPreset: samplingPreset
54 )
55 }
56 }
57 )
58 } else {
59 MessageListView(messages: viewModel.messages)
60 }
61
62 if viewModel.isModelLoading {
63 LoadingOverlayView(progress: viewModel.loadingProgress)
64 }
65 }
66
67 Divider()
68
69 InputBarView(
70 isSending: viewModel.isSending,
71 isModelReady: viewModel.isModelReady,
72 placeholder: viewModel.isModelReady
73 ? "Message Siti…"
74 : "Waiting for model to load…",
75 onSend: { text in viewModel.send(text: text) },
76 onCancel: { viewModel.cancelStreaming() }
77 )
78 }
79 .navigationTitle("Siti AI")
80 .toolbar {
81 ToolbarItemGroup(placement: .cancellationAction) {
82 if viewModel.hasMessages {
83 Button(role: .destructive) {
84 Task {
85 await viewModel.clearConversation()
86 }
87 } label: {
88 Label("Clear", systemImage: "trash")
89 }
90 .disabled(viewModel.isBusy)
91 }
92 }
93
94 ToolbarItemGroup(placement: .confirmationAction) {
95 Button {
96 Task {
97 await viewModel.reloadModel(
98 systemPrompt: systemPrompt,
99 samplingPreset: samplingPreset
100 )
101 }
102 } label: {
103 Label("Reload model", systemImage: "arrow.clockwise")
104 }
105 .disabled(viewModel.isBusy)
106
107 Button {
108 showingSettings = true
109 } label: {
110 Label("Model settings", systemImage: "slider.horizontal.3")
111 }
112 .disabled(viewModel.isBusy)
113 }
114 }
115 }
116 .frame(minWidth: 640, minHeight: 480)
117 .task {
118 await viewModel.loadModelIfNeeded(
119 systemPrompt: systemPrompt,
120 samplingPreset: samplingPreset
121 )
122 }
123 .sheet(isPresented: $showingSettings) {
124 ModelSettingsSheet(
125 systemPrompt: systemPrompt,
126 samplingPreset: samplingPreset
127 ) { updatedPrompt, updatedPreset in
128 systemPrompt = updatedPrompt
129 samplingPresetRawValue = updatedPreset.rawValue
130 Task {
131 await viewModel.applySettings(
132 systemPrompt: updatedPrompt,
133 samplingPreset: updatedPreset
134 )
135 }
136 }
137 }
138 .alert(
139 "Error",
140 isPresented: Binding(
141 get: { viewModel.alertError != nil },
142 set: { if !$0 { viewModel.alertError = nil } }
143 ),
144 presenting: viewModel.alertError
145 ) { _ in
146 Button("OK", role: .cancel) { viewModel.alertError = nil }
147 } message: { error in
148 Text(error.message)
149 }
150 }
151 }
152
153 // ─────────────────────────────────────────────────────────────────────────────
154 // MARK: - Header
155 // ─────────────────────────────────────────────────────────────────────────────
156
157 private struct HeaderView: View {
158 let info: EngineInfo
159 let isLoading: Bool
160 let isSending: Bool
161
162 private var statusText: String {
163 if isLoading { return "Loading" }
164 if isSending { return "Generating" }
165
166 switch info.status {
167 case .ready: return "Ready"
168 case .generating: return "Generating"
169 case .loading: return "Loading"
170 case .error: return "Error"
171 case .unloaded: return "Unloaded"
172 }
173 }
174
175 private var statusColor: Color {
176 if isLoading { return .orange }
177 if isSending { return .blue }
178
179 switch info.status {
180 case .ready: return .green
181 case .generating: return .blue
182 case .loading: return .orange
183 case .error: return .red
184 case .unloaded: return .secondary
185 }
186 }
187
188 var body: some View {
189 HStack(spacing: 12) {
190 Image(systemName: "brain.head.profile")
191 .font(.title2)
192 .foregroundStyle(statusColor)
193
194 VStack(alignment: .leading, spacing: 2) {
195 Text("Siti AI")
196 .font(.headline)
197
198 Text(info.modelName ?? "on-device · private")
199 .font(.caption)
200 .foregroundStyle(.secondary)
201 }
202
203 Spacer()
204
205 Label(info.approxMemory ?? "—", systemImage: "memorychip")
206 Label("\(info.historyLength) turns", systemImage: "text.bubble")
207
208 Text(statusText)
209 .font(.caption.weight(.semibold))
210 .padding(.horizontal, 10)
211 .padding(.vertical, 6)
212 .background(statusColor.opacity(0.14), in: Capsule())
213 .foregroundStyle(statusColor)
214 }
215 .font(.caption)
216 .foregroundStyle(.secondary)
217 .padding(.horizontal, 20)
218 .padding(.vertical, 14)
219 }
220 }
221
222 // ─────────────────────────────────────────────────────────────────────────────
223 // MARK: - Loading overlay
224 // ─────────────────────────────────────────────────────────────────────────────
225
226 private struct LoadingOverlayView: View {
227 let progress: String
228
229 var body: some View {
230 VStack(spacing: 16) {
231 ProgressView()
232 .scaleEffect(1.4)
233
234 Text(progress)
235 .font(.subheadline)
236 .foregroundStyle(.secondary)
237 .multilineTextAlignment(.center)
238 }
239 .padding(.horizontal, 28)
240 .padding(.vertical, 24)
241 .glassBackgroundEffect(in: RoundedRectangle(cornerRadius: 20, style: .continuous))
242 .padding(.horizontal, 24)
243 }
244 }
245
246 // ─────────────────────────────────────────────────────────────────────────────
247 // MARK: - Empty state
248 // ─────────────────────────────────────────────────────────────────────────────
249
250 private struct EmptyStateView: View {
251 let isModelReady: Bool
252 let isModelLoading: Bool
253 let loadingProgress: String
254 let promptSuggestions: [PromptSuggestion]
255 let onPromptTap: (PromptSuggestion) -> Void
256 let onRetry: () -> Void
257
258 var body: some View {
259 ScrollView {
260 VStack(alignment: .leading, spacing: 20) {
261 VStack(alignment: .leading, spacing: 8) {
262 Text(isModelReady ? "How can I help?" : "Preparing the on-device model")
263 .font(.title2.weight(.semibold))
264
265 Text(subtitle)
266 .font(.subheadline)
267 .foregroundStyle(.secondary)
268 }
269
270 if isModelReady {
271 VStack(alignment: .leading, spacing: 12) {
272 Text("Try one of these prompts")
273 .font(.headline)
274
275 ForEach(promptSuggestions) { suggestion in
276 Button {
277 onPromptTap(suggestion)
278 } label: {
279 VStack(alignment: .leading, spacing: 6) {
280 Text(suggestion.title)
281 .font(.subheadline.weight(.semibold))
282 .foregroundStyle(.primary)
283 Text(suggestion.prompt)
284 .font(.subheadline)
285 .foregroundStyle(.secondary)
286 .multilineTextAlignment(.leading)
287 }
288 .frame(maxWidth: .infinity, alignment: .leading)
289 .padding(16)
290 .background(
291 RoundedRectangle(cornerRadius: 18, style: .continuous)
292 .fill(Color.secondary.opacity(0.10))
293 )
294 }
295 .buttonStyle(.plain)
296 .hoverEffect(.highlight)
297 }
298 }
299 } else {
300 VStack(alignment: .leading, spacing: 12) {
301 Label(loadingProgress, systemImage: isModelLoading ? "arrow.down.circle" : "exclamationmark.triangle")
302 .font(.subheadline)
303 .foregroundStyle(.secondary)
304
305 if !isModelLoading {
306 Button(action: onRetry) {
307 Label("Retry loading model", systemImage: "arrow.clockwise")
308 }
309 .buttonStyle(.borderedProminent)
310 }
311 }
312 }
313 }
314 .frame(maxWidth: 640, alignment: .leading)
315 .frame(maxWidth: .infinity, alignment: .center)
316 .padding(28)
317 }
318 }
319
320 private var subtitle: String {
321 if isModelReady {
322 return "Your personal assistant, running entirely on this device. Nothing you say ever leaves it."
323 }
324
325 return "The first launch may take a while because the model has to be downloaded and loaded into memory before the chat becomes interactive."
326 }
327 }
328
329 // ─────────────────────────────────────────────────────────────────────────────
330 // MARK: - Message list
331 // ─────────────────────────────────────────────────────────────────────────────
332
333 private struct MessageListView: View {
334 let messages: [Message]
335
336 var body: some View {
337 ScrollViewReader { proxy in
338 ScrollView {
339 LazyVStack(alignment: .leading, spacing: 12) {
340 ForEach(messages) { message in
341 MessageBubble(message: message)
342 .id(message.id)
343 }
344 }
345 .frame(maxWidth: 760)
346 .frame(maxWidth: .infinity, alignment: .center)
347 .padding(20)
348 }
349 .onChange(of: messages.count) { _, _ in
350 if let last = messages.last {
351 withAnimation(.easeOut(duration: 0.2)) {
352 proxy.scrollTo(last.id, anchor: .bottom)
353 }
354 }
355 }
356 .onChange(of: messages.last?.text) { _, _ in
357 if let last = messages.last {
358 proxy.scrollTo(last.id, anchor: .bottom)
359 }
360 }
361 }
362 }
363 }
364
365 // ─────────────────────────────────────────────────────────────────────────────
366 // MARK: - Message bubble
367 // ─────────────────────────────────────────────────────────────────────────────
368
369 private struct MessageBubble: View {
370 let message: Message
371
372 private var isUser: Bool { message.role == .user }
373
374 var body: some View {
375 HStack {
376 if isUser { Spacer(minLength: 48) }
377
378 VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
379 Text(isUser ? "You" : "Siti")
380 .font(.caption2)
381 .foregroundStyle(.secondary)
382 .padding(.horizontal, 4)
383
384 HStack(alignment: .bottom, spacing: 6) {
385 Text(message.text.isEmpty ? " " : message.text)
386 .font(.body)
387 .foregroundStyle(isUser ? .white : .primary)
388 .padding(.horizontal, 14)
389 .padding(.vertical, 10)
390 .background(
391 RoundedRectangle(cornerRadius: 18, style: .continuous)
392 .fill(isUser ? Color.accentColor : Color.secondary.opacity(0.15))
393 )
394
395 if message.isStreaming {
396 ProgressView()
397 .scaleEffect(0.7)
398 .padding(.bottom, 8)
399 }
400 }
401 }
402
403 if !isUser { Spacer(minLength: 48) }
404 }
405 }
406 }
407
408 // ─────────────────────────────────────────────────────────────────────────────
409 // MARK: - Input bar
410 // ─────────────────────────────────────────────────────────────────────────────
411
412 private struct InputBarView: View {
413 let isSending: Bool
414 let isModelReady: Bool
415 let placeholder: String
416 let onSend: (String) -> Void
417 let onCancel: () -> Void
418
419 @State private var draftText: String = ""
420 @FocusState private var fieldFocused: Bool
421
422 private var canSend: Bool {
423 isModelReady && !isSending && !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
424 }
425
426 var body: some View {
427 HStack(spacing: 10) {
428 TextField(placeholder, text: $draftText, axis: .vertical)
429 .lineLimit(1...5)
430 .padding(.horizontal, 14)
431 .padding(.vertical, 10)
432 .background(
433 RoundedRectangle(cornerRadius: 20, style: .continuous)
434 .fill(Color.secondary.opacity(0.15))
435 )
436 .focused($fieldFocused)
437 .disabled(!isModelReady || isSending)
438 .onSubmit {
439 submitIfReady()
440 }
441
442 Group {
443 if isSending {
444 Button(action: onCancel) {
445 Image(systemName: "stop.circle.fill")
446 .font(.system(size: 32))
447 .foregroundStyle(.red)
448 }
449 .accessibilityLabel("Stop generating")
450 } else {
451 Button(action: submitIfReady) {
452 Image(systemName: "arrow.up.circle.fill")
453 .font(.system(size: 32))
454 .foregroundStyle(canSend ? Color.accentColor : .secondary)
455 }
456 .disabled(!canSend)
457 .accessibilityLabel("Send message")
458 }
459 }
460 .buttonStyle(.plain)
461 .animation(.easeInOut(duration: 0.15), value: isSending)
462 }
463 .frame(maxWidth: 760)
464 .frame(maxWidth: .infinity, alignment: .center)
465 .padding(.horizontal, 20)
466 .padding(.vertical, 14)
467 }
468
469 private func submitIfReady() {
470 guard canSend else { return }
471 let text = draftText
472 draftText = ""
473 onSend(text)
474 }
475 }
476
477 // ─────────────────────────────────────────────────────────────────────────────
478 // MARK: - Model settings sheet
479 // ─────────────────────────────────────────────────────────────────────────────
480
481 private struct ModelSettingsSheet: View {
482 let initialSystemPrompt: String
483 let initialSamplingPreset: SamplingPreset
484 let onSave: (String, SamplingPreset) -> Void
485
486 @Environment(\.dismiss) private var dismiss
487 @State private var systemPrompt: String
488 @State private var samplingPreset: SamplingPreset
489
490 init(
491 systemPrompt: String,
492 samplingPreset: SamplingPreset,
493 onSave: @escaping (String, SamplingPreset) -> Void
494 ) {
495 self.initialSystemPrompt = systemPrompt
496 self.initialSamplingPreset = samplingPreset
497 self.onSave = onSave
498 _systemPrompt = State(initialValue: systemPrompt)
499 _samplingPreset = State(initialValue: samplingPreset)
500 }
501
502 var body: some View {
503 NavigationStack {
504 Form {
505 Section {
506 TextEditor(text: $systemPrompt)
507 .frame(minHeight: 140)
508 } header: {
509 Text("System Prompt")
510 } footer: {
511 Text("This prompt applies to future generations. Leave it blank to clear the runtime system prompt.")
512 }
513
514 Section("Response Style") {
515 Picker("Sampling Preset", selection: $samplingPreset) {
516 ForEach(SamplingPreset.allCases) { preset in
517 VStack(alignment: .leading, spacing: 2) {
518 Text(preset.title)
519 Text(preset.subtitle)
520 .font(.caption)
521 .foregroundStyle(.secondary)
522 }
523 .tag(preset)
524 }
525 }
526 .pickerStyle(.inline)
527 }
528 }
529 .navigationTitle("Model Settings")
530 .toolbar {
531 ToolbarItem(placement: .cancellationAction) {
532 Button("Cancel") {
533 dismiss()
534 }
535 }
536
537 ToolbarItem(placement: .confirmationAction) {
538 Button("Save") {
539 onSave(systemPrompt, samplingPreset)
540 dismiss()
541 }
542 }
543 }
544 }
545 .frame(minWidth: 480, minHeight: 420)
546 }
547 }
548
549 // ─────────────────────────────────────────────────────────────────────────────
550 // MARK: - Preview
551 // ─────────────────────────────────────────────────────────────────────────────
552
553 #Preview {
554 ContentView()
555 }