diff --git a/AISight/AISight/App/AppConfig.swift b/AISight/AISight/App/AppConfig.swift index bb88dbe..480c314 100644 --- a/AISight/AISight/App/AppConfig.swift +++ b/AISight/AISight/App/AppConfig.swift @@ -1,5 +1,10 @@ import Foundation +enum SearchProvider: String, CaseIterable, Sendable { + case searxng + case tavily +} + enum AppConfig: Sendable { // Default SearXNG instance — override in Settings or set your own // For local dev: docker-compose up in /searxng folder → http://localhost:8888 @@ -15,6 +20,45 @@ enum AppConfig: Sendable { return stored } + // MARK: - Search Provider Selection + + static var effectiveSearchProvider: SearchProvider { + guard let raw = UserDefaults.standard.string(forKey: "search_provider"), + let provider = SearchProvider(rawValue: raw) else { + return .searxng + } + // Only allow Tavily if an API key is configured + if provider == .tavily && tavilyAPIKey.isEmpty { + return .searxng + } + return provider + } + + static var tavilyAPIKey: String { + get { + // Migrate from UserDefaults to Keychain if needed + if let legacy = UserDefaults.standard.string(forKey: "tavily_api_key"), + !legacy.isEmpty { + KeychainHelper.save(key: "tavily_api_key", value: legacy) + UserDefaults.standard.removeObject(forKey: "tavily_api_key") + return legacy.trimmingCharacters(in: .whitespacesAndNewlines) + } + return KeychainHelper.load(key: "tavily_api_key")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + KeychainHelper.delete(key: "tavily_api_key") + } else { + KeychainHelper.save(key: "tavily_api_key", value: trimmed) + } + } + } + + // Tavily search parameters + static let tavilySearchDepth = "basic" + static let tavilyMaxResults = 5 + // SearXNG search parameters static let searchEngines = "google,bing,brave" static let searchCategories = "general" diff --git a/AISight/AISight/Core/AI/DeepSearchPipeline.swift b/AISight/AISight/Core/AI/DeepSearchPipeline.swift index 12ddf92..e6bbba8 100644 --- a/AISight/AISight/Core/AI/DeepSearchPipeline.swift +++ b/AISight/AISight/Core/AI/DeepSearchPipeline.swift @@ -41,7 +41,7 @@ final class DeepSearchPipeline { /// Execute the full deep search pipeline: reformulate → search → research → synthesize. /// Returns the SearchOutput for source display, or nil on failure. - func execute(query: String, language: String, searchService: SearXNGService) async -> SearchOutput? { + func execute(query: String, language: String, searchService: any SearchService) async -> SearchOutput? { streamingText = "" isGenerating = true error = nil diff --git a/AISight/AISight/Core/Search/KeychainHelper.swift b/AISight/AISight/Core/Search/KeychainHelper.swift new file mode 100644 index 0000000..db62d95 --- /dev/null +++ b/AISight/AISight/Core/Search/KeychainHelper.swift @@ -0,0 +1,59 @@ +import Foundation +import Security + +/// Minimal Keychain wrapper for storing sensitive strings (e.g. API keys). +/// Uses Security.framework — no external dependencies required. +enum KeychainHelper { + + private static let service = "com.aisight.app" + + static func save(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + + // Delete any existing item first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + SecItemAdd(addQuery as CFDictionary, nil) + } + + static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let string = String(data: data, encoding: .utf8) else { + return nil + } + return string + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/AISight/AISight/Core/Search/SearchError.swift b/AISight/AISight/Core/Search/SearchError.swift index 125a3f5..c9ff950 100644 --- a/AISight/AISight/Core/Search/SearchError.swift +++ b/AISight/AISight/Core/Search/SearchError.swift @@ -2,6 +2,7 @@ import Foundation enum SearchError: LocalizedError { case serverUnavailable + case authenticationFailed case timeout case invalidResponse case noResults @@ -9,7 +10,9 @@ enum SearchError: LocalizedError { var errorDescription: String? { switch self { case .serverUnavailable: - return String(localized: "The search server is currently unavailable. Please check your SearXNG instance URL in Settings.") + return String(localized: "The search service is unavailable. Check your settings.") + case .authenticationFailed: + return String(localized: "Invalid API key. Update it in Settings.") case .timeout: return String(localized: "The search request timed out. Please try again.") case .invalidResponse: diff --git a/AISight/AISight/Core/Search/SearchService.swift b/AISight/AISight/Core/Search/SearchService.swift index 4e5eda7..da29c9e 100644 --- a/AISight/AISight/Core/Search/SearchService.swift +++ b/AISight/AISight/Core/Search/SearchService.swift @@ -18,5 +18,6 @@ struct SearchOutput: Sendable { protocol SearchService: Sendable { func search(query: String, language: String) async throws -> SearchOutput + func multiSearch(queries: [String], language: String) async throws -> SearchOutput func checkAvailability() async -> Bool } diff --git a/AISight/AISight/Core/Search/TavilyService.swift b/AISight/AISight/Core/Search/TavilyService.swift new file mode 100644 index 0000000..80f5d5f --- /dev/null +++ b/AISight/AISight/Core/Search/TavilyService.swift @@ -0,0 +1,218 @@ +import Foundation + +/// Tavily search API client conforming to SearchService. +/// Calls POST https://api.tavily.com/search via URLSession and maps +/// Tavily response fields to SearXNGResult for full pipeline compatibility. +final class TavilyService: SearchService, Sendable { + + private static let apiURL = "https://api.tavily.com/search" + + // Tavily does not support a language filter in the basic search API, + // so the language parameter is accepted for protocol conformance but not forwarded. + func search(query: String, language: String) async throws -> SearchOutput { + let apiKey = AppConfig.tavilyAPIKey + guard !apiKey.isEmpty else { + throw SearchError.serverUnavailable + } + + guard let url = URL(string: Self.apiURL) else { + throw SearchError.invalidResponse + } + + let requestBody = TavilySearchRequest( + apiKey: apiKey, + query: query, + searchDepth: AppConfig.tavilySearchDepth, + maxResults: AppConfig.tavilyMaxResults, + includeAnswer: false + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = AppConfig.searchTimeoutSeconds + + do { + request.httpBody = try JSONEncoder().encode(requestBody) + } catch { + throw SearchError.invalidResponse + } + + let data: Data + let response: URLResponse + + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError where error.code == .timedOut { + throw SearchError.timeout + } catch let error as URLError where error.code == .cannotConnectToHost + || error.code == .notConnectedToInternet + || error.code == .cannotFindHost { + throw SearchError.serverUnavailable + } catch { + throw SearchError.serverUnavailable + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw SearchError.serverUnavailable + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw SearchError.authenticationFailed + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw SearchError.serverUnavailable + } + + let tavilyResponse: TavilySearchResponse + do { + tavilyResponse = try JSONDecoder().decode(TavilySearchResponse.self, from: data) + } catch { + throw SearchError.invalidResponse + } + + let results = tavilyResponse.results.map { item in + SearXNGResult( + url: item.url, + title: item.title, + content: item.content, + engine: "tavily", + score: item.score, + engines: ["tavily"], + positions: nil, + category: nil, + publishedDate: nil + ) + } + + guard !results.isEmpty else { + throw SearchError.noResults + } + + return SearchOutput( + results: results, + queryGroups: [SearchQueryGroup(query: query, results: results)], + directAnswers: [], + suggestions: [], + infoboxes: [] + ) + } + + func multiSearch(queries: [String], language: String) async throws -> SearchOutput { + guard !queries.isEmpty else { throw SearchError.noResults } + + if queries.count == 1 { + return try await search(query: queries[0], language: language) + } + + var allResults: [SearXNGResult] = [] + var queryGroups: [SearchQueryGroup] = [] + + await withTaskGroup(of: (String, SearchOutput?).self) { group in + for query in queries { + group.addTask { + let output = try? await self.search(query: query, language: language) + return (query, output) + } + } + + for await (query, output) in group { + if let output { + queryGroups.append(SearchQueryGroup(query: query, results: output.results)) + allResults.append(contentsOf: output.results) + } + } + } + + // Deduplicate by URL, keeping best score + var bestByURL: [String: SearXNGResult] = [:] + for result in allResults { + let key = result.url.lowercased() + if let existing = bestByURL[key] { + if (result.score ?? 0) > (existing.score ?? 0) { + bestByURL[key] = result + } + } else { + bestByURL[key] = result + } + } + + let merged = Array(bestByURL.values) + .sorted { ($0.score ?? 0) > ($1.score ?? 0) } + .prefix(AppConfig.maxResults) + + guard !merged.isEmpty else { + throw SearchError.noResults + } + + return SearchOutput( + results: Array(merged), + queryGroups: queryGroups, + directAnswers: [], + suggestions: [], + infoboxes: [] + ) + } + + func checkAvailability() async -> Bool { + let apiKey = AppConfig.tavilyAPIKey + guard !apiKey.isEmpty, apiKey.hasPrefix("tvly-") else { return false } + + guard let url = URL(string: Self.apiURL) else { return false } + + // Intentionally uses "basic" depth and 1 result to minimize credit cost, + // regardless of the user's configured tavilySearchDepth. + // Note: this performs a live search and consumes 1 Tavily credit. + let requestBody = TavilySearchRequest( + apiKey: apiKey, + query: "test", + searchDepth: "basic", + maxResults: 1, + includeAnswer: false + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = AppConfig.searchTimeoutSeconds + + do { + request.httpBody = try JSONEncoder().encode(requestBody) + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return false } + return (200...299).contains(httpResponse.statusCode) + } catch { + return false + } + } +} + +// MARK: - Tavily API Models + +private struct TavilySearchRequest: Encodable { + let apiKey: String + let query: String + let searchDepth: String + let maxResults: Int + let includeAnswer: Bool + + enum CodingKeys: String, CodingKey { + case apiKey = "api_key" + case query + case searchDepth = "search_depth" + case maxResults = "max_results" + case includeAnswer = "include_answer" + } +} + +private struct TavilySearchResponse: Decodable { + let results: [TavilyResult] +} + +private struct TavilyResult: Decodable { + let url: String + let title: String + let content: String + let score: Double? +} diff --git a/AISight/AISight/Features/Search/SearchViewModel.swift b/AISight/AISight/Features/Search/SearchViewModel.swift index 9cf1f86..d8275cf 100644 --- a/AISight/AISight/Features/Search/SearchViewModel.swift +++ b/AISight/AISight/Features/Search/SearchViewModel.swift @@ -19,7 +19,6 @@ final class SearchViewModel { private(set) var answerSession: AnswerSession private(set) var deepSearchPipeline: DeepSearchPipeline - private let searchService: SearXNGService private let reformulator: QueryReformulator private var currentTask: Task? private var lastSearchOutput: SearchOutput? @@ -55,13 +54,23 @@ final class SearchViewModel { return step.description } - init(searchService: SearXNGService = SearXNGService()) { - self.searchService = searchService + init() { self.answerSession = AnswerSession() self.deepSearchPipeline = DeepSearchPipeline() self.reformulator = QueryReformulator() } + /// Builds the correct search service based on the current provider setting. + /// Called at search time so that provider changes in Settings take effect immediately. + private static func makeSearchService() -> any SearchService { + switch AppConfig.effectiveSearchProvider { + case .searxng: + return SearXNGService() + case .tavily: + return TavilyService() + } + } + var language: String { UserDefaults.standard.string(forKey: "search_language") ?? AppConfig.defaultSearchLanguage } @@ -92,7 +101,7 @@ final class SearchViewModel { let searchOutput = await deepSearchPipeline.execute( query: trimmedQuery, language: language, - searchService: searchService + searchService: Self.makeSearchService() ) guard !Task.isCancelled else { return } @@ -139,9 +148,10 @@ final class SearchViewModel { guard !Task.isCancelled else { return } // 2. Search with all reformulated queries in parallel, merge results + let activeService = Self.makeSearchService() var searchOutput: SearchOutput do { - searchOutput = try await searchService.multiSearch(queries: searchQueries, language: language) + searchOutput = try await activeService.multiSearch(queries: searchQueries, language: language) guard !Task.isCancelled else { return } self.sources = searchOutput.results self.queryGroups = searchOutput.queryGroups @@ -253,13 +263,15 @@ final class SearchViewModel { private func userFacingMessage(for error: SearchError) -> String { switch error { case .serverUnavailable: - return String(localized: "Search server is unavailable. Check your connection or update the server URL in Settings.") + return String(localized: "The search service is unavailable. Check your connection or settings.") + case .authenticationFailed: + return String(localized: "Invalid API key. Update it in Settings.") case .timeout: return String(localized: "Search took too long. The server may be overloaded \u{2014} try again in a moment.") case .noResults: return String(localized: "No sources found for this query. Try rephrasing.") case .invalidResponse: - return String(localized: "Search server is unavailable. Check your connection or update the server URL in Settings.") + return String(localized: "The search service is unavailable. Check your connection or settings.") } } diff --git a/AISight/AISight/Features/Settings/SettingsView.swift b/AISight/AISight/Features/Settings/SettingsView.swift index 84bb13b..08f2a23 100644 --- a/AISight/AISight/Features/Settings/SettingsView.swift +++ b/AISight/AISight/Features/Settings/SettingsView.swift @@ -6,6 +6,8 @@ struct SettingsView: View { @Environment(AppState.self) private var appState @Environment(StoreManager.self) private var storeManager + @State private var selectedProvider: SearchProvider = UserDefaults.standard.string(forKey: "search_provider").flatMap(SearchProvider.init(rawValue:)) ?? .searxng + @State private var tavilyAPIKey: String = AppConfig.tavilyAPIKey @State private var serverURL: String = AppConfig.effectiveSearXNGBaseURL @State private var isTesting = false @State private var testResult: TestResult? @@ -59,66 +61,134 @@ struct SettingsView: View { ProSettingsSection(showPaywall: $showPaywall) Section { - TextField("SearXNG Server URL", text: $serverURL, prompt: Text("https://search.yourdomain.com").foregroundStyle(.secondary)) - .autocorrectionDisabled() - #if os(iOS) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - #endif - .onSubmit { + Picker("Search Provider", selection: $selectedProvider) { + Text("SearXNG").tag(SearchProvider.searxng) + Text("Tavily").tag(SearchProvider.tavily) + } + .onChange(of: selectedProvider) { _, newValue in + UserDefaults.standard.set(newValue.rawValue, forKey: "search_provider") + testResult = nil + } + } header: { + Text("Search Provider") + } + + if selectedProvider == .tavily { + Section { + SecureField("Tavily API Key", text: $tavilyAPIKey, prompt: Text("tvly-...").foregroundStyle(.secondary)) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .onChange(of: tavilyAPIKey) { _, newValue in + AppConfig.tavilyAPIKey = newValue + } + + Button { Task { await testConnection() } + } label: { + HStack(spacing: 6) { + if isTesting { + ProgressView() + .controlSize(.small) + } + Text("Test Connection") + } } + .disabled(isTesting || tavilyAPIKey.isEmpty) - Button { - Task { await testConnection() } - } label: { - HStack(spacing: 6) { - if isTesting { - ProgressView() - .controlSize(.small) + if isTesting { + HStack(spacing: 8) { + Image(systemName: "arrow.trianglehead.2.counterclockwise") + .foregroundStyle(.secondary) + .symbolEffect(.pulse) + Text("Testing...") + .font(.callout) + .foregroundStyle(.secondary) } - Text(hasURLChanged ? "Activate and Test" : "Test Connection") - } - } - .disabled(isTesting || serverURL.isEmpty) + } else if let testResult { + HStack(spacing: 8) { + Image(systemName: testResult.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(testResult.success ? .green : .red) + .symbolEffect(.appear) - if isTesting { - HStack(spacing: 8) { - Image(systemName: "arrow.trianglehead.2.counterclockwise") - .foregroundStyle(.secondary) - .symbolEffect(.pulse) - Text("Testing...") - .font(.callout) - .foregroundStyle(.secondary) - } - } else if let testResult { - HStack(spacing: 8) { - Image(systemName: testResult.success ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(testResult.success ? .green : .red) - .symbolEffect(.appear) - - Text(testResult.message) - .font(.callout) - .foregroundStyle(testResult.success ? .green : .red) + Text(testResult.message) + .font(.callout) + .foregroundStyle(testResult.success ? .green : .red) + } } - } - if storeManager.isUsingCustomServer { - Label(String(localized: "All features unlocked with your own server"), systemImage: "checkmark.seal.fill") - .font(.caption) - .foregroundStyle(.accent) - } else if !storeManager.isPro { - Text("Use your own SearXNG server to unlock all features for free") + Text("Get an API key at app.tavily.com (1,000 free credits/month). Testing uses 1 credit.") .font(.caption) .foregroundStyle(.secondary) + } header: { + Text("Tavily API") } + } - if serverURL != AppConfig.defaultSearXNGBaseURL { - Button("Use Default Server", action: resetToDefaultServer) - .foregroundStyle(.secondary) + if selectedProvider == .searxng { + Section { + TextField("SearXNG Server URL", text: $serverURL, prompt: Text("https://search.yourdomain.com").foregroundStyle(.secondary)) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + #endif + .onSubmit { + Task { await testConnection() } + } + + Button { + Task { await testConnection() } + } label: { + HStack(spacing: 6) { + if isTesting { + ProgressView() + .controlSize(.small) + } + Text(hasURLChanged ? "Activate and Test" : "Test Connection") + } + } + .disabled(isTesting || serverURL.isEmpty) + + if isTesting { + HStack(spacing: 8) { + Image(systemName: "arrow.trianglehead.2.counterclockwise") + .foregroundStyle(.secondary) + .symbolEffect(.pulse) + Text("Testing...") + .font(.callout) + .foregroundStyle(.secondary) + } + } else if let testResult { + HStack(spacing: 8) { + Image(systemName: testResult.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(testResult.success ? .green : .red) + .symbolEffect(.appear) + + Text(testResult.message) + .font(.callout) + .foregroundStyle(testResult.success ? .green : .red) + } + } + + if storeManager.isUsingCustomServer { + Label(String(localized: "All features unlocked with your own server"), systemImage: "checkmark.seal.fill") + .font(.caption) + .foregroundStyle(.accent) + } else if !storeManager.isPro { + Text("Use your own SearXNG server to unlock all features for free") + .font(.caption) + .foregroundStyle(.secondary) + } + + if serverURL != AppConfig.defaultSearXNGBaseURL { + Button("Use Default Server", action: resetToDefaultServer) + .foregroundStyle(.secondary) + } + } header: { + Text("Search Server") } - } header: { - Text("Search Server") } Section("Preferences") { @@ -234,6 +304,40 @@ struct SettingsView: View { } private func testConnection() async { + if selectedProvider == .tavily { + await testTavilyConnection() + } else { + await testSearXNGConnection() + } + } + + private func testTavilyConnection() async { + guard !tavilyAPIKey.isEmpty else { + testResult = TestResult(success: false, message: String(localized: "Enter a Tavily API key.")) + return + } + + guard tavilyAPIKey.hasPrefix("tvly-") else { + testResult = TestResult(success: false, message: String(localized: "Invalid key format. Tavily keys start with \"tvly-\".")) + return + } + + isTesting = true + let start = Date.now + let service = TavilyService() + let available = await service.checkAvailability() + let latency = Date.now.timeIntervalSince(start) + isTesting = false + + if available { + let ms = Int(latency * 1000) + testResult = TestResult(success: true, message: String(localized: "Connected (\(ms)ms)")) + } else { + testResult = TestResult(success: false, message: String(localized: "Connection failed. Check your API key.")) + } + } + + private func testSearXNGConnection() async { guard isValidURL(serverURL) else { testResult = TestResult(success: false, message: String(localized: "Invalid URL. Use http:// or https://.")) return