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
44 changes: 44 additions & 0 deletions AISight/AISight/App/AppConfig.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion AISight/AISight/Core/AI/DeepSearchPipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions AISight/AISight/Core/Search/KeychainHelper.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion AISight/AISight/Core/Search/SearchError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import Foundation

enum SearchError: LocalizedError {
case serverUnavailable
case authenticationFailed
case timeout
case invalidResponse
case noResults

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:
Expand Down
1 change: 1 addition & 0 deletions AISight/AISight/Core/Search/SearchService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
218 changes: 218 additions & 0 deletions AISight/AISight/Core/Search/TavilyService.swift
Original file line number Diff line number Diff line change
@@ -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?
}
Loading