diff --git a/ora/Common/Constants/AppEvents.swift b/ora/Common/Constants/AppEvents.swift index 5195393b..a7965d5c 100644 --- a/ora/Common/Constants/AppEvents.swift +++ b/ora/Common/Constants/AppEvents.swift @@ -16,6 +16,7 @@ extension Notification.Name { static let nextTab = Notification.Name("NextTab") static let previousTab = Notification.Name("PreviousTab") static let toggleToolbar = Notification.Name("ToggleToolbar") + static let toggleChatPanel = Notification.Name("ToggleChatPanel") static let selectTabAtIndex = Notification.Name("SelectTabAtIndex") // userInfo: ["index": Int] // Per-window settings/events diff --git a/ora/Common/Constants/KeyboardShortcuts.swift b/ora/Common/Constants/KeyboardShortcuts.swift index 2f1a12e2..029d6653 100644 --- a/ora/Common/Constants/KeyboardShortcuts.swift +++ b/ora/Common/Constants/KeyboardShortcuts.swift @@ -297,6 +297,12 @@ enum KeyboardShortcuts { category: "App", defaultChord: KeyChord(keyEquivalent: .init("d"), modifiers: [.command, .shift]) ) + static let toggleChatPanel = KeyboardShortcutDefinition( + id: "app.toggleChatPanel", + name: "Toggle Chat Panel", + category: "App", + defaultChord: KeyChord(keyEquivalent: .init("/"), modifiers: [.command]) + ) } /// All keyboard shortcut definitions @@ -329,7 +335,7 @@ enum KeyboardShortcuts { Developer.toggleDevTools, Developer.reloadIgnoringCache, // App - App.quit, App.hide, App.preferences, App.toggleSidebar, App.toggleToolbar + App.quit, App.hide, App.preferences, App.toggleSidebar, App.toggleToolbar, App.toggleChatPanel ] /// Get shortcuts grouped by category for settings display diff --git a/ora/Common/Utils/WindowFactory.swift b/ora/Common/Utils/WindowFactory.swift index b66f9941..15ee5251 100644 --- a/ora/Common/Utils/WindowFactory.swift +++ b/ora/Common/Utils/WindowFactory.swift @@ -21,6 +21,3 @@ enum WindowFactory { return window } } - - - diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 25ad8a99..00ee61cf 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -14,6 +14,8 @@ struct BrowserView: View { @State private var isMouseOverSidebar = false @StateObject private var sidebarFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction") @StateObject private var sidebarVisibility = SideHolder.usingUserDefaults(key: "ui.sidebar.visibility") + @StateObject private var chatPanelFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.chatPanel.fraction") + @StateObject private var chatPanelVisibility = SideHolder.usingUserDefaults(key: "ui.chatPanel.visibility") private func toggleSidebar() { withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { @@ -21,6 +23,12 @@ struct BrowserView: View { } } + private func toggleChatPanel() { + withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { + chatPanelVisibility.toggle(.secondary) + } + } + private func toggleMaximizeWindow() { window?.toggleMaximized() } @@ -32,16 +40,25 @@ struct BrowserView: View { SidebarView(isFullscreen: isFullscreen) }, right: { - if tabManager.activeTab != nil { - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - webView + // Main content with optional chat panel + HSplit( + left: { + mainContentArea + }, + right: { + ChatPanel() + .frame(minWidth: 295, maxWidth: 500) } - } else { - // Start page (visible when no tab is active) - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - HomeView(sidebarToggle: toggleSidebar) - } - } + ) + .splitter { Splitter.invisible() } + .fraction(chatPanelFraction) + .hide(chatPanelVisibility) + .constraints( + minPFraction: 0.4, + minSFraction: 0.33, + priority: .left + ) + .styling(hideSplitter: false) } ) .hide(sidebarVisibility) @@ -150,6 +167,9 @@ struct BrowserView: View { .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in toggleSidebar() } + .onReceive(NotificationCenter.default.publisher(for: .toggleChatPanel)) { _ in + toggleChatPanel() + } .onChange(of: downloadManager.isDownloadsPopoverOpen) { isOpen in if sidebarVisibility.side == .primary { if isOpen { @@ -192,6 +212,20 @@ struct BrowserView: View { } } + @ViewBuilder + private var mainContentArea: some View { + if tabManager.activeTab != nil { + BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { + webView + } + } else { + // Start page (visible when no tab is active) + BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { + HomeView(sidebarToggle: toggleSidebar) + } + } + } + @ViewBuilder private var webView: some View { VStack(alignment: .leading, spacing: 0) { diff --git a/ora/Modules/Settings/Sections/AISettingsView.swift b/ora/Modules/Settings/Sections/AISettingsView.swift new file mode 100644 index 00000000..18fd43a9 --- /dev/null +++ b/ora/Modules/Settings/Sections/AISettingsView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +struct AISettingsView: View { + @Environment(\.theme) var theme + @StateObject private var providerManager = AIProviderManager.shared + @State private var openAIAPIKey: String = "" + @State private var showAPIKeyField = false + @State private var isAPIKeyVisible = false + + var body: some View { + SettingsContainer(maxContentWidth: 760) { + Form { + VStack(alignment: .leading, spacing: 20) { + // Header + VStack(alignment: .leading, spacing: 8) { + Text("AI Integration") + .font(.headline) + + Text("Configure AI providers to enable chat functionality") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + + // Provider Status + VStack(alignment: .leading, spacing: 12) { + Text("Provider Status") + .font(.subheadline) + .fontWeight(.medium) + + ForEach(providerManager.providers, id: \.name) { provider in + providerStatusRow(provider) + } + } + + // OpenAI Configuration + VStack(alignment: .leading, spacing: 12) { + Text("OpenAI Configuration") + .font(.subheadline) + .fontWeight(.medium) + + VStack(alignment: .leading, spacing: 8) { + Text("API Key") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + if isAPIKeyVisible { + TextField("", text: $openAIAPIKey) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(.body, design: .monospaced)) + } else { + SecureField("", text: $openAIAPIKey) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(.body, design: .monospaced)) + } + + Button(action: { isAPIKeyVisible.toggle() }) { + Image(systemName: isAPIKeyVisible ? "eye.slash" : "eye") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: saveOpenAIKey) { + Text("Save") + } + .disabled(openAIAPIKey.isEmpty) + + if KeychainService.shared.hasOpenAIKey() { + Button(action: clearOpenAIKey) { + Text("Remove") + } + .foregroundColor(.red) + } + } + + Text("Get your API key from [OpenAI Platform](https://platform.openai.com/api-keys)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + + // Usage Information + VStack(alignment: .leading, spacing: 8) { + Text("About API Usage") + .font(.subheadline) + .fontWeight(.medium) + + VStack(alignment: .leading, spacing: 4) { + Text("• Your API keys are stored securely in macOS Keychain") + Text("• Keys are encrypted and never sent to our servers") + Text("• API requests are made directly from your browser to the provider") + Text("• You will be charged by the provider based on your usage") + Text("• Remove your API key anytime to disable AI features") + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + .padding(.vertical, 8) + } + } + .onAppear { + loadAPIKeys() + } + } + + private func providerStatusRow(_ provider: AIProvider) -> some View { + HStack { + Image(systemName: provider.isConfigured ? "checkmark.circle.fill" : "exclamationmark.circle.fill") + .foregroundColor(provider.isConfigured ? .green : .orange) + + VStack(alignment: .leading, spacing: 4) { + Text(provider.name) + .font(.subheadline) + + Text(provider.isConfigured ? "Configured" : "Requires API key") + .font(.caption) + .foregroundColor(.secondary) + + if provider.isConfigured { + Text("Models: " + provider.models.map(\.displayName).joined(separator: ", ")) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + + Spacer() + + if provider.isConfigured { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.system(size: 16)) + } + } + .padding(8) + .background( + provider.isConfigured ? Color.green.opacity(0.1) : Color.orange.opacity(0.1), + in: RoundedRectangle(cornerRadius: 6) + ) + } + + private func loadAPIKeys() { + openAIAPIKey = KeychainService.shared.getOpenAIKey() ?? "" + } + + private func saveOpenAIKey() { + let success = KeychainService.shared.storeOpenAIKey(openAIAPIKey) + + if success { + providerManager.refreshProviders() + // Show success feedback + NSSound(named: NSSound.Name("Glass"))?.play() + } else { + // Show error feedback + NSSound.beep() + print("Failed to save API key to keychain") + } + } + + private func clearOpenAIKey() { + let success = KeychainService.shared.deleteOpenAIKey() + + if success { + openAIAPIKey = "" + providerManager.refreshProviders() + NSSound(named: NSSound.Name("Glass"))?.play() + } else { + NSSound.beep() + print("Failed to remove API key from keychain") + } + } +} diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index 8b6c9770..52cf37b7 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -30,9 +30,8 @@ struct GeneralSettingsView: View { .padding(12) .background(theme.solidWindowBackgroundColor) .cornerRadius(8) - - if !defaultBrowserManager.isDefault { - + + if !defaultBrowserManager.isDefault { HStack { Text("Born for your Mac. Make Ora your default browser.") Spacer() diff --git a/ora/Modules/Settings/SettingsContentView.swift b/ora/Modules/Settings/SettingsContentView.swift index 2ec2801f..be20583a 100644 --- a/ora/Modules/Settings/SettingsContentView.swift +++ b/ora/Modules/Settings/SettingsContentView.swift @@ -1,7 +1,7 @@ import SwiftUI enum SettingsTab: Hashable { - case general, spaces, privacySecurity, shortcuts, searchEngines + case general, spaces, privacySecurity, shortcuts, searchEngines, ai var title: String { switch self { @@ -10,6 +10,7 @@ enum SettingsTab: Hashable { case .privacySecurity: return "Privacy" case .shortcuts: return "Shortcuts" case .searchEngines: return "Search" + case .ai: return "AI" } } @@ -20,6 +21,7 @@ enum SettingsTab: Hashable { case .privacySecurity: return "lock.shield" case .shortcuts: return "command" case .searchEngines: return "magnifyingglass" + case .ai: return "brain" } } } @@ -50,6 +52,10 @@ struct SettingsContentView: View { SearchEngineSettingsView() .tabItem { Label(SettingsTab.searchEngines.title, systemImage: SettingsTab.searchEngines.symbol) } .tag(SettingsTab.searchEngines) + + AISettingsView() + .tabItem { Label(SettingsTab.ai.title, systemImage: SettingsTab.ai.symbol) } + .tag(SettingsTab.ai) } .tabViewStyle(.automatic) .frame(width: 600, height: 350) diff --git a/ora/OraCommands.swift b/ora/OraCommands.swift index 45a30612..699bfaf9 100644 --- a/ora/OraCommands.swift +++ b/ora/OraCommands.swift @@ -68,6 +68,11 @@ struct OraCommands: Commands { } .keyboardShortcut(KeyboardShortcuts.App.toggleSidebar.keyboardShortcut) + Button("Toggle Chat Panel") { + NotificationCenter.default.post(name: .toggleChatPanel, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.App.toggleChatPanel.keyboardShortcut) + Divider() Button("Toggle Full URL") { NotificationCenter.default.post(name: .toggleFullURL, object: NSApp.keyWindow) } diff --git a/ora/Providers/AIProvider.swift b/ora/Providers/AIProvider.swift new file mode 100644 index 00000000..69e7671a --- /dev/null +++ b/ora/Providers/AIProvider.swift @@ -0,0 +1,91 @@ +import Foundation + +// MARK: - AI Provider Protocol + +protocol AIProvider { + var name: String { get } + var models: [AIModel] { get } + var requiresAPIKey: Bool { get } + var isConfigured: Bool { get } + + @MainActor + func sendMessageStreaming( + _ message: String, + pageContent: String?, + model: AIModel, + onChunk: @escaping (String) -> Void + ) async throws +} + +// MARK: - AI Provider Error + +enum AIProviderError: LocalizedError { + case notConfigured + case invalidAPIKey + case networkError(Error) + case invalidResponse + case rateLimited + case modelNotSupported + case unknown(String) + + var errorDescription: String? { + switch self { + case .notConfigured: + return "AI provider is not configured. Please add your API key in settings." + case .invalidAPIKey: + return "Invalid API key. Please check your API key in settings." + case let .networkError(error): + return "Network error: \(error.localizedDescription)" + case .invalidResponse: + return "Invalid response from AI provider." + case .rateLimited: + return "Rate limit exceeded. Please try again later." + case .modelNotSupported: + return "Selected model is not supported by this provider." + case let .unknown(message): + return "Unknown error: \(message)" + } + } +} + +// MARK: - AI Provider Manager + +@MainActor +class AIProviderManager: ObservableObject { + static let shared = AIProviderManager() + + @Published private(set) var providers: [AIProvider] = [] + @Published var selectedProvider: AIProvider? + + private init() { + setupProviders() + } + + private func setupProviders() { + providers = [ + OpenAIProvider() + // Future providers: ClaudeProvider(), GeminiProvider(), etc. + ] + + // Select the first configured provider, or first provider if none configured + selectedProvider = providers.first { $0.isConfigured } ?? providers.first + } + + func refreshProviders() { + setupProviders() + } + + @MainActor + func sendMessageStreaming( + _ message: String, + pageContent: String?, + model: AIModel, + onChunk: @escaping (String) -> Void + ) async throws { + guard let provider = selectedProvider else { + throw AIProviderError.notConfigured + } + + try await provider.sendMessageStreaming(message, pageContent: pageContent, model: model, onChunk: onChunk) + } +} diff --git a/ora/Providers/OpenAIProvider.swift b/ora/Providers/OpenAIProvider.swift new file mode 100644 index 00000000..7d48dc6b --- /dev/null +++ b/ora/Providers/OpenAIProvider.swift @@ -0,0 +1,101 @@ +import AIProxy +import Foundation + +// MARK: - OpenAI Provider + +class OpenAIProvider: AIProvider { + let name = "OpenAI" + let requiresAPIKey = true + + let models: [AIModel] = [ + .gpt4, + .gpt4Turbo + ] + + var isConfigured: Bool { + KeychainService.shared.hasOpenAIKey() + } + + private var apiKey: String { + KeychainService.shared.getOpenAIKey() ?? "" + } + + @MainActor + func sendMessageStreaming( + _ message: String, + pageContent: String?, + model: AIModel, + onChunk: @escaping (String) -> Void + ) async throws { + guard isConfigured else { + throw AIProviderError.notConfigured + } + + // Create the OpenAI service using BYOK (bring your own key) + let openAIService = AIProxy.openAIDirectService( + unprotectedAPIKey: apiKey + ) + + // Build messages array + var messages: [OpenAIChatCompletionRequestBody.Message] = [] + + // Add system message with page content if available + if let pageContent, !pageContent.isEmpty { + let systemMessage = """ + You are a helpful AI assistant. The user is asking about a web page. Here is the page content for context: + + \(pageContent) + + Please answer the user's question based on this page content when relevant. Be concise but informative. + """ + messages.append(.system(content: .text(systemMessage))) + } + + // Add user message + messages.append(.user(content: .text(message))) + + // Map our model enum to OpenAI model strings + let openAIModel: String + switch model { + case .gpt4: + openAIModel = "gpt-4" + case .gpt4Turbo: + openAIModel = "gpt-4-turbo" + case .claude3, .gemini: + throw AIProviderError.modelNotSupported + } + + let requestBody = OpenAIChatCompletionRequestBody( + model: openAIModel, + messages: messages + ) + + do { + // Use streaming with real-time UI updates + let stream = try await openAIService.streamingChatCompletionRequest( + body: requestBody, + secondsToWait: 60 + ) + + for try await chunk in stream { + if let content = chunk.choices.first?.delta.content { + // Call the UI update callback for each chunk + onChunk(content) + } + } + + } catch let AIProxyError.unsuccessfulRequest(statusCode, responseBody) { + // Handle specific OpenAI errors + switch statusCode { + case 401: + throw AIProviderError.invalidAPIKey + case 429: + throw AIProviderError.rateLimited + default: + throw AIProviderError.unknown("OpenAI API error (\(statusCode)): \(responseBody)") + } + } catch { + throw AIProviderError.networkError(error) + } + } +} diff --git a/ora/Services/DefaultBrowserManager.swift b/ora/Services/DefaultBrowserManager.swift index 67fdc55a..d993b8da 100644 --- a/ora/Services/DefaultBrowserManager.swift +++ b/ora/Services/DefaultBrowserManager.swift @@ -5,10 +5,9 @@ // Created by keni on 9/30/25. // - -import CoreServices import AppKit import Combine +import CoreServices class DefaultBrowserManager: ObservableObject { static let shared = DefaultBrowserManager() @@ -38,7 +37,8 @@ class DefaultBrowserManager: ObservableObject { static func checkIsDefault() -> Bool { guard let testURL = URL(string: "http://example.com"), let appURL = NSWorkspace.shared.urlForApplication(toOpen: testURL), - let appBundle = Bundle(url: appURL) else { + let appBundle = Bundle(url: appURL) + else { return false } diff --git a/ora/Services/KeychainService.swift b/ora/Services/KeychainService.swift new file mode 100644 index 00000000..aa0c4606 --- /dev/null +++ b/ora/Services/KeychainService.swift @@ -0,0 +1,112 @@ +import Foundation +import Security + +// MARK: - Keychain Service + +class KeychainService { + static let shared = KeychainService() + + private init() {} + + // MARK: - Store Data + + func store(_ data: Data, forKey key: String) -> Bool { + // Delete any existing item first + _ = delete(key: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: "com.orabrowser.app.keychain", // Fixed service identifier + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + func store(_ string: String, forKey key: String) -> Bool { + guard let data = string.data(using: .utf8) else { return false } + return store(data, forKey: key) + } + + // MARK: - Retrieve Data + + func retrieve(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: "com.orabrowser.app.keychain", // Fixed service identifier + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + guard status == errSecSuccess else { return nil } + return dataTypeRef as? Data + } + + func retrieveString(key: String) -> String? { + guard let data = retrieve(key: key) else { return nil } + return String(data: data, encoding: .utf8) + } + + // MARK: - Delete Data + + func delete(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: "com.orabrowser.app.keychain" // Fixed service identifier + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + // MARK: - Check if Key Exists + + func exists(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: "com.orabrowser.app.keychain", // Fixed service identifier + kSecReturnData as String: kCFBooleanFalse!, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } +} + +// MARK: - API Key Storage Helper + +extension KeychainService { + // MARK: - OpenAI + + func storeOpenAIKey(_ key: String) -> Bool { + return store(key, forKey: "openai_api_key") + } + + func getOpenAIKey() -> String? { + return retrieveString(key: "openai_api_key") + } + + func deleteOpenAIKey() -> Bool { + return delete(key: "openai_api_key") + } + + func hasOpenAIKey() -> Bool { + return exists(key: "openai_api_key") && !(getOpenAIKey()?.isEmpty ?? true) + } + + // MARK: - Future providers can be added here + + // Example: + // func storeClaudeKey(_ key: String) -> Bool { ... } + // func getClaudeKey() -> String? { ... } +} diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 4ba09c32..431d5df4 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -93,6 +93,7 @@ class TabManager: ObservableObject { tab.container = toContainer try? modelContext.save() } + private func initializeActiveContainerAndTab() { // Ensure containers are fetched let containers = fetchContainers() @@ -116,7 +117,6 @@ class TabManager: ObservableObject { @discardableResult func createContainer(name: String = "Default", emoji: String = "•") -> TabContainer { - let newContainer = TabContainer(name: name, emoji: emoji) modelContext.insert(newContainer) activeContainer = newContainer @@ -354,7 +354,6 @@ class TabManager: ObservableObject { } } - func selectTabAtIndex(_ index: Int) { guard let container = activeContainer else { return } diff --git a/ora/UI/ChatPanel.swift b/ora/UI/ChatPanel.swift new file mode 100644 index 00000000..eb230f24 --- /dev/null +++ b/ora/UI/ChatPanel.swift @@ -0,0 +1,621 @@ +import SwiftData +import SwiftUI + +// MARK: - Chat Message Model + +struct ChatMessage: Identifiable, Codable { + let id: UUID + let content: String + let isUser: Bool + let timestamp: Date + + init(content: String, isUser: Bool) { + self.id = UUID() + self.content = content + self.isUser = isUser + self.timestamp = Date() + } + + init(id: UUID, content: String, isUser: Bool, timestamp: Date) { + self.id = id + self.content = content + self.isUser = isUser + self.timestamp = timestamp + } +} + +// MARK: - Available AI Models + +enum AIModel: String, CaseIterable, Identifiable { + case gpt4 = "GPT-4" + case gpt4Turbo = "GPT-4 Turbo" + case claude3 = "Claude 3" + case gemini = "Gemini Pro" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .gpt4: return "GPT-4" + case .gpt4Turbo: return "GPT-4 Turbo" + case .claude3: return "Claude 3" + case .gemini: return "Gemini Pro" + } + } +} + +// MARK: - Chat Panel View + +struct ChatPanel: View { + @Environment(\.theme) private var theme + @EnvironmentObject var tabManager: TabManager + @StateObject private var providerManager = AIProviderManager.shared + + @State private var messages: [ChatMessage] = [] + @State private var inputText: String = "" + @State private var selectedModel: AIModel = .gpt4 + @State private var isLoading: Bool = false + @State private var pageContent: String = "" + @State private var errorMessage: String? + @State private var hasExtractedContent: Bool = false + @State private var streamingMessageId: UUID? + @State private var streamingContent: String = "" + + @FocusState private var isInputFocused: Bool + + private let chatPanelCornerRadius: CGFloat = { + if #available(macOS 26, *) { + return 8 + } else { + return 6 + } + }() + + var body: some View { + let clipShape = ConditionallyConcentricRectangle(cornerRadius: chatPanelCornerRadius) + + VStack(alignment: .leading, spacing: 0) { + // Header with model selector and controls + headerSection + + Divider() + .background(theme.border.opacity(0.3)) + + // Messages area + messagesSection + + // Input section + inputSection + } + .clipShape(clipShape) + .padding(6) + .onAppear { + // Don't extract content on appear - wait for first message + } + } + + // MARK: - Header Section + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Chat") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(theme.foreground) + + Spacer() + + Button(action: startNewChat) { + Image(systemName: "plus") + .foregroundColor(theme.foreground.opacity(0.6)) + .font(.system(size: 14)) + } + .buttonStyle(PlainButtonStyle()) + .help("Start new chat") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + } + + // MARK: - Messages Section + + private var messagesSection: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if messages.isEmpty { + emptyStateView + } else { + ForEach(messages) { message in + messageRow(message) + .id(message.id) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .move(edge: .top)) + )) + } + } + + if isLoading { + loadingIndicator + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + // Add some bottom padding for better scrolling + Spacer(minLength: 20) + } + .padding(.horizontal, 16) + .padding(.top, messages.isEmpty ? 0 : 16) + } + .onChange(of: messages.count) { + if let lastMessage = messages.last { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + } + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 40)) + .foregroundColor(theme.foreground.opacity(0.2)) + + VStack(spacing: 8) { + Text("Start a conversation") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(theme.foreground.opacity(0.8)) + + Text("Ask questions about this page or discuss its content with AI") + .font(.system(size: 14)) + .foregroundColor(theme.foreground.opacity(0.5)) + .multilineTextAlignment(.center) + .lineLimit(3) + } + + // Sample prompts + VStack(alignment: .leading, spacing: 8) { + samplePromptButton("Summarize this page") + samplePromptButton("Explain the key points") + samplePromptButton("Ask a question about this content") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + private func samplePromptButton(_ prompt: String) -> some View { + Button(action: { + inputText = prompt + sendMessage() + }) { + HStack { + Image(systemName: "sparkles") + .font(.system(size: 12)) + .foregroundColor(theme.accent.opacity(0.7)) + Text(prompt) + .font(.system(size: 13)) + .foregroundColor(theme.foreground.opacity(0.7)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(theme.mutedBackground.opacity(0.5)) + .stroke(theme.border.opacity(0.3), lineWidth: 0.5) + ) + } + .buttonStyle(PlainButtonStyle()) + .onHover { _ in + // Add subtle hover effect + } + } + + private func messageRow(_ message: ChatMessage) -> some View { + HStack(alignment: .top, spacing: 0) { + if message.isUser { + Spacer(minLength: 40) + } + + VStack(alignment: message.isUser ? .trailing : .leading, spacing: 6) { + HStack(spacing: 6) { + if !message.isUser { + providerLogo(for: selectedModel) + .frame(width: 14, height: 14) + } + + Text(message.isUser ? "You" : selectedModel.displayName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(theme.foreground.opacity(0.7)) + + if message.isUser { + Image(systemName: "person.circle.fill") + .font(.system(size: 11)) + .foregroundColor(theme.accent.opacity(0.8)) + } + } + + Text(message.content) + .font(.system(size: 14)) + .foregroundColor(theme.foreground) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(message.isUser ? theme.accent.opacity(0.08) : theme.mutedBackground) + .stroke( + message.isUser ? theme.accent.opacity(0.2) : theme.border.opacity(0.3), + lineWidth: 0.5 + ) + ) + .frame(maxWidth: .infinity, alignment: message.isUser ? .trailing : .leading) + } + + if !message.isUser { + Spacer(minLength: 40) + } + } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: message.id) + } + + private var loadingIndicator: some View { + HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + providerLogo(for: selectedModel) + .frame(width: 14, height: 14) + + Text(selectedModel.displayName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(theme.foreground.opacity(0.7)) + } + + HStack(spacing: 6) { + ForEach(0 ..< 3) { index in + Circle() + .fill(theme.accent.opacity(0.6)) + .frame(width: 4, height: 4) + .scaleEffect(isLoading ? 1.2 : 0.8) + .animation( + Animation.easeInOut(duration: 0.8) + .repeatForever() + .delay(Double(index) * 0.2), + value: isLoading + ) + } + + Text("thinking...") + .font(.system(size: 12)) + .foregroundColor(theme.foreground.opacity(0.5)) + .italic() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(theme.mutedBackground) + .stroke(theme.border.opacity(0.3), lineWidth: 0.5) + ) + } + + Spacer(minLength: 40) + } + } + + // MARK: - Input Section + + private var inputSection: some View { + VStack(spacing: 0) { + Divider() + .background(theme.border.opacity(0.3)) + + // Model selector + HStack { + providerLogo(for: selectedModel) + .frame(width: 16, height: 16) + + Menu { + ForEach(AIModel.allCases) { model in + Button(action: { selectedModel = model }) { + HStack { + providerLogo(for: model) + .frame(width: 14, height: 14) + Text(model.displayName) + } + } + } + } label: { + Text(selectedModel.displayName) + .font(.system(size: 13)) + .foregroundColor(theme.foreground) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(theme.mutedBackground.opacity(0.5)) + ) + } + .buttonStyle(PlainButtonStyle()) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + HStack(alignment: .center, spacing: 10) { + TextField("Ask about this page...", text: $inputText, axis: .vertical) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 14)) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(theme.mutedBackground.opacity(0.7)) + .stroke( + isInputFocused ? theme.accent.opacity(0.5) : theme.border.opacity(0.3), + lineWidth: isInputFocused ? 1.0 : 0.5 + ) + ) + .focused($isInputFocused) + .onSubmit { + sendMessage() + } + .animation(.easeInOut(duration: 0.2), value: isInputFocused) + + Button(action: sendMessage) { + Image(systemName: canSendMessage ? "arrow.up.circle.fill" : "arrow.up.circle") + .font(.system(size: 20)) + .foregroundColor(canSendMessage ? theme.accent : theme.foreground.opacity(0.3)) + .animation(.easeInOut(duration: 0.2), value: canSendMessage) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!canSendMessage) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + + private var canSendMessage: Bool { + !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading + } + + // MARK: - Actions + + private func sendMessage() { + let trimmedText = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty, !isLoading else { return } + + let userMessage = ChatMessage(content: trimmedText, isUser: true) + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + messages.append(userMessage) + } + + inputText = "" + + // Keep input focused for continuous typing + Task { + try await Task.sleep(nanoseconds: 100_000_000) // Small delay to ensure smooth animation + isInputFocused = true + } + + // Extract page content only for the first message in a conversation + let shouldIncludePageContent = messages.count == 1 && !hasExtractedContent + + if shouldIncludePageContent { + extractPageContentAndSend(message: trimmedText) + } else { + // For follow-up messages, don't include page content (AI already has context) + sendToAI(message: trimmedText, includePageContent: false) + } + } + + private func extractPageContentAndSend(message: String) { + // First extract page content, then send message + extractPageContent { success in + Task { @MainActor in + if success { + hasExtractedContent = true + // Debug: Print page content for first message only + print("🔍 DEBUG: Page content extracted for first message:") + print("📄 Content length: \(pageContent.count) characters") + if !pageContent.isEmpty { + print("📄 Content preview: \(String(pageContent.prefix(500)))") + if pageContent.count > 500 { + print("... (truncated, full content is \(pageContent.count) characters)") + } + } + print("💬 User message: \(message)") + print("---") + } + + sendToAI(message: message, includePageContent: success) + } + } + } + + private func sendToAI(message: String, includePageContent: Bool) { + // Create a placeholder message for streaming (no loading indicator needed) + let streamingMessage = ChatMessage(content: "", isUser: false) + streamingMessageId = streamingMessage.id + streamingContent = "" + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + messages.append(streamingMessage) + } + + Task { + do { + let contentToSend = (includePageContent && !pageContent.isEmpty) ? pageContent : nil + + try await providerManager.sendMessageStreaming( + message, + pageContent: contentToSend, + model: selectedModel + ) { chunk in + // Update streaming content on main actor + streamingContent += chunk + + // Find and update the streaming message + if let streamingId = streamingMessageId, + let index = messages.firstIndex(where: { $0.id == streamingId }) + { + messages[index] = ChatMessage( + id: streamingId, + content: streamingContent, + isUser: false, + timestamp: messages[index].timestamp + ) + } + } + + // Streaming complete - clean up + streamingMessageId = nil + streamingContent = "" + + } catch { + // Remove the streaming message and show error + if let streamingId = streamingMessageId, + let index = messages.firstIndex(where: { $0.id == streamingId }) + { + messages.remove(at: index) + } + + errorMessage = error.localizedDescription + + // Show error as a system message + let errorResponse = ChatMessage( + content: "Error: \(error.localizedDescription)", + isUser: false + ) + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + messages.append(errorResponse) + } + + streamingMessageId = nil + streamingContent = "" + } + } + } + + private func startNewChat() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + messages.removeAll() + hasExtractedContent = false // Reset for new conversation + pageContent = "" // Clear old content + streamingMessageId = nil // Clear streaming state + streamingContent = "" + } + } + + private func extractPageContent(completion: @escaping (Bool) -> Void = { _ in }) { + guard let activeTab = tabManager.activeTab else { + pageContent = "" + completion(false) + return + } + + let script = """ + (function() { + // Remove script and style elements + var scripts = document.querySelectorAll('script, style, noscript'); + scripts.forEach(function(el) { el.remove(); }); + + // Get the main content + var content = ''; + var title = document.title || ''; + var metaDescription = ''; + var descriptionMeta = document.querySelector('meta[name="description"]'); + if (descriptionMeta) { + metaDescription = descriptionMeta.getAttribute('content') || ''; + } + + // Try to find main content areas + var mainContent = document.querySelector('main, article, [role="main"], .main-content, #main-content, .content'); + if (mainContent) { + content = mainContent.innerText || mainContent.textContent || ''; + } else { + // Fallback to body text + content = document.body.innerText || document.body.textContent || ''; + } + + // Clean up the content + content = content.replace(/\\s+/g, ' ').trim(); + + return { + title: title, + description: metaDescription, + content: content.substring(0, 8000), // Limit content length + url: window.location.href + }; + })(); + """ + + activeTab.webView.evaluateJavaScript(script) { result, error in + DispatchQueue.main.async { + if let error { + print("Error extracting page content: \(error)") + pageContent = "" + completion(false) + return + } + + if let result = result as? [String: Any] { + let title = result["title"] as? String ?? "" + let description = result["description"] as? String ?? "" + let content = result["content"] as? String ?? "" + let url = result["url"] as? String ?? "" + + var extractedContent = "" + if !title.isEmpty { + extractedContent += "Title: \(title)\n\n" + } + if !description.isEmpty { + extractedContent += "Description: \(description)\n\n" + } + if !url.isEmpty { + extractedContent += "URL: \(url)\n\n" + } + if !content.isEmpty { + extractedContent += "Content:\n\(content)" + } + + pageContent = extractedContent + completion(!extractedContent.isEmpty) + } else { + pageContent = "" + completion(false) + } + } + } + } + + // MARK: - Provider Logo Helper + + @ViewBuilder + private func providerLogo(for model: AIModel) -> some View { + switch model { + case .gpt4, .gpt4Turbo: + Image("openai-capsule-logo") + .resizable() + .aspectRatio(contentMode: .fit) + case .claude3: + Image(systemName: "brain.head.profile") + .foregroundColor(.purple) + case .gemini: + Image(systemName: "diamond") + .foregroundColor(.blue) + } + } +} + +#Preview { + ChatPanel() + .withTheme() +} diff --git a/ora/oraApp.swift b/ora/oraApp.swift index 2197ae88..543f3045 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -9,6 +9,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSWindow.allowsAutomaticWindowTabbing = false AppearanceManager.shared.updateAppearance() } + func application(_ application: NSApplication, open urls: [URL]) { handleIncomingURLs(urls) } @@ -22,6 +23,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } return WindowFactory.makeMainWindow(rootView: OraRoot()) } + func handleIncomingURLs(_ urls: [URL]) { let window = getWindow()! for url in urls { @@ -59,11 +61,11 @@ class AppState: ObservableObject { @main struct OraApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - + // Shared model container that uses the same configuration as the main browser private let sharedModelContainer: ModelContainer? = - try? ModelConfiguration.createOraContainer(isPrivate: false) - + try? ModelConfiguration.createOraContainer(isPrivate: false) + var body: some Scene { WindowGroup(id: "normal") { OraRoot() @@ -74,7 +76,7 @@ struct OraApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) .handlesExternalEvents(matching: []) - + WindowGroup("Private", id: "private") { OraRoot(isPrivate: true) .frame(minWidth: 500, minHeight: 360) @@ -84,7 +86,7 @@ struct OraApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) .handlesExternalEvents(matching: []) - + Settings { if let sharedModelContainer { SettingsContentView() diff --git a/project.yml b/project.yml index ec5a8280..ca9f9126 100644 --- a/project.yml +++ b/project.yml @@ -6,6 +6,9 @@ packages: Sparkle: url: https://github.com/sparkle-project/Sparkle from: 2.6.0 + AIProxy: + url: https://github.com/lzell/AIProxySwift + exactVersion: 0.129.0 targets: ora: @@ -22,6 +25,7 @@ targets: optional: true dependencies: - package: Sparkle + - package: AIProxy preBuildScripts: - name: "Copy Icon Bundle" @@ -76,7 +80,7 @@ targets: - NSUserActivityTypeBrowsingWeb LSMinimumSystemVersion: "13.0" LSApplicationCategoryType: public.app-category.web-browser - + entitlements: path: ora/ora.entitlements properties: