diff --git a/Sources/LocalNotch/AppSettings.swift b/Sources/LocalNotch/AppSettings.swift index d1fd9c0..1a3670a 100644 --- a/Sources/LocalNotch/AppSettings.swift +++ b/Sources/LocalNotch/AppSettings.swift @@ -1,6 +1,20 @@ import Foundation import AppKit +enum SearchProvider: String, CaseIterable { + case brave = "brave" + case tavily = "tavily" + case auto = "auto" + + var label: String { + switch self { + case .brave: return "Brave" + case .tavily: return "Tavily" + case .auto: return "Auto" + } + } +} + @MainActor final class AppSettings: ObservableObject { static let shared = AppSettings() @@ -25,7 +39,7 @@ Never fabricate information. Never use emojis. -This assistant has live web search integrated via Brave Search. When a block appears \ +This assistant has live web search integrated. When a block appears \ in a message, those results are real data retrieved from the internet in real time — not simulated, \ not from your training data. Treat them as authoritative current information and use them to \ answer accurately. @@ -46,6 +60,12 @@ found, state that plainly and answer with what you know. Trust the query — do @Published var braveSearchAPIKey: String { didSet { UserDefaults.standard.set(braveSearchAPIKey, forKey: "braveSearchAPIKey") } } + @Published var tavilyAPIKey: String { + didSet { UserDefaults.standard.set(tavilyAPIKey, forKey: "tavilyAPIKey") } + } + @Published var searchProvider: SearchProvider { + didSet { UserDefaults.standard.set(searchProvider.rawValue, forKey: "searchProvider") } + } @Published var displayName: String { didSet { UserDefaults.standard.set(displayName, forKey: "displayName") } } @@ -64,6 +84,9 @@ found, state that plainly and answer with what you know. Trust the query — do textModelName = UserDefaults.standard.string(forKey: "textModelName") ?? "" visionModelName = UserDefaults.standard.string(forKey: "visionModelName") ?? "" braveSearchAPIKey = UserDefaults.standard.string(forKey: "braveSearchAPIKey") ?? "" + tavilyAPIKey = UserDefaults.standard.string(forKey: "tavilyAPIKey") ?? "" + let providerRaw = UserDefaults.standard.string(forKey: "searchProvider") ?? "brave" + searchProvider = SearchProvider(rawValue: providerRaw) ?? .brave displayName = UserDefaults.standard.string(forKey: "displayName") ?? "" systemPrompt = UserDefaults.standard.string(forKey: "systemPrompt") ?? AppSettings.defaultSystemPrompt onboardingComplete = UserDefaults.standard.bool(forKey: "onboardingComplete") diff --git a/Sources/LocalNotch/ChatState.swift b/Sources/LocalNotch/ChatState.swift index 6d02255..fa44a1c 100644 --- a/Sources/LocalNotch/ChatState.swift +++ b/Sources/LocalNotch/ChatState.swift @@ -31,7 +31,7 @@ class ChatState: ObservableObject { private func makeSystemMessage() -> OllamaMessage { let preamble = """ - CAPABILITY: This assistant has real-time web search via Brave Search API. \ + CAPABILITY: This assistant has real-time web search. \ When blocks appear in the conversation, those are REAL results \ retrieved from the internet moments ago — not simulated, not from training data. \ If the user asks "did you search the web?" and a block exists in \ diff --git a/Sources/LocalNotch/ChatView.swift b/Sources/LocalNotch/ChatView.swift index 50a2a79..aa30dfd 100644 --- a/Sources/LocalNotch/ChatView.swift +++ b/Sources/LocalNotch/ChatView.swift @@ -464,7 +464,7 @@ struct ChatView: View { state.isSearching = true state.lastSearchQuery = query // set immediately so badge shows during search } - let searchContext = await BraveSearchService.shared.search(query) + let searchContext = await performWebSearch(query) await MainActor.run { state.isSearching = false } guard !Task.isCancelled else { await MainActor.run { state.isLoading = false } @@ -475,7 +475,7 @@ struct ChatView: View { \(query) -Brave Search API — live internet retrieval +Web search — live internet retrieval These are REAL results from the internet. Use them to answer. \ If asked whether a web search was performed, say YES. @@ -704,13 +704,31 @@ If asked whether a web search was performed, say YES. /// Combined decision: returns a search query if any layer decides to search. private func decideSearchQuery(from text: String) async -> String? { - let hasKey = !AppSettings.shared.braveSearchAPIKey.trimmingCharacters(in: .whitespaces).isEmpty - guard hasKey else { return nil } + let hasBrave = !AppSettings.shared.braveSearchAPIKey.trimmingCharacters(in: .whitespaces).isEmpty + let hasTavily = !AppSettings.shared.tavilyAPIKey.trimmingCharacters(in: .whitespaces).isEmpty + guard hasBrave || hasTavily else { return nil } if let q = extractExplicitSearchQuery(from: text) { return q } if let q = detectCurrentInfoQuery(from: text) { return q } return await classifySearchNeed(from: text) } + /// Dispatches search to the configured provider, with fallback in auto mode. + private func performWebSearch(_ query: String) async -> String? { + let provider = AppSettings.shared.searchProvider + switch provider { + case .brave: + return await BraveSearchService.shared.search(query) + case .tavily: + return await TavilySearchService.shared.search(query) + case .auto: + // Try Brave first, fall back to Tavily + if let result = await BraveSearchService.shared.search(query) { + return result + } + return await TavilySearchService.shared.search(query) + } + } + private func captureScreen() { // Don't call CGPreflightScreenCaptureAccess() — on macOS 26 with ad-hoc signing // it gives false negatives even when permission is actually granted, causing the diff --git a/Sources/LocalNotch/SettingsView.swift b/Sources/LocalNotch/SettingsView.swift index b702c3b..7a28aec 100644 --- a/Sources/LocalNotch/SettingsView.swift +++ b/Sources/LocalNotch/SettingsView.swift @@ -382,72 +382,147 @@ struct ModelOptionRow: View { struct WebSearchSettingsView: View { @ObservedObject private var settings = AppSettings.shared - @State private var keyVisible = false + @State private var braveKeyVisible = false + @State private var tavilyKeyVisible = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { + // Provider picker VStack(alignment: .leading, spacing: 6) { - Text("Brave Search API key") + Text("Search provider") .font(.system(size: 11, weight: .medium)) .foregroundColor(.white.opacity(0.5)) - HStack { - Group { - if keyVisible { - TextField("Paste key here", text: $settings.braveSearchAPIKey) - } else { - SecureField("Paste key here", text: $settings.braveSearchAPIKey) + Picker("", selection: $settings.searchProvider) { + ForEach(SearchProvider.allCases, id: \.self) { provider in + Text(provider.label).tag(provider) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + + // Brave key field + if settings.searchProvider == .brave || settings.searchProvider == .auto { + VStack(alignment: .leading, spacing: 6) { + Text("Brave Search API key") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + + HStack { + Group { + if braveKeyVisible { + TextField("Paste key here", text: $settings.braveSearchAPIKey) + } else { + SecureField("Paste key here", text: $settings.braveSearchAPIKey) + } } + .textFieldStyle(.plain) + .font(.system(size: 12)) + .foregroundColor(.white) + .tint(.white) + + Button { braveKeyVisible.toggle() } label: { + Image(systemName: braveKeyVisible ? "eye.slash" : "eye") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + } + .buttonStyle(.plain) } - .textFieldStyle(.plain) - .font(.system(size: 12)) - .foregroundColor(.white) - .tint(.white) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .modifier(GlassPillModifier()) - Button { keyVisible.toggle() } label: { - Image(systemName: keyVisible ? "eye.slash" : "eye") - .font(.system(size: 12)) - .foregroundColor(.white.opacity(0.5)) + HStack(spacing: 4) { + Text("Get a Brave API key") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(red: 0.4, green: 0.7, blue: 1.0)) + Image(systemName: "arrow.up.right") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(red: 0.4, green: 0.7, blue: 1.0)) } - .buttonStyle(.plain) + .overlay(AppKitTapHandler { + NSWorkspace.shared.open(URL(string: "https://api.search.brave.com/register")!) + }) } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .modifier(GlassPillModifier()) } - VStack(alignment: .leading, spacing: 6) { - Text("Free tier: 1,000 queries/month. Requires a credit card on file with Brave.") - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.45)) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 4) { - Text("Get an API key") + // Tavily key field + if settings.searchProvider == .tavily || settings.searchProvider == .auto { + VStack(alignment: .leading, spacing: 6) { + Text("Tavily API key") .font(.system(size: 11, weight: .medium)) - .foregroundColor(Color(red: 0.4, green: 0.7, blue: 1.0)) - Image(systemName: "arrow.up.right") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(Color(red: 0.4, green: 0.7, blue: 1.0)) + .foregroundColor(.white.opacity(0.5)) + + HStack { + Group { + if tavilyKeyVisible { + TextField("Paste key here", text: $settings.tavilyAPIKey) + } else { + SecureField("Paste key here", text: $settings.tavilyAPIKey) + } + } + .textFieldStyle(.plain) + .font(.system(size: 12)) + .foregroundColor(.white) + .tint(.white) + + Button { tavilyKeyVisible.toggle() } label: { + Image(systemName: tavilyKeyVisible ? "eye.slash" : "eye") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .modifier(GlassPillModifier()) + + HStack(spacing: 4) { + Text("Get a Tavily API key") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(red: 0.4, green: 0.7, blue: 1.0)) + Image(systemName: "arrow.up.right") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(red: 0.4, green: 0.7, blue: 1.0)) + } + .overlay(AppKitTapHandler { + NSWorkspace.shared.open(URL(string: "https://app.tavily.com")!) + }) } - .overlay(AppKitTapHandler { - NSWorkspace.shared.open(URL(string: "https://api.search.brave.com/register")!) - }) } - Text(settings.braveSearchAPIKey.trimmingCharacters(in: .whitespaces).isEmpty - ? "No key set — web search disabled." - : "Web search enabled.") + Text(webSearchStatusText) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.35)) - .animation(.easeInOut(duration: 0.2), value: settings.braveSearchAPIKey.isEmpty) + .animation(.easeInOut(duration: 0.2), value: settings.searchProvider) } .padding(.horizontal, 14) .padding(.bottom, 14) } .scrollIndicators(.hidden) } + + private var webSearchStatusText: String { + switch settings.searchProvider { + case .brave: + return settings.braveSearchAPIKey.trimmingCharacters(in: .whitespaces).isEmpty + ? "No Brave key set — web search disabled." + : "Web search enabled via Brave." + case .tavily: + return settings.tavilyAPIKey.trimmingCharacters(in: .whitespaces).isEmpty + ? "No Tavily key set — web search disabled." + : "Web search enabled via Tavily." + case .auto: + let hasBrave = !settings.braveSearchAPIKey.trimmingCharacters(in: .whitespaces).isEmpty + let hasTavily = !settings.tavilyAPIKey.trimmingCharacters(in: .whitespaces).isEmpty + if hasBrave || hasTavily { + return "Auto mode — will try available providers with fallback." + } + return "No API keys set — web search disabled." + } + } } // MARK: - Personality Section diff --git a/Sources/LocalNotch/TavilySearchService.swift b/Sources/LocalNotch/TavilySearchService.swift new file mode 100644 index 0000000..bdb877c --- /dev/null +++ b/Sources/LocalNotch/TavilySearchService.swift @@ -0,0 +1,48 @@ +import Foundation + +final class TavilySearchService: Sendable { + static let shared = TavilySearchService() + + func search(_ query: String) async -> String? { + let apiKey = (UserDefaults.standard.string(forKey: "tavilyAPIKey") ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !apiKey.isEmpty else { return nil } + guard let url = URL(string: "https://api.tavily.com/search") else { return nil } + + let body: [String: Any] = [ + "api_key": apiKey, + "query": query, + "max_results": 5, + "search_depth": "basic" + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = jsonData + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } + let parsed = try JSONDecoder().decode(TavilyResponse.self, from: data) + guard !parsed.results.isEmpty else { return nil } + return parsed.results.prefix(5).enumerated().map { i, r in + let snippet = r.content + return "[\(i + 1)] \(r.title)\nURL: \(r.url)\n\(snippet)" + }.joined(separator: "\n\n") + } catch { + return nil + } + } +} + +private struct TavilyResponse: Decodable { + struct Result: Decodable { + let title: String + let url: String + let content: String + } + let results: [Result] +}