bbcf0c74bb
- 19 Swift source files (~4900 lines) - Complete UI with SwiftUI (MainView, SettingsView, MessageBubble, InputBar) - Inference layer (LlmEngine, Agent, ToolCalling, ConversationContext) - Services (Audio, TTS, WebSearch, ModelDownload, Storage) - Build system: Makefile, Package.swift, Podfile - Documentation: BUILD.md, plan.md, PROJECT_STATUS.md - Ready for Xcode build - just need LiteRT dependency added
346 lines
11 KiB
Swift
346 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@StateObject private var viewModel = SettingsViewModel()
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Form {
|
|
// Model Section
|
|
modelSection
|
|
|
|
// Server Section
|
|
serverSection
|
|
|
|
// TTS Section
|
|
ttsSection
|
|
|
|
// Device Info
|
|
deviceSection
|
|
|
|
// About
|
|
aboutSection
|
|
}
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
viewModel.loadSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Model Section
|
|
|
|
private var modelSection: some View {
|
|
Section("Models") {
|
|
// Current model status
|
|
HStack {
|
|
Text("Status")
|
|
Spacer()
|
|
modelStatusView
|
|
}
|
|
|
|
// Download progress
|
|
if let variant = viewModel.downloadingVariant {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Downloading \(variant.uppercased())")
|
|
.font(.caption)
|
|
ProgressView(value: viewModel.downloadProgress, total: 100)
|
|
Text("\(Int(viewModel.downloadProgress))%")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
// Gemma 4 E2B
|
|
ModelVariantRow(
|
|
name: "Gemma 4 E2B",
|
|
description: "2B params, fastest, good for most tasks (~2.7GB)",
|
|
isDownloaded: viewModel.isE2BDownloaded,
|
|
isSelected: viewModel.selectedVariant == "e2b",
|
|
isDownloading: viewModel.downloadingVariant == "e2b",
|
|
onSelect: { viewModel.selectVariant("e2b") },
|
|
onDownload: { viewModel.downloadModel("e2b") },
|
|
onDelete: { viewModel.deleteModel("e2b") }
|
|
)
|
|
|
|
// Gemma 4 E4B
|
|
ModelVariantRow(
|
|
name: "Gemma 4 E4B",
|
|
description: "4B params, better quality, slower (~4.5GB)",
|
|
isDownloaded: viewModel.isE4BDownloaded,
|
|
isSelected: viewModel.selectedVariant == "e4b",
|
|
isDownloading: viewModel.downloadingVariant == "e4b",
|
|
onSelect: { viewModel.selectVariant("e4b") },
|
|
onDownload: { viewModel.downloadModel("e4b") },
|
|
onDelete: { viewModel.deleteModel("e4b") }
|
|
)
|
|
|
|
// Custom model
|
|
Button(action: { viewModel.showDocumentPicker = true }) {
|
|
Label("Select from Files", systemImage: "folder")
|
|
}
|
|
.sheet(isPresented: $viewModel.showDocumentPicker) {
|
|
DocumentPicker(selectedURL: $viewModel.customModelURL)
|
|
}
|
|
|
|
// Load button
|
|
if !viewModel.modelPath.isEmpty && !viewModel.modelLoaded && !viewModel.isLoadingModel {
|
|
Button(action: { viewModel.loadModel() }) {
|
|
Label("Load Selected Model", systemImage: "play.circle")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var modelStatusView: some View {
|
|
if viewModel.isLoadingModel {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
Text("Loading...")
|
|
}
|
|
.foregroundColor(.secondary)
|
|
} else if viewModel.modelLoaded {
|
|
Label("Loaded", systemImage: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
} else if let error = viewModel.modelLoadError {
|
|
Label("Error", systemImage: "xmark.circle.fill")
|
|
.foregroundColor(.red)
|
|
.help(error)
|
|
} else {
|
|
Text("Not loaded")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Section
|
|
|
|
private var serverSection: some View {
|
|
Section("Servers") {
|
|
HStack {
|
|
TextField("Search Server (SearXNG)", text: $viewModel.searchServerURL)
|
|
.textContentType(.URL)
|
|
.keyboardType(.URL)
|
|
.autocapitalization(.none)
|
|
|
|
if viewModel.isCheckingSearchHealth {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else if let healthy = viewModel.searchServerHealthy {
|
|
Image(systemName: healthy ? "checkmark.circle.fill" : "xmark.circle.fill")
|
|
.foregroundColor(healthy ? .green : .red)
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
TextField("Delegate Server (LLM)", text: $viewModel.delegateServerURL)
|
|
.textContentType(.URL)
|
|
.keyboardType(.URL)
|
|
.autocapitalization(.none)
|
|
|
|
if viewModel.isCheckingDelegateHealth {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else if let healthy = viewModel.delegateServerHealthy {
|
|
Image(systemName: healthy ? "checkmark.circle.fill" : "xmark.circle.fill")
|
|
.foregroundColor(healthy ? .green : .red)
|
|
}
|
|
}
|
|
|
|
Text("Leave empty to disable server features. URLs are saved automatically.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - TTS Section
|
|
|
|
private var ttsSection: some View {
|
|
Section("Text to Speech") {
|
|
Toggle("Enable TTS", isOn: $viewModel.ttsEnabled)
|
|
|
|
Toggle("Auto-detect mode", isOn: $viewModel.ttsAutoMode)
|
|
.disabled(!viewModel.ttsEnabled)
|
|
|
|
if viewModel.ttsEnabled {
|
|
Text("Voice input → speaks response, Text input → silent")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Device Section
|
|
|
|
private var deviceSection: some View {
|
|
Section("Your Device") {
|
|
HStack {
|
|
Text("Total RAM")
|
|
Spacer()
|
|
Text(viewModel.totalRAM)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text("Available RAM")
|
|
Spacer()
|
|
Text(viewModel.availableRAM)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text("Device")
|
|
Spacer()
|
|
Text(viewModel.deviceModel)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text("iOS Version")
|
|
Spacer()
|
|
Text(viewModel.iOSVersion)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - About Section
|
|
|
|
private var aboutSection: some View {
|
|
Section {
|
|
HStack {
|
|
Spacer()
|
|
VStack(spacing: 4) {
|
|
Text("Sleepy Agent")
|
|
.font(.headline)
|
|
Text("Local LLM inference with Gemma 4")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Model Variant Row
|
|
|
|
struct ModelVariantRow: View {
|
|
let name: String
|
|
let description: String
|
|
let isDownloaded: Bool
|
|
let isSelected: Bool
|
|
let isDownloading: Bool
|
|
let onSelect: () -> Void
|
|
let onDownload: () -> Void
|
|
let onDelete: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(name)
|
|
.font(.body)
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isDownloaded {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Button(action: onSelect) {
|
|
Text(isSelected ? "Selected" : "Select")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(!isDownloaded || isSelected)
|
|
|
|
Spacer()
|
|
|
|
if isDownloaded {
|
|
Button(action: onDelete) {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.tint(.red)
|
|
} else {
|
|
Button(action: onDownload) {
|
|
if isDownloading {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else {
|
|
Label("Download", systemImage: "arrow.down.circle")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isDownloading)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
// MARK: - Document Picker
|
|
|
|
struct DocumentPicker: UIViewControllerRepresentable {
|
|
@Binding var selectedURL: URL?
|
|
@Environment(\.presentationMode) var presentationMode
|
|
|
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data, .item])
|
|
picker.delegate = context.coordinator
|
|
picker.allowsMultipleSelection = false
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
class Coordinator: NSObject, UIDocumentPickerDelegate {
|
|
let parent: DocumentPicker
|
|
|
|
init(_ parent: DocumentPicker) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
parent.selectedURL = urls.first
|
|
parent.presentationMode.wrappedValue.dismiss()
|
|
}
|
|
|
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
|
parent.presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
struct SettingsView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
SettingsView()
|
|
}
|
|
}
|