|
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
|
} |