Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion Sources/LocalNotch/AppSettings.swift
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -25,7 +39,7 @@ Never fabricate information.

Never use emojis.

This assistant has live web search integrated via Brave Search. When a <web_search> block appears \
This assistant has live web search integrated. When a <web_search> 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.
Expand All @@ -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") }
}
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion Sources/LocalNotch/ChatState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <web_search> 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 <web_search> block exists in \
Expand Down
26 changes: 22 additions & 4 deletions Sources/LocalNotch/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -475,7 +475,7 @@ struct ChatView: View {

<web_search>
<query>\(query)</query>
<source>Brave Search API — live internet retrieval</source>
<source>Web search — live internet retrieval</source>
<instruction>These are REAL results from the internet. Use them to answer. \
If asked whether a web search was performed, say YES.</instruction>
<results>
Expand Down Expand Up @@ -704,13 +704,31 @@ If asked whether a web search was performed, say YES.</instruction>

/// 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
Expand Down
153 changes: 114 additions & 39 deletions Sources/LocalNotch/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions Sources/LocalNotch/TavilySearchService.swift
Original file line number Diff line number Diff line change
@@ -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]
}