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
This commit is contained in:
2026-04-06 14:26:08 +02:00
commit bbcf0c74bb
28 changed files with 5747 additions and 0 deletions
+333
View File
@@ -0,0 +1,333 @@
import SwiftUI
struct MainView: View {
@StateObject private var viewModel = MainViewModel()
@State private var showingSettings = false
@State private var showingSidebar = false
@State private var inputText = ""
@State private var scrollProxy: ScrollViewProxy?
var body: some View {
NavigationView {
ZStack {
// Main content
VStack(spacing: 0) {
// Messages list
messagesList
// Loading indicator
if viewModel.isLoading {
ProgressView()
.padding(.vertical, 8)
}
// Input bar
InputBar(
text: $inputText,
onSend: { text in
viewModel.sendMessage(text: text)
inputText = ""
},
onVoiceTap: {
viewModel.toggleRecording()
},
onImageTap: {
viewModel.showImagePicker = true
},
isRecording: viewModel.isRecording,
isProcessing: viewModel.isProcessing,
isExecutingTool: viewModel.isExecutingTool
)
.padding(.horizontal)
.padding(.bottom, 8)
}
// Sidebar overlay
if showingSidebar {
sidebarOverlay
}
}
.navigationTitle("Sleepy Agent")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { showingSidebar.toggle() }) {
Image(systemName: "line.3.horizontal")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingSettings = true }) {
Image(systemName: "gear")
}
}
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(isPresented: $viewModel.showImagePicker) {
ImagePicker(selectedImage: $viewModel.selectedImage)
}
.alert("Error", isPresented: $viewModel.showError) {
Button("OK") { viewModel.dismissError() }
} message: {
Text(viewModel.errorMessage)
}
.onChange(of: viewModel.messages.count) { _ in
scrollToBottom()
}
.onChange(of: viewModel.streamingText) { _ in
scrollToBottom()
}
}
private var messagesList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
if viewModel.messages.isEmpty {
welcomeView
} else {
ForEach(viewModel.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
// Streaming response
if !viewModel.streamingText.isEmpty && viewModel.isResponding {
MessageBubble(
message: ChatMessage(
id: "streaming",
text: viewModel.streamingText + (viewModel.isSpeaking ? "" : ""),
isUser: false,
isStreaming: true
)
)
.id("streaming")
}
}
}
.padding()
}
.onAppear {
scrollProxy = proxy
}
}
}
private var welcomeView: some View {
VStack(spacing: 16) {
Spacer()
Text("👋 Welcome to Sleepy Agent")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.accentColor)
Text("Tap the microphone to start speaking\nor type a message below")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
if viewModel.uiState == .error {
Text(viewModel.errorMessage)
.font(.callout)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Spacer()
}
.frame(maxWidth: .infinity, minHeight: 300)
}
private var sidebarOverlay: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
// Sidebar content
ConversationSidebar(
conversations: viewModel.conversations,
currentId: viewModel.currentConversationId,
onSelect: { id in
viewModel.loadConversation(id: id)
showingSidebar = false
},
onNewChat: {
viewModel.startNewConversation()
showingSidebar = false
},
onDelete: { id in
viewModel.deleteConversation(id: id)
}
)
.frame(width: min(300, geometry.size.width * 0.75))
.background(Color(.systemBackground))
// Tap to dismiss
Color.black.opacity(0.3)
.onTapGesture {
showingSidebar = false
}
}
}
.transition(.move(edge: .leading))
.zIndex(1)
}
private func scrollToBottom() {
guard let proxy = scrollProxy else { return }
withAnimation {
if viewModel.isResponding && !viewModel.streamingText.isEmpty {
proxy.scrollTo("streaming", anchor: .bottom)
} else if let lastMessage = viewModel.messages.last {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
// MARK: - Conversation Sidebar
struct ConversationSidebar: View {
let conversations: [ConversationInfo]
let currentId: String
let onSelect: (String) -> Void
let onNewChat: () -> Void
let onDelete: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Text("Chat History")
.font(.headline)
Spacer()
Button(action: onNewChat) {
Image(systemName: "plus")
}
}
.padding()
Divider()
// New Chat button
Button(action: onNewChat) {
Label("New Chat", systemImage: "plus.circle")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
.padding()
Divider()
// Conversations list
if conversations.isEmpty {
Spacer()
Text("No previous chats")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
} else {
List {
ForEach(conversations) { conversation in
ConversationRow(
conversation: conversation,
isSelected: conversation.id == currentId
)
.contentShape(Rectangle())
.onTapGesture {
onSelect(conversation.id)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
onDelete(conversation.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
}
}
}
}
struct ConversationRow: View {
let conversation: ConversationInfo
let isSelected: Bool
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.title)
.font(.body)
.lineLimit(1)
.foregroundColor(isSelected ? .accentColor : .primary)
Text("\(dateFormatter.string(from: conversation.date))\(conversation.messageCount) messages")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
}
}
// MARK: - Image Picker
struct ImagePicker: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.presentationMode) var presentationMode
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage {
parent.selectedImage = image
}
parent.presentationMode.wrappedValue.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.presentationMode.wrappedValue.dismiss()
}
}
}
// MARK: - Preview
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}