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
334 lines
11 KiB
Swift
334 lines
11 KiB
Swift
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()
|
|
}
|
|
}
|