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