Files
sleepy_agent_ios/SleepyAgent/UI/Views/SettingsView.swift
T
sleepy bbcf0c74bb Initial iOS port - Complete source code and build system
- 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
2026-04-06 14:26:08 +02:00

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