diff --git a/Ollmao/Ollmao/ContentView.swift b/Ollmao/Ollmao/ContentView.swift index 743399f..8f43173 100644 --- a/Ollmao/Ollmao/ContentView.swift +++ b/Ollmao/Ollmao/ContentView.swift @@ -9,7 +9,14 @@ import SwiftUI import MarkdownUI struct ContentView: View { - @StateObject private var viewModel = ChatViewModel() + @StateObject private var conversationManager = ConversationManager() + @StateObject private var viewModel: ChatViewModel + + init() { + let manager = ConversationManager() + _conversationManager = StateObject(wrappedValue: manager) + _viewModel = StateObject(wrappedValue: ChatViewModel(conversationManager: manager)) + } var body: some View { NavigationSplitView { @@ -18,7 +25,8 @@ struct ContentView: View { selectedId: $viewModel.selectedConversationId, selectedModel: $viewModel.selectedModel, availableModels: viewModel.availableModels, - onNewChat: { viewModel.newConversation() } + onNewChat: { viewModel.newConversation() }, + onDelete: { viewModel.deleteConversation($0) } ) .frame(minWidth: 300) } detail: { @@ -37,6 +45,7 @@ struct SidebarView: View { @Binding var selectedModel: String let availableModels: [String] let onNewChat: () -> Void + let onDelete: (UUID) -> Void var body: some View { VStack(spacing: 0) { @@ -64,12 +73,25 @@ struct SidebarView: View { ConversationButton( conversation: conversation, isSelected: selectedId == conversation.id, - action: { selectedId = conversation.id } - ) + onDelete: { onDelete(conversation.id) } + ) { + selectedId = conversation.id + } Divider() } } } + + Divider() + + // Model selector + Picker("Model", selection: $selectedModel) { + ForEach(availableModels, id: \.self) { model in + Text(model).tag(model) + } + } + .pickerStyle(.menu) + .padding() } .background(Color.gray.opacity(0.1)) } @@ -78,6 +100,7 @@ struct SidebarView: View { struct ConversationButton: View { let conversation: Conversation let isSelected: Bool + let onDelete: () -> Void let action: () -> Void var body: some View { @@ -88,6 +111,15 @@ struct ConversationButton: View { Text(conversation.title) .lineLimit(1) Spacer() + + Menu { + Button(role: .destructive, action: onDelete) { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .foregroundColor(.secondary) + } } .padding(.horizontal) .padding(.vertical, 12) @@ -112,10 +144,19 @@ struct ChatView: View { } if !viewModel.currentStreamContent.isEmpty { - StreamingMessageView(content: viewModel.currentStreamContent, isStreaming: viewModel.isStreaming) + MessageView(message: .init(role: .assistant, content: viewModel.currentStreamContent)) .id("streaming") } else if viewModel.isLoading { - TypingIndicator() + HStack { + Image("Ollmao") + .resizable() + .frame(width: 30, height: 30) + .clipShape(Circle()) + Text("Waiting for assistant...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() } } .padding(.horizontal) @@ -136,15 +177,6 @@ struct ChatView: View { .padding() } - // Model selector above input - Picker("Model", selection: $viewModel.selectedModel) { - ForEach(viewModel.availableModels, id: \.self) { model in - Text(model).tag(model) - } - } - .pickerStyle(.menu) - .padding(.horizontal) - HStack(alignment: .bottom, spacing: 12) { TextField("Send a message...", text: $viewModel.inputMessage, axis: .vertical) .textFieldStyle(.plain) @@ -161,118 +193,51 @@ struct ChatView: View { } } - sendButton + Button { + Task { + await viewModel.sendMessage() + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 32)) + .symbolRenderingMode(.hierarchical) + .foregroundColor(viewModel.inputMessage.isEmpty ? .secondary : .accentColor) + } + .disabled(viewModel.isLoading || viewModel.inputMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .keyboardShortcut(.return, modifiers: []) } .padding() } .background(Color.gray.opacity(0.1)) } - - private var sendButton: some View { - Button { - Task { - await viewModel.sendMessage() - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 32)) - .symbolRenderingMode(.hierarchical) - .foregroundColor(viewModel.inputMessage.isEmpty ? .secondary : .accentColor) - } - .disabled(viewModel.isLoading || viewModel.inputMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .keyboardShortcut(.return, modifiers: []) - } } -struct MarkdownView: View { - let content: String - +struct EmptyStateView: View { var body: some View { - Markdown(content) - .textSelection(.enabled) - .applyCodeBlockStyle() - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 16) { + Image(systemName: "message") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("Select or create a conversation") + .font(.headline) + .foregroundColor(.secondary) + } } } -private extension View { - func applyCodeBlockStyle() -> some View { - markdownBlockStyle(\.codeBlock) { configuration in - VStack(alignment: .leading, spacing: 0) { - // Language label if available - if let language = configuration.language { - Text(language) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .padding(.horizontal, 12) - .padding(.vertical, 4) - } - - // Code content - ScrollView(.horizontal, showsIndicators: false) { - configuration.label - .font(.system(.body, design: .monospaced)) - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .background { - RoundedRectangle(cornerRadius: 8) - .fill(Color(nsColor: .textBackgroundColor)) - } - .overlay(alignment: .topTrailing) { - Button { - #if os(iOS) - UIPasteboard.general.string = configuration.content - #else - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(configuration.content, forType: .string) - #endif - } label: { - Image(systemName: "doc.on.doc") - .foregroundColor(.secondary) - .padding(8) - } - .buttonStyle(.plain) - } - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .padding(.vertical, 8) // Add vertical margin - } - } - - func applyHeadingStyles() -> some View { - self - .markdownBlockStyle(\.heading1) { config in - config.label - .foregroundColor(.primary) - .font(.system(size: 28, weight: .bold)) - .padding(.vertical, 8) - } - .markdownBlockStyle(\.heading2) { config in - config.label - .foregroundColor(.primary) - .font(.system(size: 24, weight: .bold)) - .padding(.vertical, 6) - } - .markdownBlockStyle(\.heading3) { config in - config.label - .foregroundColor(.primary) - .font(.system(size: 20, weight: .bold)) - .padding(.vertical, 4) - } - } - - func applyParagraphStyle() -> some View { - markdownBlockStyle(\.paragraph) { config in - config.label - .foregroundColor(.primary) - .font(.system(size: 16)) - .lineSpacing(4) - .padding(.vertical, 2) +struct LoadingView: View { + var body: some View { + HStack(alignment: .top) { + Image("Ollmao") + .resizable() + .frame(width: 30, height: 30) + .clipShape(Circle()) + + ProgressView() + .padding(.top, 8) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() } } @@ -300,7 +265,13 @@ struct MessageView: View { HStack { Text(message.role == .user ? "You" : "Assistant") .font(.headline) + + Text(message.timestamp, style: .time) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Button { #if os(iOS) UIPasteboard.general.string = message.content @@ -427,266 +398,94 @@ struct ThinkingStreamView: View { } } -struct StreamingMessageView: View { +struct MarkdownView: View { let content: String - let isStreaming: Bool var body: some View { - HStack(alignment: .top, spacing: 12) { - Image("Ollmao") - .resizable() - .frame(width: 30, height: 30) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Assistant") - .font(.headline) - Spacer() - Button { - #if os(iOS) - UIPasteboard.general.string = content - #else - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(content, forType: .string) - #endif - } label: { - Image(systemName: "doc.on.doc") - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - - ThinkingStreamView(content: content, isStreaming: isStreaming) - } - } - .padding() - .background(Color.clear) - .cornerRadius(8) - } -} - -struct TypingIndicator: View { - var body: some View { - HStack(alignment: .top, spacing: 12) { - Image("Ollmao") - .resizable() - .frame(width: 30, height: 30) - .clipShape(Circle()) - - Text("Assistant is typing...") - .foregroundColor(.secondary) - Spacer() - } - .padding() - } -} - -struct EmptyStateView: View { - var body: some View { - VStack(spacing: 16) { - Image(systemName: "message") - .font(.system(size: 64)) - .foregroundColor(.secondary) - Text("Select a conversation or start a new chat") - .font(.headline) - .foregroundColor(.secondary) - } - } -} - -struct ConversationRow: View { - let conversation: Conversation - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(conversation.title) - .lineLimit(1) - .foregroundColor(.primary) - Text(conversation.model) - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.vertical, 4) - } -} - -struct LoadingView: View { - var body: some View { - HStack(alignment: .top) { - Image("Ollmao") - .resizable() - .frame(width: 30, height: 30) - .clipShape(Circle()) - .padding(.top, 4) - - TypingIndicator() - .padding() - - Spacer() - } - .padding() + Markdown(content) + .textSelection(.enabled) + .applyCodeBlockStyle() + .frame(maxWidth: .infinity, alignment: .leading) } } -struct StreamingView: View { - let content: String - - var body: some View { - HStack(alignment: .top) { - Image("Ollmao") - .resizable() - .frame(width: 30, height: 30) - .clipShape(Circle()) - .padding(.top, 4) - - VStack(alignment: .leading) { - Button(action: { +private extension View { + func applyCodeBlockStyle() -> some View { + markdownBlockStyle(\.codeBlock) { configuration in + VStack(alignment: .leading, spacing: 0) { + // Language label if available + if let language = configuration.language { + Text(language) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 4) + } + + // Code content + ScrollView(.horizontal, showsIndicators: false) { + configuration.label + .font(.system(.body, design: .monospaced)) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background { + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .textBackgroundColor)) + } + .overlay(alignment: .topTrailing) { + Button { #if os(iOS) - UIPasteboard.general.string = content + UIPasteboard.general.string = configuration.content #else NSPasteboard.general.clearContents() - NSPasteboard.general.setString(content, forType: .string) + NSPasteboard.general.setString(configuration.content, forType: .string) #endif - }) { + } label: { Image(systemName: "doc.on.doc") .foregroundColor(.secondary) + .padding(8) } .buttonStyle(.plain) - - ScrollView { - Text(.init(content)) - .textSelection(.enabled) - } } - - Spacer() - } - .padding() - } -} - -struct MessageBubble: View { - let message: ChatMessage - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 16) { - if message.role == .assistant { - Image("Ollmao") - .resizable() - .frame(width: 30, height: 30) - .clipShape(Circle()) - } else { - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 30, height: 30) - .foregroundColor(.accentColor) - } - - VStack(alignment: .leading, spacing: 8) { - if message.role == .assistant { - Button(action: { - #if os(iOS) - UIPasteboard.general.string = message.content - #else - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(message.content, forType: .string) - #endif - }) { - Image(systemName: "doc.on.doc") - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - - ScrollView { - Text(.init(message.content)) - .textSelection(.enabled) - } - } + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } - - Text(message.timestamp, style: .time) - .font(.caption2) - .foregroundColor(.secondary) - } - .padding() - .background(message.role == .user ? Color.accentColor : Color.clear) - .cornerRadius(8) - } -} - -struct ErrorView: View { - let errorMessage: String? - - var body: some View { - if let errorMessage, !errorMessage.isEmpty { - Text(errorMessage) - .foregroundColor(.red) - .padding() - } - } -} - -struct MessageInputRow: View { - @ObservedObject var viewModel: ChatViewModel - @FocusState var isInputFocused: Bool - - var body: some View { - HStack(spacing: 12) { - TextField("Message...", text: $viewModel.inputMessage, axis: .vertical) - .textFieldStyle(.plain) - .padding(12) - .background(Color.gray.opacity(0.2)) - .cornerRadius(8) - .focused($isInputFocused) - - SendButton(viewModel: viewModel) + .padding(.vertical, 8) // Add vertical margin } - .padding(.horizontal, 48) - .padding(.vertical, 16) } -} - -struct SendButton: View { - @ObservedObject var viewModel: ChatViewModel - var body: some View { - Button { - Task { - await viewModel.sendMessage() + func applyHeadingStyles() -> some View { + self + .markdownBlockStyle(\.heading1) { config in + config.label + .foregroundColor(.primary) + .font(.system(size: 28, weight: .bold)) + .padding(.vertical, 8) + } + .markdownBlockStyle(\.heading2) { config in + config.label + .foregroundColor(.primary) + .font(.system(size: 24, weight: .bold)) + .padding(.vertical, 6) + } + .markdownBlockStyle(\.heading3) { config in + config.label + .foregroundColor(.primary) + .font(.system(size: 20, weight: .bold)) + .padding(.vertical, 4) } - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 24)) - .foregroundColor(viewModel.inputMessage.isEmpty ? .secondary : .accentColor) - } - .disabled(viewModel.isLoading || viewModel.inputMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .keyboardShortcut(.return, modifiers: []) - } -} - -struct LoadingContent: View { - @ObservedObject var viewModel: ChatViewModel - - var body: some View { - if !viewModel.isStreaming { - LoadingView() - } else if !viewModel.currentStreamContent.isEmpty { - StreamingView(content: viewModel.currentStreamContent) - } } -} - -struct ChatInputView: View { - @ObservedObject var viewModel: ChatViewModel - @FocusState var isInputFocused: Bool - var body: some View { - VStack(spacing: 16) { - ErrorView(errorMessage: viewModel.errorMessage) - MessageInputRow(viewModel: viewModel, isInputFocused: _isInputFocused) + func applyParagraphStyle() -> some View { + markdownBlockStyle(\.paragraph) { config in + config.label + .foregroundColor(.primary) + .font(.system(size: 16)) + .lineSpacing(4) + .padding(.vertical, 2) } } } diff --git a/Ollmao/Ollmao/Managers/ConversationManager.swift b/Ollmao/Ollmao/Managers/ConversationManager.swift new file mode 100644 index 0000000..62d8056 --- /dev/null +++ b/Ollmao/Ollmao/Managers/ConversationManager.swift @@ -0,0 +1,52 @@ +import Foundation + +class ConversationManager: ObservableObject { + private let saveKey = "savedConversations" + @Published private(set) var conversations: [Conversation] = [] + + init() { + loadConversations() + } + + func loadConversations() { + if let data = UserDefaults.standard.data(forKey: saveKey), + let decoded = try? JSONDecoder().decode([Conversation].self, from: data) { + self.conversations = decoded + } + } + + private func saveConversations() { + if let encoded = try? JSONEncoder().encode(conversations) { + UserDefaults.standard.set(encoded, forKey: saveKey) + } + } + + func addConversation() { + let conversation = Conversation() + conversations.insert(conversation, at: 0) + saveConversations() + } + + func updateConversation(_ conversation: Conversation) { + if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { + conversations[index] = conversation + } else { + conversations.insert(conversation, at: 0) + } + saveConversations() + } + + func deleteConversation(_ conversation: Conversation) { + conversations.removeAll { $0.id == conversation.id } + saveConversations() + } + + func renameConversation(_ conversation: Conversation, newTitle: String) { + if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { + var updated = conversation + updated.title = newTitle + conversations[index] = updated + saveConversations() + } + } +} diff --git a/Ollmao/Ollmao/Models.swift b/Ollmao/Ollmao/Models.swift deleted file mode 100644 index 60dc7f4..0000000 --- a/Ollmao/Ollmao/Models.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -struct ChatMessage: Identifiable, Equatable, Codable { - var id: UUID - let role: MessageRole - var content: String - let timestamp: Date - - init(id: UUID = UUID(), role: MessageRole, content: String, timestamp: Date = Date()) { - self.id = id - self.role = role - self.content = content - self.timestamp = timestamp - } -} - -enum MessageRole: String, Codable, Equatable { - case user - case assistant - case system -} - -struct Conversation: Identifiable { - var id: UUID - var title: String - var messages: [ChatMessage] - let model: String - let timestamp: Date - - init(id: UUID = UUID(), title: String = "New Chat", messages: [ChatMessage] = [], model: String, timestamp: Date = Date()) { - self.id = id - self.title = title - self.messages = messages - self.model = model - self.timestamp = timestamp - } -} diff --git a/Ollmao/Ollmao/Models/ChatMessage.swift b/Ollmao/Ollmao/Models/ChatMessage.swift new file mode 100644 index 0000000..6518cbc --- /dev/null +++ b/Ollmao/Ollmao/Models/ChatMessage.swift @@ -0,0 +1,21 @@ +import Foundation + +struct ChatMessage: Identifiable, Codable { + let id: UUID + let role: Role + var content: String + let timestamp: Date + + init(id: UUID = UUID(), role: Role, content: String, timestamp: Date = Date()) { + self.id = id + self.role = role + self.content = content + self.timestamp = timestamp + } + + enum Role: String, Codable { + case system + case user + case assistant + } +} diff --git a/Ollmao/Ollmao/Models/Conversation.swift b/Ollmao/Ollmao/Models/Conversation.swift new file mode 100644 index 0000000..d2d6d96 --- /dev/null +++ b/Ollmao/Ollmao/Models/Conversation.swift @@ -0,0 +1,35 @@ +import Foundation + +struct Conversation: Identifiable, Codable { + let id: UUID + var title: String + var messages: [ChatMessage] + let createdAt: Date + + init(id: UUID = UUID(), title: String = "New Chat", messages: [ChatMessage] = [], createdAt: Date = Date()) { + self.id = id + self.title = title + self.messages = messages + self.createdAt = createdAt + } + + enum CodingKeys: String, CodingKey { + case id, title, messages, createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + messages = try container.decode([ChatMessage].self, forKey: .messages) + createdAt = try container.decode(Date.self, forKey: .createdAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encode(messages, forKey: .messages) + try container.encode(createdAt, forKey: .createdAt) + } +} diff --git a/Ollmao/Ollmao/OllmaoApp.swift b/Ollmao/Ollmao/OllmaoApp.swift index c9b662f..dcfea28 100644 --- a/Ollmao/Ollmao/OllmaoApp.swift +++ b/Ollmao/Ollmao/OllmaoApp.swift @@ -9,9 +9,19 @@ import SwiftUI @main struct OllmaoApp: App { + @StateObject private var conversationManager = ConversationManager() + @StateObject private var viewModel: ChatViewModel + + init() { + let manager = ConversationManager() + _conversationManager = StateObject(wrappedValue: manager) + _viewModel = StateObject(wrappedValue: ChatViewModel(conversationManager: manager)) + } + var body: some Scene { WindowGroup { ContentView() + .environmentObject(viewModel) } } } diff --git a/Ollmao/Ollmao/ViewModels/ChatViewModel.swift b/Ollmao/Ollmao/ViewModels/ChatViewModel.swift index 3f5c54d..6e4e30b 100644 --- a/Ollmao/Ollmao/ViewModels/ChatViewModel.swift +++ b/Ollmao/Ollmao/ViewModels/ChatViewModel.swift @@ -3,7 +3,6 @@ import SwiftUI @MainActor class ChatViewModel: ObservableObject { - @Published var conversations: [Conversation] = [] @Published var selectedConversationId: UUID? @Published var inputMessage = "" @Published var isLoading = false @@ -13,6 +12,12 @@ class ChatViewModel: ObservableObject { @Published var availableModels: [String] = [] @Published var errorMessage: String? + private let conversationManager: ConversationManager + + var conversations: [Conversation] { + conversationManager.conversations + } + var selectedConversation: Conversation? { conversations.first { $0.id == selectedConversationId } } @@ -21,7 +26,9 @@ class ChatViewModel: ObservableObject { conversations.firstIndex { $0.id == selectedConversationId } } - init() { + init(conversationManager: ConversationManager) { + self.conversationManager = conversationManager + self.selectedConversationId = conversationManager.conversations.first?.id Task { await loadModels() } @@ -41,15 +48,17 @@ class ChatViewModel: ObservableObject { } func newConversation() { - let conversation = Conversation(model: selectedModel) - conversations.insert(conversation, at: 0) + let conversation = Conversation() + conversationManager.updateConversation(conversation) selectedConversationId = conversation.id } func deleteConversation(_ id: UUID) { - conversations.removeAll { $0.id == id } - if selectedConversationId == id { - selectedConversationId = conversations.first?.id + if let conversation = conversations.first(where: { $0.id == id }) { + conversationManager.deleteConversation(conversation) + if selectedConversationId == id { + selectedConversationId = conversations.first?.id + } } } @@ -60,31 +69,36 @@ class ChatViewModel: ObservableObject { } let userMessage = ChatMessage(role: .user, content: inputMessage) - conversations[conversationIndex].messages.append(userMessage) + var updatedConversation = conversations[conversationIndex] + updatedConversation.messages.append(userMessage) + conversationManager.updateConversation(updatedConversation) + inputMessage = "" isLoading = true currentStreamContent = "" do { - var streamedResponse = "" let stream = try await OllamaService.shared.generateResponse( prompt: userMessage.content, - messages: Array(conversations[conversationIndex].messages.dropLast()), + messages: Array(updatedConversation.messages.dropLast()), model: selectedModel ) isStreaming = true - for try await chunk in stream { - streamedResponse += chunk + var streamedResponse = "" + + for try await text in stream { + streamedResponse += text currentStreamContent = streamedResponse } - let assistantMessage = ChatMessage(role: .assistant, content: streamedResponse) - conversations[conversationIndex].messages.append(assistantMessage) + updatedConversation.messages.append(ChatMessage(role: .assistant, content: streamedResponse)) + conversationManager.updateConversation(updatedConversation) + isStreaming = false currentStreamContent = "" } catch { - print("Error sending message: \(error)") + print("Error: \(error)") errorMessage = "Failed to send message: \(error.localizedDescription)" }