diff --git a/Sources/Domain/WebSearchAndToolControls.swift b/Sources/Domain/WebSearchAndToolControls.swift index ae73a945..70451815 100644 --- a/Sources/Domain/WebSearchAndToolControls.swift +++ b/Sources/Domain/WebSearchAndToolControls.swift @@ -112,11 +112,13 @@ enum SearchPluginProvider: String, Codable, CaseIterable, Identifiable, Sendable enum ExaSearchType: String, Codable, CaseIterable, Sendable { case auto case fast - case deep case neural + case deepLite = "deep-lite" + case deep + case deepReasoning = "deep-reasoning" case instant - static let publicCases: [ExaSearchType] = [.auto, .fast, .neural, .deep, .instant] + static let publicCases: [ExaSearchType] = [.auto, .fast, .neural, .deepLite, .deep, .deepReasoning, .instant] static func resolved(from rawValue: String?) -> ExaSearchType? { guard let value = rawValue?.trimmedNonEmpty?.lowercased() else { @@ -131,6 +133,30 @@ enum ExaSearchType: String, Codable, CaseIterable, Sendable { } } +/// Exa per-request category filter. Validated against Exa's documented allowlist. +enum ExaCategory: String, Codable, CaseIterable, Sendable { + case company + case researchPaper = "research paper" + case news + case personalSite = "personal site" + case financialReport = "financial report" + case people + + static func resolved(from rawValue: String?) -> ExaCategory? { + guard let value = rawValue?.trimmedNonEmpty?.lowercased() else { + return nil + } + return ExaCategory(rawValue: value) + } +} + +/// Firecrawl source kinds for the v2 search API. JSON-encoded as `[String]` in user defaults. +enum FirecrawlSourceKind: String, Codable, CaseIterable, Sendable { + case web + case news + case images +} + /// Built-in web search controls (app plugin-backed). struct SearchPluginControls: Codable, Sendable { var preferJinSearch: Bool? @@ -142,6 +168,7 @@ struct SearchPluginControls: Codable, Sendable { // Exa-specific var exaSearchType: ExaSearchType? + var exaCategory: String? // Brave-specific var braveCountry: String? @@ -150,6 +177,7 @@ struct SearchPluginControls: Codable, Sendable { // Firecrawl-specific var firecrawlExtractContent: Bool? + var firecrawlCountry: String? // Tavily-specific var tavilySearchDepth: String? // "basic" | "fast" | "advanced" | "ultra_fast" @@ -163,10 +191,12 @@ struct SearchPluginControls: Codable, Sendable { includeRawContent: Bool? = nil, fetchPageContent: Bool? = nil, exaSearchType: ExaSearchType? = nil, + exaCategory: String? = nil, braveCountry: String? = nil, braveLanguage: String? = nil, braveSafesearch: String? = nil, firecrawlExtractContent: Bool? = nil, + firecrawlCountry: String? = nil, tavilySearchDepth: String? = nil, tavilyTopic: String? = nil ) { @@ -177,10 +207,12 @@ struct SearchPluginControls: Codable, Sendable { self.includeRawContent = includeRawContent self.fetchPageContent = fetchPageContent self.exaSearchType = exaSearchType + self.exaCategory = exaCategory self.braveCountry = braveCountry self.braveLanguage = braveLanguage self.braveSafesearch = braveSafesearch self.firecrawlExtractContent = firecrawlExtractContent + self.firecrawlCountry = firecrawlCountry self.tavilySearchDepth = tavilySearchDepth self.tavilyTopic = tavilyTopic } diff --git a/Sources/Tools/BuiltinSearchHelpers.swift b/Sources/Tools/BuiltinSearchHelpers.swift index a466bc34..684046be 100644 --- a/Sources/Tools/BuiltinSearchHelpers.swift +++ b/Sources/Tools/BuiltinSearchHelpers.swift @@ -144,99 +144,30 @@ extension BuiltinSearchToolHub { return String(data: data, encoding: .utf8) } - func braveFreshnessValue(recencyDays: Int) -> String { - switch recencyDays { - case ...1: - return "pd" - case ...7: - return "pw" - case ...31: - return "pm" - default: - return "py" - } - } - - func tavilyTimeRange(recencyDays: Int) -> String { - switch recencyDays { - case ...1: - return "day" - case ...7: - return "week" - case ...31: - return "month" - default: - return "year" - } - } - - func perplexityRecencyFilter(recencyDays: Int) -> String { - switch recencyDays { - case ...1: - return "day" - case ...7: - return "week" - case ...31: - return "month" - default: - return "year" - } - } - - func perplexitySearchDomainFilter(includeDomains: [String], excludeDomains: [String]) -> [String] { - let include = includeDomains.compactMap(normalizedTrimmedString) - if !include.isEmpty { - // Perplexity Search API allows either include-only or exclude-only filters. - return Array(include.prefix(20)) - } + /// Augments a Firecrawl query with Google-style `site:` / `-site:` operators because the v2 + /// search endpoint does not accept first-class include/exclude domain arrays. + /// Caps each list at 10 entries to keep the URL/query sane. + static func firecrawlAugmentedQuery( + _ query: String, + includeDomains: [String], + excludeDomains: [String] + ) -> String { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let includes = includeDomains.compactMap { $0.trimmedNonEmpty }.prefix(10) + let excludes = excludeDomains.compactMap { $0.trimmedNonEmpty }.prefix(10) - let exclude = excludeDomains.compactMap(normalizedTrimmedString).map { "-\($0)" } - return Array(exclude.prefix(20)) - } + var pieces: [String] = trimmed.isEmpty ? [] : [trimmed] - var tavilyDateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd" - return formatter - } - - func tavilySearchDepthValue(_ value: String?) -> String { - guard let depth = normalizedTrimmedString(value)?.lowercased() else { - return "basic" + if !includes.isEmpty { + let operators = includes.map { "site:\($0)" } + pieces.append(operators.count == 1 ? operators[0] : "(\(operators.joined(separator: " OR ")))") } - let normalized = depth.replacingOccurrences(of: "-", with: "_") - switch normalized { - case "basic", "fast", "advanced", "ultra_fast": - return normalized == "ultra_fast" ? "ultra-fast" : normalized - default: - return "basic" + for domain in excludes { + pieces.append("-site:\(domain)") } - } - func tavilyTopicValue(_ value: String?) -> String { - guard let topic = normalizedTrimmedString(value)?.lowercased() else { return "general" } - switch topic { - case "general", "news", "finance": - return topic - default: - return "general" - } - } - - func firecrawlRecencyValue(recencyDays: Int) -> String { - switch recencyDays { - case ...1: - return "qdr:d" - case ...7: - return "qdr:w" - case ...31: - return "qdr:m" - default: - return "qdr:y" - } + return pieces.joined(separator: " ") } func urlHost(_ urlString: String) -> String? { diff --git a/Sources/Tools/BuiltinSearchProvider+Brave.swift b/Sources/Tools/BuiltinSearchProvider+Brave.swift index a3c3f69d..3b812b7f 100644 --- a/Sources/Tools/BuiltinSearchProvider+Brave.swift +++ b/Sources/Tools/BuiltinSearchProvider+Brave.swift @@ -11,7 +11,7 @@ extension BuiltinSearchToolHub { let language = normalizedTrimmedString(route.overrides?.braveLanguage) ?? route.settings.braveLanguage let safesearch = normalizedTrimmedString(route.overrides?.braveSafesearch) ?? route.settings.braveSafesearch - let freshness = args.recencyDays.map { braveFreshnessValue(recencyDays: $0) } + let freshness = args.recencyDays.map { Self.braveDateRangeFreshness(recencyDays: $0) } let pageCount = Int(ceil(Double(desiredMaxResults) / Double(BraveSearchAPI.maxCount))) let maxPages = min(pageCount, BraveSearchAPI.maxOffset + 1) @@ -76,4 +76,22 @@ extension BuiltinSearchToolHub { return BuiltinSearchToolOutput(provider: .brave, query: args.query, resultCount: rows.count, results: rows) } + + /// Builds a UTC `YYYY-MM-DDtoYYYY-MM-DD` window for Brave's `freshness` parameter — strictly + /// more precise than the coarse `pd|pw|pm|py` buckets the API also accepts. + nonisolated static func braveDateRangeFreshness(recencyDays: Int, now: Date = Date()) -> String { + let calendar = Calendar(identifier: .gregorian) + var utc = calendar + utc.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + + let clamped = max(1, recencyDays) + let start = utc.date(byAdding: .day, value: -clamped, to: now) ?? now + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + + return "\(formatter.string(from: start))to\(formatter.string(from: now))" + } } diff --git a/Sources/Tools/BuiltinSearchProvider+Firecrawl.swift b/Sources/Tools/BuiltinSearchProvider+Firecrawl.swift index 891a2d2c..0d640b1e 100644 --- a/Sources/Tools/BuiltinSearchProvider+Firecrawl.swift +++ b/Sources/Tools/BuiltinSearchProvider+Firecrawl.swift @@ -8,23 +8,8 @@ extension BuiltinSearchToolHub { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("application/json", forHTTPHeaderField: "Accept") + let body = Self.makeFirecrawlRequestBody(args: args, settings: route.settings, overrides: route.overrides) let maxResults = args.maxResults.clamped(to: 1...50) - var body: [String: Any] = [ - "query": args.query, - "limit": maxResults - ] - - if let recency = args.recencyDays { - body["tbs"] = firecrawlRecencyValue(recencyDays: recency) - } - if let country = normalizedTrimmedString(route.overrides?.braveCountry) ?? route.settings.braveCountry { - body["country"] = country - } - - let shouldExtractContent = route.overrides?.firecrawlExtractContent ?? route.settings.firecrawlExtractContent - if shouldExtractContent || args.includeRawContent { - body["scrapeOptions"] = ["formats": ["markdown"]] - } request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, _) = try await networkManager.sendRequest(request) @@ -35,29 +20,25 @@ extension BuiltinSearchToolHub { } var raw = parseArray(json["data"]) + let dataDict = json["data"] as? [String: Any] + if raw.isEmpty { - raw = parseArray((json["data"] as? [String: Any])?["web"]) + raw = parseArray(dataDict?["web"]) + } + if let news = dataDict?["news"] { + raw.append(contentsOf: parseArray(news)) + } + if let images = dataDict?["images"] { + raw.append(contentsOf: parseArray(images)) } if raw.isEmpty { - raw = parseArray((json["data"] as? [String: Any])?["results"]) + raw = parseArray(dataDict?["results"]) } if raw.isEmpty { raw = parseArray(json["results"]) } - let rows = raw.prefix(maxResults).compactMap { item -> SearchCitationRow? in - guard let url = firstString(in: item, keys: ["url", "link"]) else { return nil } - let title = firstString(in: item, keys: ["title"]) ?? URL(string: url)?.host ?? url - let snippet = firstString(in: item, keys: ["description", "markdown", "summary", "content", "snippet"]) - let publishedAt = firstString(in: item, keys: ["publishedDate", "published", "date"]) - return SearchCitationRow( - title: title, - url: url, - snippet: snippet.map { String($0.prefix(500)) }, - publishedAt: publishedAt, - source: urlHost(url) - ) - } + let rows = Self.makeFirecrawlRows(from: raw, maxResults: maxResults) return BuiltinSearchToolOutput( provider: .firecrawl, @@ -66,4 +47,98 @@ extension BuiltinSearchToolHub { results: rows ) } + + /// Pure builder for the `/v2/search` request body, exposed for tests. + nonisolated static func makeFirecrawlRequestBody( + args: ResolvedArguments, + settings: WebSearchPluginSettings, + overrides: SearchPluginControls? + ) -> [String: Any] { + let maxResults = args.maxResults.clamped(to: 1...50) + let augmentedQuery = firecrawlAugmentedQuery( + args.query, + includeDomains: args.includeDomains, + excludeDomains: args.excludeDomains + ) + + var body: [String: Any] = [ + "query": augmentedQuery, + "limit": maxResults, + "ignoreInvalidURLs": true + ] + + if let recency = args.recencyDays { + body["tbs"] = firecrawlRecencyTBS(recencyDays: recency) + } + + if let country = (overrides?.firecrawlCountry?.trimmedNonEmpty ?? settings.firecrawlCountry?.trimmedNonEmpty) { + body["country"] = country + } + + if let language = settings.firecrawlLanguage?.trimmedNonEmpty { + body["lang"] = language + } + + if !settings.firecrawlSources.isEmpty { + body["sources"] = settings.firecrawlSources.map { ["type": $0.rawValue] } + } + + let shouldExtractContent = overrides?.firecrawlExtractContent ?? settings.firecrawlExtractContent + if shouldExtractContent || args.includeRawContent { + body["scrapeOptions"] = ["formats": ["markdown"]] + } + + return body + } + + /// Pure recency-window mapper duplicated as a `nonisolated static` so the body builder can + /// remain test-callable without crossing actor isolation. + nonisolated static func firecrawlRecencyTBS(recencyDays: Int) -> String { + switch recencyDays { + case ...1: return "qdr:d" + case ...7: return "qdr:w" + case ...31: return "qdr:m" + default: return "qdr:y" + } + } + + /// Pure mapper for Firecrawl result rows. Dedupes by URL before applying the cap because + /// multi-source responses can repeat the same hit across web, news, and image buckets. + nonisolated static func makeFirecrawlRows(from raw: [[String: Any]], maxResults: Int) -> [SearchCitationRow] { + let cap = maxResults.clamped(to: 0...50) + guard cap > 0 else { return [] } + + var seenURLs = Set() + var rows: [SearchCitationRow] = [] + rows.reserveCapacity(min(cap, raw.count)) + + for item in raw { + guard rows.count < cap else { break } + guard let url = firstFirecrawlString(in: item, keys: ["url", "link", "imageUrl"]) else { continue } + guard seenURLs.insert(url).inserted else { continue } + + let title = firstFirecrawlString(in: item, keys: ["title"]) ?? URL(string: url)?.host ?? url + let snippet = firstFirecrawlString(in: item, keys: ["description", "snippet", "markdown", "summary", "content"]) + let publishedAt = firstFirecrawlString(in: item, keys: ["publishedDate", "published", "date"]) + rows.append(SearchCitationRow( + title: title, + url: url, + snippet: snippet.map { String($0.prefix(500)) }, + publishedAt: publishedAt, + source: URL(string: url)?.host + )) + } + + return rows + } + + private nonisolated static func firstFirecrawlString(in dictionary: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = dictionary[key] as? String, + let trimmed = value.trimmedNonEmpty { + return trimmed + } + } + return nil + } } diff --git a/Sources/Tools/BuiltinSearchProvider+Jina.swift b/Sources/Tools/BuiltinSearchProvider+Jina.swift index 1a2b042a..4c42ae3f 100644 --- a/Sources/Tools/BuiltinSearchProvider+Jina.swift +++ b/Sources/Tools/BuiltinSearchProvider+Jina.swift @@ -2,62 +2,41 @@ import Foundation extension BuiltinSearchToolHub { func searchJina(_ args: ResolvedArguments, route: ToolRoute) async throws -> BuiltinSearchToolOutput { - let encoded = args.query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? args.query - var components = URLComponents(string: "https://s.jina.ai/\(encoded)") - let queryItems: [URLQueryItem] = args.includeDomains.map { domain in - URLQueryItem(name: "site", value: domain) - } - let resolvedMaxResults = min(max(args.maxResults, 1), 5) - components?.queryItems = queryItems - - guard let url = components?.url else { - throw LLMError.invalidRequest(message: "Failed to construct Jina search URL.") + let resolvedMaxResults = args.maxResults.clamped(to: 0...5) + if resolvedMaxResults == 0 { + return BuiltinSearchToolOutput( + provider: .jina, + query: args.query, + resultCount: 0, + results: [] + ) } - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.addValue("Bearer \(route.apiKey)", forHTTPHeaderField: "Authorization") - request.addValue("application/json", forHTTPHeaderField: "Accept") + let request = try Self.makeJinaRequest( + args: args, + settings: route.settings, + apiKey: route.apiKey + ) let (data, _) = try await networkManager.sendRequest(request) let response = try parseJSON(data) - var rawResults: [[String: Any]] - switch response { - case let results as [[String: Any]]: - rawResults = results - case let results as [Any]: - rawResults = parseArray(results) - case let dict as [String: Any]: - rawResults = parseArray(dict["data"]) - if rawResults.isEmpty { - rawResults = parseArray(dict["web"]) - } - if rawResults.isEmpty { - rawResults = parseArray(dict["results"]) - } - default: - throw LLMError.decodingError(message: "Unexpected Jina response format.") - } + let rawResults = Self.extractJinaResults(from: response) - var rows = rawResults.prefix(resolvedMaxResults).compactMap { item -> SearchCitationRow? in + let rows = rawResults.prefix(resolvedMaxResults).compactMap { item -> SearchCitationRow? in guard let url = firstString(in: item, keys: ["url", "link"]) else { return nil } let title = firstString(in: item, keys: ["title"]) ?? URL(string: url)?.host ?? url - let snippet = firstString(in: item, keys: ["snippet", "summary", "description", "text", "content"]) + let snippet = firstString(in: item, keys: ["content", "snippet", "summary", "description", "text"]) let publishedAt = firstString(in: item, keys: ["publishedDate", "date", "published"]) return SearchCitationRow( title: title, url: url, - snippet: snippet, + snippet: snippet.map { String($0.prefix(500)) }, publishedAt: publishedAt, source: urlHost(url) ) } - if args.fetchPageContent { - rows = try await enrichJinaRowsWithReader(rows) - } - return BuiltinSearchToolOutput( provider: .jina, query: args.query, @@ -66,47 +45,80 @@ extension BuiltinSearchToolHub { ) } - private func enrichJinaRowsWithReader(_ rows: [SearchCitationRow]) async throws -> [SearchCitationRow] { - var out: [SearchCitationRow] = [] - out.reserveCapacity(rows.count) + /// Pure builder for the s.jina.ai POST request, exposed for tests. + nonisolated static func makeJinaRequest( + args: ResolvedArguments, + settings: WebSearchPluginSettings, + apiKey: String + ) throws -> URLRequest { + guard let url = URL(string: "https://s.jina.ai/") else { + throw LLMError.invalidRequest(message: "Failed to construct Jina search URL.") + } - for (index, row) in rows.enumerated() { - guard index < 3 else { - out.append(row) - continue - } - if let snippet = try await fetchJinaReaderSnippet(for: row.url) { - out.append( - SearchCitationRow( - title: row.title, - url: row.url, - snippet: snippet, - publishedAt: row.publishedAt, - source: row.source - ) - ) - } else { - out.append(row) - } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("browser", forHTTPHeaderField: "X-Engine") + + if !args.fetchPageContent { + request.addValue("no-content", forHTTPHeaderField: "X-Respond-With") } - return out - } + if args.includeRawContent { + request.addValue("true", forHTTPHeaderField: "X-With-Generated-Alt") + request.addValue("true", forHTTPHeaderField: "X-With-Links-Summary") + } - private func fetchJinaReaderSnippet(for urlString: String) async throws -> String? { - guard let encoded = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - return nil + let trimmedIncludes = args.includeDomains.compactMap { $0.trimmedNonEmpty } + if let firstSite = trimmedIncludes.first { + request.addValue(firstSite, forHTTPHeaderField: "X-Site") } - let readerURL = try validatedURL("https://r.jina.ai/\(encoded)") - var request = URLRequest(url: readerURL) - request.httpMethod = "GET" - let (data, _) = try await networkManager.sendRequest(request) - guard let raw = String(data: data, encoding: .utf8) else { return nil } - let condensed = raw - .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !condensed.isEmpty else { return nil } - return String(condensed.prefix(500)) + if let locale = settings.jinaLocale?.trimmedNonEmpty { + request.addValue(locale, forHTTPHeaderField: "X-Locale") + } + + let augmentedQuery = jinaAugmentedQuery(args.query, includeDomains: Array(trimmedIncludes.dropFirst())) + var body: [String: Any] = ["q": augmentedQuery] + if let country = settings.jinaCountry?.trimmedNonEmpty { + body["gl"] = country + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + /// Appends `site:` operators for any include domains beyond the first (which the X-Site header + /// covers). Jina supports a single X-Site header; spillover goes into the body's query string. + nonisolated static func jinaAugmentedQuery(_ query: String, includeDomains: [String]) -> String { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let domains = includeDomains.compactMap { $0.trimmedNonEmpty }.prefix(10) + guard !domains.isEmpty else { return trimmed } + + let operators = domains.map { "site:\($0)" }.joined(separator: " OR ") + let groupedOperators = domains.count == 1 ? operators : "(\(operators))" + return trimmed.isEmpty ? groupedOperators : "\(trimmed) \(groupedOperators)" + } + + nonisolated static func extractJinaResults(from response: Any) -> [[String: Any]] { + switch response { + case let results as [[String: Any]]: + return results + case let results as [Any]: + return results.compactMap { $0 as? [String: Any] } + case let dict as [String: Any]: + for key in ["data", "web", "results"] { + if let values = dict[key] as? [[String: Any]] { + return values + } + if let values = dict[key] as? [Any] { + return values.compactMap { $0 as? [String: Any] } + } + } + return [] + default: + return [] + } } } diff --git a/Sources/Tools/BuiltinSearchProvider+Perplexity.swift b/Sources/Tools/BuiltinSearchProvider+Perplexity.swift index 5d93df93..a959aded 100644 --- a/Sources/Tools/BuiltinSearchProvider+Perplexity.swift +++ b/Sources/Tools/BuiltinSearchProvider+Perplexity.swift @@ -11,39 +11,20 @@ extension BuiltinSearchToolHub { ) } - var request = URLRequest(url: try validatedURL("https://api.perplexity.ai/search")) - request.httpMethod = "POST" - request.addValue("Bearer \(route.apiKey)", forHTTPHeaderField: "Authorization") - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue("application/json", forHTTPHeaderField: "Accept") - - let clampedMax = args.maxResults.clamped(to: 1...20) - var body: [String: Any] = [ - "query": args.query, - "max_results": clampedMax - ] - - if let recencyDays = args.recencyDays { - body["search_recency_filter"] = perplexityRecencyFilter(recencyDays: recencyDays) - } - if !args.includeDomains.isEmpty && !args.excludeDomains.isEmpty { throw LLMError.invalidRequest( message: "Perplexity supports either `include_domains` or `exclude_domains`, not both." ) } - let domainFilter = perplexitySearchDomainFilter( - includeDomains: args.includeDomains, - excludeDomains: args.excludeDomains - ) - if !domainFilter.isEmpty { - body["search_domain_filter"] = domainFilter - } + var request = URLRequest(url: try validatedURL("https://api.perplexity.ai/search")) + request.httpMethod = "POST" + request.addValue("Bearer \(route.apiKey)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") - if args.includeRawContent { - body["max_tokens_per_page"] = 4_096 - } + let body = Self.makePerplexityRequestBody(args: args, settings: route.settings, overrides: route.overrides) + let clampedMax = args.maxResults.clamped(to: 1...20) request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, _) = try await networkManager.sendRequest(request) @@ -73,4 +54,65 @@ extension BuiltinSearchToolHub { results: rows ) } + + /// Pure builder for the `/search` request body, exposed for tests. + nonisolated static func makePerplexityRequestBody( + args: ResolvedArguments, + settings: WebSearchPluginSettings, + overrides: SearchPluginControls? + ) -> [String: Any] { + let clampedMax = args.maxResults.clamped(to: 1...20) + var body: [String: Any] = [ + "query": args.query, + "max_results": clampedMax + ] + + if let recencyDays = args.recencyDays { + body["search_after_date_filter"] = perplexityDateFilter(daysAgo: recencyDays) + } + + let domainFilter = perplexityDomainFilter( + includeDomains: args.includeDomains, + excludeDomains: args.excludeDomains + ) + if !domainFilter.isEmpty { + body["search_domain_filter"] = domainFilter + } + + if let country = settings.perplexityCountry?.trimmedNonEmpty { + body["country"] = country + } + + if let language = settings.perplexityLanguage?.trimmedNonEmpty { + body["search_language_filter"] = [language] + } + + if args.includeRawContent { + body["max_tokens"] = 4_096 + } + + return body + } + + /// Builds the Perplexity `MM/DD/YYYY` UTC date string for `now - daysAgo`. + nonisolated static func perplexityDateFilter(daysAgo: Int, now: Date = Date()) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let target = calendar.date(byAdding: .day, value: -max(1, daysAgo), to: now) ?? now + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "MM/dd/yyyy" + return formatter.string(from: target) + } + + nonisolated static func perplexityDomainFilter(includeDomains: [String], excludeDomains: [String]) -> [String] { + let include = includeDomains.compactMap { $0.trimmedNonEmpty } + if !include.isEmpty { + return Array(include.prefix(20)) + } + let exclude = excludeDomains.compactMap { $0.trimmedNonEmpty }.map { "-\($0)" } + return Array(exclude.prefix(20)) + } } diff --git a/Sources/Tools/BuiltinSearchProvider+Tavily.swift b/Sources/Tools/BuiltinSearchProvider+Tavily.swift index 5a06c648..1b357921 100644 --- a/Sources/Tools/BuiltinSearchProvider+Tavily.swift +++ b/Sources/Tools/BuiltinSearchProvider+Tavily.swift @@ -8,50 +8,9 @@ extension BuiltinSearchToolHub { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("application/json", forHTTPHeaderField: "Accept") + let body = Self.makeTavilyRequestBody(args: args, settings: route.settings, overrides: route.overrides) let clampedMax = args.maxResults.clamped(to: 0...20) - var body: [String: Any] = [ - "query": args.query, - "max_results": clampedMax - ] - - let searchDepth = tavilySearchDepthValue( - normalizedTrimmedString(route.overrides?.tavilySearchDepth) - ?? route.settings.tavilySearchDepth - ) - body["search_depth"] = searchDepth - - let topic = tavilyTopicValue( - normalizedTrimmedString(route.overrides?.tavilyTopic) - ?? route.settings.tavilyTopic - ) - body["topic"] = topic - - if let recency = args.recencyDays { - if let startDate = Calendar.current.date( - byAdding: .day, - value: -recency, - to: Date() - ) { - let date = tavilyDateFormatter.string(from: startDate) - body["start_date"] = date - body["end_date"] = tavilyDateFormatter.string(from: Date()) - } else { - body["time_range"] = tavilyTimeRange(recencyDays: recency) - } - } - - if !args.includeDomains.isEmpty { - body["include_domains"] = Array(args.includeDomains.prefix(300)) - } - if !args.excludeDomains.isEmpty { - body["exclude_domains"] = Array(args.excludeDomains.prefix(150)) - } - - if args.includeRawContent { - body["include_raw_content"] = "markdown" - } - request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, _) = try await networkManager.sendRequest(request) let json = try parseJSONObject(data) @@ -79,4 +38,158 @@ extension BuiltinSearchToolHub { results: rows ) } + + /// Pure builder for the `/search` request body, exposed for tests. + nonisolated static func makeTavilyRequestBody( + args: ResolvedArguments, + settings: WebSearchPluginSettings, + overrides: SearchPluginControls? + ) -> [String: Any] { + let clampedMax = args.maxResults.clamped(to: 0...20) + let depth = tavilyDepthValue(overrides?.tavilySearchDepth ?? settings.tavilySearchDepth) + let topic = tavilyTopicValue(overrides?.tavilyTopic ?? settings.tavilyTopic) + let shouldAutoTune = settings.tavilyAutoParameters + let hasDepthOverride = overrides?.tavilySearchDepth?.trimmedNonEmpty != nil + let hasTopicOverride = overrides?.tavilyTopic?.trimmedNonEmpty != nil + + var body: [String: Any] = [ + "query": args.query, + "max_results": clampedMax + ] + + if !shouldAutoTune || hasDepthOverride { + body["search_depth"] = depth + } + + if !shouldAutoTune || hasTopicOverride { + body["topic"] = topic + } + + if let recency = args.recencyDays { + let calendar = utcGregorianCalendar() + let now = Date() + let start = calendar.date(byAdding: .day, value: -recency, to: now) ?? now + body["start_date"] = tavilyDateString(start) + body["end_date"] = tavilyDateString(now) + } + + if !args.includeDomains.isEmpty { + body["include_domains"] = Array(args.includeDomains.prefix(300)) + } + if !args.excludeDomains.isEmpty { + body["exclude_domains"] = Array(args.excludeDomains.prefix(150)) + } + + if args.includeRawContent { + body["include_raw_content"] = "markdown" + } + + if topic == "general", + (!shouldAutoTune || hasTopicOverride), + let country = tavilyCountryValue(settings.tavilyCountry) { + body["country"] = country + } + + if settings.tavilyAutoParameters { + body["auto_parameters"] = true + } + + if body["search_depth"] as? String == "advanced" { + body["chunks_per_source"] = 3 + } + + return body + } + + nonisolated static func tavilyDepthValue(_ value: String?) -> String { + guard let depth = value?.trimmedNonEmpty?.lowercased() else { return "basic" } + let normalized = depth.replacingOccurrences(of: "-", with: "_") + switch normalized { + case "basic", "fast", "advanced": + return normalized + case "ultra_fast": + return "ultra-fast" + default: + return "basic" + } + } + + nonisolated static func tavilyTopicValue(_ value: String?) -> String { + guard let topic = value?.trimmedNonEmpty?.lowercased() else { return "general" } + switch topic { + case "general", "news", "finance": + return topic + default: + return "general" + } + } + + nonisolated static func tavilyCountryValue(_ value: String?) -> String? { + guard let raw = value?.trimmedNonEmpty else { return nil } + let normalized = raw.lowercased() + if tavilyCountryCodeMap.values.contains(normalized) { + return normalized + } + + return tavilyCountryCodeMap[normalized.uppercased()] + } + + private static let tavilyCountryCodeMap: [String: String] = [ + "AF": "afghanistan", "AL": "albania", "DZ": "algeria", "AD": "andorra", + "AO": "angola", "AR": "argentina", "AM": "armenia", "AU": "australia", + "AT": "austria", "AZ": "azerbaijan", "BS": "bahamas", "BH": "bahrain", + "BD": "bangladesh", "BB": "barbados", "BY": "belarus", "BE": "belgium", + "BZ": "belize", "BJ": "benin", "BT": "bhutan", "BO": "bolivia", + "BA": "bosnia and herzegovina", "BW": "botswana", "BR": "brazil", "BN": "brunei", + "BG": "bulgaria", "BF": "burkina faso", "BI": "burundi", "KH": "cambodia", + "CM": "cameroon", "CA": "canada", "CV": "cape verde", "CF": "central african republic", + "TD": "chad", "CL": "chile", "CN": "china", "CO": "colombia", + "KM": "comoros", "CG": "congo", "CR": "costa rica", "HR": "croatia", + "CU": "cuba", "CY": "cyprus", "CZ": "czech republic", "DK": "denmark", + "DJ": "djibouti", "DO": "dominican republic", "EC": "ecuador", "EG": "egypt", + "SV": "el salvador", "GQ": "equatorial guinea", "ER": "eritrea", "EE": "estonia", + "ET": "ethiopia", "FJ": "fiji", "FI": "finland", "FR": "france", + "GA": "gabon", "GM": "gambia", "GE": "georgia", "DE": "germany", + "GH": "ghana", "GR": "greece", "GT": "guatemala", "GN": "guinea", + "HT": "haiti", "HN": "honduras", "HU": "hungary", "IS": "iceland", + "IN": "india", "ID": "indonesia", "IR": "iran", "IQ": "iraq", + "IE": "ireland", "IL": "israel", "IT": "italy", "JM": "jamaica", + "JP": "japan", "JO": "jordan", "KZ": "kazakhstan", "KE": "kenya", + "KW": "kuwait", "KG": "kyrgyzstan", "LV": "latvia", "LB": "lebanon", + "LS": "lesotho", "LR": "liberia", "LY": "libya", "LI": "liechtenstein", + "LT": "lithuania", "LU": "luxembourg", "MG": "madagascar", "MW": "malawi", + "MY": "malaysia", "MV": "maldives", "ML": "mali", "MT": "malta", + "MR": "mauritania", "MU": "mauritius", "MX": "mexico", "MD": "moldova", + "MC": "monaco", "MN": "mongolia", "ME": "montenegro", "MA": "morocco", + "MZ": "mozambique", "MM": "myanmar", "NA": "namibia", "NP": "nepal", + "NL": "netherlands", "NZ": "new zealand", "NI": "nicaragua", "NE": "niger", + "NG": "nigeria", "KP": "north korea", "MK": "north macedonia", "NO": "norway", + "OM": "oman", "PK": "pakistan", "PA": "panama", "PG": "papua new guinea", + "PY": "paraguay", "PE": "peru", "PH": "philippines", "PL": "poland", + "PT": "portugal", "QA": "qatar", "RO": "romania", "RU": "russia", + "RW": "rwanda", "SA": "saudi arabia", "SN": "senegal", "RS": "serbia", + "SG": "singapore", "SK": "slovakia", "SI": "slovenia", "SO": "somalia", + "ZA": "south africa", "KR": "south korea", "SS": "south sudan", "ES": "spain", + "LK": "sri lanka", "SD": "sudan", "SE": "sweden", "CH": "switzerland", + "SY": "syria", "TW": "taiwan", "TJ": "tajikistan", "TZ": "tanzania", + "TH": "thailand", "TG": "togo", "TT": "trinidad and tobago", "TN": "tunisia", + "TR": "turkey", "TM": "turkmenistan", "UG": "uganda", "UA": "ukraine", + "AE": "united arab emirates", "GB": "united kingdom", "US": "united states", + "UY": "uruguay", "UZ": "uzbekistan", "VE": "venezuela", "VN": "vietnam", + "YE": "yemen", "ZM": "zambia", "ZW": "zimbabwe" + ] + + nonisolated static func tavilyDateString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + nonisolated static func utcGregorianCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar + } } diff --git a/Sources/Tools/BuiltinSearchProviders.swift b/Sources/Tools/BuiltinSearchProviders.swift index 94965bec..ff9af790 100644 --- a/Sources/Tools/BuiltinSearchProviders.swift +++ b/Sources/Tools/BuiltinSearchProviders.swift @@ -12,28 +12,7 @@ extension BuiltinSearchToolHub { request.addValue("application/json", forHTTPHeaderField: "Content-Type") let maxResults = args.maxResults.clamped(to: 1...50) - var body: [String: Any] = [ - "query": args.query, - "numResults": maxResults - ] - - if let searchType = route.overrides?.exaSearchType ?? route.settings.exaSearchType { - body["type"] = searchType.rawValue - } - if !args.includeDomains.isEmpty { - body["includeDomains"] = args.includeDomains - } - if !args.excludeDomains.isEmpty { - body["excludeDomains"] = args.excludeDomains - } - if let recencyDays = args.recencyDays { - let start = Date(timeIntervalSinceNow: TimeInterval(-recencyDays * 86_400)) - body["startPublishedDate"] = Self.iso8601String(start) - } - if args.includeRawContent { - // Exa API requires content retrieval to be nested under "contents" - body["contents"] = ["text": true] - } + let body = Self.makeExaRequestBody(args: args, settings: route.settings, overrides: route.overrides) request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, _) = try await networkManager.sendRequest(request) @@ -63,4 +42,71 @@ extension BuiltinSearchToolHub { results: results ) } + + /// Pure builder for the `/search` request body, exposed for tests. + nonisolated static func makeExaRequestBody( + args: ResolvedArguments, + settings: WebSearchPluginSettings, + overrides: SearchPluginControls? + ) -> [String: Any] { + let maxResults = args.maxResults.clamped(to: 1...50) + var body: [String: Any] = [ + "query": args.query, + "numResults": maxResults + ] + + if let searchType = overrides?.exaSearchType ?? settings.exaSearchType { + body["type"] = searchType.rawValue + } + + let category = ExaCategory.resolved(from: overrides?.exaCategory ?? settings.exaCategory) + if let category { + body["category"] = category.rawValue + } + let usesEntityCategory = category == .company || category == .people + + if let userLocation = settings.exaUserLocation?.trimmedNonEmpty { + body["userLocation"] = userLocation + } + + if settings.exaModeration { + body["moderation"] = true + } + + let includeDomains = exaIncludeDomains(args.includeDomains, category: category) + if !includeDomains.isEmpty { + body["includeDomains"] = includeDomains + } + + if !usesEntityCategory, !args.excludeDomains.isEmpty { + body["excludeDomains"] = args.excludeDomains + } + + if !usesEntityCategory, let recencyDays = args.recencyDays { + let start = Date(timeIntervalSinceNow: TimeInterval(-recencyDays * 86_400)) + body["startPublishedDate"] = iso8601String(start) + } + + if args.includeRawContent { + let text: [String: Any] = [ + "maxCharacters": 8_000, + "verbosity": "compact" + ] + var contents: [String: Any] = ["text": text] + if let recencyDays = args.recencyDays { + contents["maxAgeHours"] = recencyDays * 24 + } + body["contents"] = contents + } + + return body + } + + nonisolated static func exaIncludeDomains(_ domains: [String], category: ExaCategory?) -> [String] { + guard category == .people else { return domains } + return domains.filter { domain in + guard let normalized = domain.trimmedNonEmpty?.lowercased() else { return false } + return normalized == "linkedin.com" || normalized.hasSuffix(".linkedin.com") + } + } } diff --git a/Sources/UI/AppPreferenceKeys.swift b/Sources/UI/AppPreferenceKeys.swift index 96fe4d61..fcbbde9e 100644 --- a/Sources/UI/AppPreferenceKeys.swift +++ b/Sources/UI/AppPreferenceKeys.swift @@ -92,6 +92,18 @@ enum AppPreferenceKeys { static let pluginWebSearchPerplexityAPIKey = "pluginWebSearchPerplexityAPIKey" static let pluginWebSearchTavilySearchDepth = "pluginWebSearchTavilySearchDepth" static let pluginWebSearchTavilyTopic = "pluginWebSearchTavilyTopic" + static let pluginWebSearchExaCategory = "pluginWebSearchExaCategory" + static let pluginWebSearchExaUserLocation = "pluginWebSearchExaUserLocation" + static let pluginWebSearchExaModeration = "pluginWebSearchExaModeration" + static let pluginWebSearchJinaCountry = "pluginWebSearchJinaCountry" + static let pluginWebSearchJinaLocale = "pluginWebSearchJinaLocale" + static let pluginWebSearchFirecrawlCountry = "pluginWebSearchFirecrawlCountry" + static let pluginWebSearchFirecrawlLanguage = "pluginWebSearchFirecrawlLanguage" + static let pluginWebSearchFirecrawlSources = "pluginWebSearchFirecrawlSources" + static let pluginWebSearchTavilyCountry = "pluginWebSearchTavilyCountry" + static let pluginWebSearchTavilyAutoParameters = "pluginWebSearchTavilyAutoParameters" + static let pluginWebSearchPerplexityCountry = "pluginWebSearchPerplexityCountry" + static let pluginWebSearchPerplexityLanguage = "pluginWebSearchPerplexityLanguage" // Cloudflare R2 Upload static let cloudflareR2AccountID = "cloudflareR2AccountID" diff --git a/Sources/UI/WebSearchPluginSettingsChromeViews.swift b/Sources/UI/WebSearchPluginSettingsChromeViews.swift index 566123dc..2019b7e2 100644 --- a/Sources/UI/WebSearchPluginSettingsChromeViews.swift +++ b/Sources/UI/WebSearchPluginSettingsChromeViews.swift @@ -73,14 +73,32 @@ struct WebSearchConfiguredProvidersRow: View { struct WebSearchAdvancedProviderSettingsView: View { let provider: SearchPluginProvider + @Binding var exaSearchTypeRaw: String + @Binding var exaCategory: String + @Binding var exaUserLocation: String + @Binding var exaModeration: Bool + @Binding var braveCountry: String @Binding var braveLanguage: String @Binding var braveSafesearch: String + @Binding var jinaReadPages: Bool + @Binding var jinaCountry: String + @Binding var jinaLocale: String + @Binding var firecrawlExtractContent: Bool + @Binding var firecrawlCountry: String + @Binding var firecrawlLanguage: String + @Binding var firecrawlSourcesRaw: String + @Binding var tavilySearchDepth: String @Binding var tavilyTopic: String + @Binding var tavilyCountry: String + @Binding var tavilyAutoParameters: Bool + + @Binding var perplexityCountry: String + @Binding var perplexityLanguage: String var body: some View { providerSettings @@ -104,6 +122,7 @@ struct WebSearchAdvancedProviderSettingsView: View { } } + @ViewBuilder private var exaSettings: some View { JinSettingsPickerRow( "Search type", @@ -112,9 +131,34 @@ struct WebSearchAdvancedProviderSettingsView: View { ) { Text("Auto").tag("") ForEach(ExaSearchType.publicCases, id: \.self) { value in - Text(value.rawValue.capitalized).tag(value.rawValue) + Text(exaSearchTypeLabel(for: value)).tag(value.rawValue) } } + + JinSettingsPickerRow( + "Category", + supportingText: "Restrict results to a content kind. Useful for research, company, or people queries.", + selection: $exaCategory + ) { + Text("Any").tag("") + ForEach(ExaCategory.allCases, id: \.self) { value in + Text(exaCategoryLabel(for: value)).tag(value.rawValue) + } + } + + JinSettingsTextFieldRow( + "User location", + fieldTitle: "User location", + supportingText: "Optional 2-letter country code (ISO-3166) to localize results.", + text: $exaUserLocation, + usesMonospacedFont: true + ) + + JinSettingsToggleRow( + "Filter unsafe content", + supportingText: "Asks Exa to drop adult, violent, or otherwise unsafe results.", + isOn: $exaModeration + ) } @ViewBuilder @@ -157,26 +201,92 @@ struct WebSearchAdvancedProviderSettingsView: View { } } + @ViewBuilder private var jinaSettings: some View { JinSettingsToggleRow( "Fetch pages with Jina Reader", - supportingText: "Requests Jina Reader content for pages before sending results back to chat.", + supportingText: "When off, Jina returns search hits with no per-page content (fast path).", isOn: $jinaReadPages ) + + JinSettingsTextFieldRow( + "Country", + fieldTitle: "Country", + supportingText: "Optional 2-letter country code that Jina passes through to the search engine.", + text: $jinaCountry, + usesMonospacedFont: true + ) + + JinSettingsTextFieldRow( + "Locale", + fieldTitle: "Locale", + supportingText: "Optional locale (BCP-47), e.g. en-US or de-DE.", + text: $jinaLocale, + usesMonospacedFont: true + ) } + @ViewBuilder private var firecrawlSettings: some View { JinSettingsToggleRow( "Extract markdown content", supportingText: "When enabled, Firecrawl returns extracted page content instead of just the search hit.", isOn: $firecrawlExtractContent ) + + JinSettingsTextFieldRow( + "Country", + fieldTitle: "Country", + supportingText: "Optional 2-letter country code for Firecrawl results.", + text: $firecrawlCountry, + usesMonospacedFont: true + ) + + JinSettingsTextFieldRow( + "Language", + fieldTitle: "Language", + supportingText: "Optional language hint passed as `lang` to Firecrawl.", + text: $firecrawlLanguage, + usesMonospacedFont: true + ) + + JinSettingsToggleRow( + "Web results", + supportingText: "Standard search hits.", + isOn: firecrawlSourceBinding(for: .web) + ) + + JinSettingsToggleRow( + "News results", + supportingText: "Includes news articles when Firecrawl has them.", + isOn: firecrawlSourceBinding(for: .news) + ) + + JinSettingsToggleRow( + "Image results", + supportingText: "Includes image hits when Firecrawl has them.", + isOn: firecrawlSourceBinding(for: .images) + ) } @ViewBuilder private var tavilySettings: some View { tavilySearchDepthRow tavilyTopicRow + + JinSettingsTextFieldRow( + "Country", + fieldTitle: "Country", + supportingText: "Optional 2-letter country code. Tavily applies country only on the General topic.", + text: $tavilyCountry, + usesMonospacedFont: true + ) + + JinSettingsToggleRow( + "Auto-tune parameters", + supportingText: "Lets Tavily override search depth and topic when it has higher confidence.", + isOn: $tavilyAutoParameters + ) } private var tavilySearchDepthRow: some View { @@ -204,8 +314,163 @@ struct WebSearchAdvancedProviderSettingsView: View { } } + @ViewBuilder private var perplexitySettings: some View { - Text("Perplexity Search currently has no plugin-specific options.") - .jinInfoCallout() + JinSettingsTextFieldRow( + "Country", + fieldTitle: "Country", + supportingText: "Optional 2-letter country code (ISO 3166-1 alpha-2).", + text: $perplexityCountry, + usesMonospacedFont: true + ) + + JinSettingsTextFieldRow( + "Language", + fieldTitle: "Language", + supportingText: "Optional ISO 639-1 language code, e.g. en or de.", + text: $perplexityLanguage, + usesMonospacedFont: true + ) + } + + // MARK: - Firecrawl sources binding + + private func firecrawlSourceBinding(for kind: FirecrawlSourceKind) -> Binding { + Binding( + get: { + firecrawlSelectedSources().contains(kind) + }, + set: { isOn in + var current = firecrawlSelectedSources() + if isOn { + if !current.contains(kind) { + current.append(kind) + } + } else { + current.removeAll { $0 == kind } + } + firecrawlSourcesRaw = WebSearchPluginSettingsStore.encodeFirecrawlSources(current) + } + ) + } + + private func firecrawlSelectedSources() -> [FirecrawlSourceKind] { + WebSearchPluginSettingsStore.firecrawlSourceSelection(from: firecrawlSourcesRaw) + } +} + +// MARK: - Observers + +/// Per-provider observer modifiers split out so the main view can chain them without the SwiftUI +/// type checker timing out (it could not handle 20+ `.onChange` modifiers in one expression). + +struct ExaProviderObservers: ViewModifier { + let exaSearchTypeRaw: String + let exaCategory: String + let exaUserLocation: String + let exaModeration: Bool + let onChange: () -> Void + + func body(content: Content) -> some View { + content + .onChange(of: exaSearchTypeRaw) { _, _ in onChange() } + .onChange(of: exaCategory) { _, _ in onChange() } + .onChange(of: exaUserLocation) { _, _ in onChange() } + .onChange(of: exaModeration) { _, _ in onChange() } + } +} + +struct BraveProviderObservers: ViewModifier { + let braveCountry: String + let braveLanguage: String + let braveSafesearch: String + let onChange: () -> Void + + func body(content: Content) -> some View { + content + .onChange(of: braveCountry) { _, _ in onChange() } + .onChange(of: braveLanguage) { _, _ in onChange() } + .onChange(of: braveSafesearch) { _, _ in onChange() } + } +} + +struct JinaProviderObservers: ViewModifier { + let jinaReadPages: Bool + let jinaCountry: String + let jinaLocale: String + let onChange: () -> Void + + func body(content: Content) -> some View { + content + .onChange(of: jinaReadPages) { _, _ in onChange() } + .onChange(of: jinaCountry) { _, _ in onChange() } + .onChange(of: jinaLocale) { _, _ in onChange() } + } +} + +struct FirecrawlProviderObservers: ViewModifier { + let firecrawlExtractContent: Bool + let firecrawlCountry: String + let firecrawlLanguage: String + let firecrawlSourcesRaw: String + let onChange: () -> Void + + func body(content: Content) -> some View { + content + .onChange(of: firecrawlExtractContent) { _, _ in onChange() } + .onChange(of: firecrawlCountry) { _, _ in onChange() } + .onChange(of: firecrawlLanguage) { _, _ in onChange() } + .onChange(of: firecrawlSourcesRaw) { _, _ in onChange() } + } +} + +struct TavilyProviderObservers: ViewModifier { + let tavilySearchDepth: String + let tavilyTopic: String + let tavilyCountry: String + let tavilyAutoParameters: Bool + let onChange: () -> Void + + func body(content: Content) -> some View { + content + .onChange(of: tavilySearchDepth) { _, _ in onChange() } + .onChange(of: tavilyTopic) { _, _ in onChange() } + .onChange(of: tavilyCountry) { _, _ in onChange() } + .onChange(of: tavilyAutoParameters) { _, _ in onChange() } + } +} + +struct PerplexityProviderObservers: ViewModifier { + let perplexityCountry: String + let perplexityLanguage: String + let onChange: () -> Void + + func body(content: Content) -> some View { + content + .onChange(of: perplexityCountry) { _, _ in onChange() } + .onChange(of: perplexityLanguage) { _, _ in onChange() } + } +} + +private func exaSearchTypeLabel(for value: ExaSearchType) -> String { + switch value { + case .auto: return "Auto" + case .fast: return "Fast" + case .neural: return "Neural" + case .deepLite: return "Deep Lite" + case .deep: return "Deep" + case .deepReasoning: return "Deep Reasoning" + case .instant: return "Instant" + } +} + +private func exaCategoryLabel(for value: ExaCategory) -> String { + switch value { + case .company: return "Company" + case .researchPaper: return "Research paper" + case .news: return "News" + case .personalSite: return "Personal site" + case .financialReport: return "Financial report" + case .people: return "People" } } diff --git a/Sources/UI/WebSearchPluginSettingsStore.swift b/Sources/UI/WebSearchPluginSettingsStore.swift index 2ac73190..7e8407b2 100644 --- a/Sources/UI/WebSearchPluginSettingsStore.swift +++ b/Sources/UI/WebSearchPluginSettingsStore.swift @@ -13,18 +13,32 @@ struct WebSearchPluginSettings: Sendable { var firecrawlAPIKey: String var exaSearchType: ExaSearchType? + var exaCategory: String? + var exaUserLocation: String? + var exaModeration: Bool var braveCountry: String? var braveLanguage: String? var braveSafesearch: String? var jinaReadPages: Bool + var jinaCountry: String? + var jinaLocale: String? + var firecrawlExtractContent: Bool + var firecrawlCountry: String? + var firecrawlLanguage: String? + var firecrawlSources: [FirecrawlSourceKind] var tavilyAPIKey: String var perplexityAPIKey: String var tavilySearchDepth: String? // "basic" | "fast" | "advanced" | "ultra-fast" var tavilyTopic: String? // "general" | "news" | "finance" + var tavilyCountry: String? + var tavilyAutoParameters: Bool + + var perplexityCountry: String? + var perplexityLanguage: String? func apiKey(for provider: SearchPluginProvider) -> String { switch provider { @@ -87,15 +101,27 @@ enum WebSearchPluginSettingsStore { jinaAPIKey: trimmedPreference(AppPreferenceKeys.pluginWebSearchJinaAPIKey) ?? "", firecrawlAPIKey: trimmedPreference(AppPreferenceKeys.pluginWebSearchFirecrawlAPIKey) ?? "", exaSearchType: exaType, + exaCategory: trimmedPreference(AppPreferenceKeys.pluginWebSearchExaCategory), + exaUserLocation: trimmedPreference(AppPreferenceKeys.pluginWebSearchExaUserLocation), + exaModeration: defaults.bool(forKey: AppPreferenceKeys.pluginWebSearchExaModeration), braveCountry: braveCountry, braveLanguage: braveLanguage, braveSafesearch: braveSafesearch, jinaReadPages: defaults.object(forKey: AppPreferenceKeys.pluginWebSearchJinaReadPages) as? Bool ?? true, + jinaCountry: trimmedPreference(AppPreferenceKeys.pluginWebSearchJinaCountry), + jinaLocale: trimmedPreference(AppPreferenceKeys.pluginWebSearchJinaLocale), firecrawlExtractContent: defaults.object(forKey: AppPreferenceKeys.pluginWebSearchFirecrawlExtractContent) as? Bool ?? true, + firecrawlCountry: trimmedPreference(AppPreferenceKeys.pluginWebSearchFirecrawlCountry), + firecrawlLanguage: trimmedPreference(AppPreferenceKeys.pluginWebSearchFirecrawlLanguage), + firecrawlSources: decodeFirecrawlSources(defaults.string(forKey: AppPreferenceKeys.pluginWebSearchFirecrawlSources)), tavilyAPIKey: trimmedPreference(AppPreferenceKeys.pluginWebSearchTavilyAPIKey) ?? "", perplexityAPIKey: trimmedPreference(AppPreferenceKeys.pluginWebSearchPerplexityAPIKey) ?? "", tavilySearchDepth: tavilySearchDepth, - tavilyTopic: tavilyTopic + tavilyTopic: tavilyTopic, + tavilyCountry: trimmedPreference(AppPreferenceKeys.pluginWebSearchTavilyCountry), + tavilyAutoParameters: defaults.bool(forKey: AppPreferenceKeys.pluginWebSearchTavilyAutoParameters), + perplexityCountry: trimmedPreference(AppPreferenceKeys.pluginWebSearchPerplexityCountry), + perplexityLanguage: trimmedPreference(AppPreferenceKeys.pluginWebSearchPerplexityLanguage) ) } @@ -107,4 +133,28 @@ enum WebSearchPluginSettingsStore { static func hasConfiguredProvider(_ provider: SearchPluginProvider, defaults: UserDefaults = .standard) -> Bool { load(defaults: defaults).hasConfiguredCredential(for: provider) } + + static func encodeFirecrawlSources(_ kinds: [FirecrawlSourceKind]) -> String { + guard !kinds.isEmpty else { return "" } + let raw = kinds.map(\.rawValue) + guard let data = try? JSONEncoder().encode(raw), + let string = String(data: data, encoding: .utf8) else { + return "" + } + return string + } + + static func firecrawlSourceSelection(from stored: String?) -> [FirecrawlSourceKind] { + let decoded = decodeFirecrawlSources(stored) + return decoded.isEmpty ? [.web] : decoded + } + + private static func decodeFirecrawlSources(_ stored: String?) -> [FirecrawlSourceKind] { + guard let stored = stored?.trimmedNonEmpty, + let data = stored.data(using: .utf8), + let raw = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + return raw.compactMap(FirecrawlSourceKind.init(rawValue:)) + } } diff --git a/Sources/UI/WebSearchPluginSettingsView.swift b/Sources/UI/WebSearchPluginSettingsView.swift index 472f3e32..9a655179 100644 --- a/Sources/UI/WebSearchPluginSettingsView.swift +++ b/Sources/UI/WebSearchPluginSettingsView.swift @@ -14,13 +14,25 @@ struct WebSearchPluginSettingsView: View { @AppStorage(AppPreferenceKeys.pluginWebSearchPerplexityAPIKey) private var perplexityAPIKey = "" @AppStorage(AppPreferenceKeys.pluginWebSearchExaSearchType) private var exaSearchTypeRaw = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchExaCategory) private var exaCategory = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchExaUserLocation) private var exaUserLocation = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchExaModeration) private var exaModeration = false @AppStorage(AppPreferenceKeys.pluginWebSearchBraveCountry) private var braveCountry = "" @AppStorage(AppPreferenceKeys.pluginWebSearchBraveLanguage) private var braveLanguage = "" @AppStorage(AppPreferenceKeys.pluginWebSearchBraveSafesearch) private var braveSafesearch = "" @AppStorage(AppPreferenceKeys.pluginWebSearchJinaReadPages) private var jinaReadPages = true + @AppStorage(AppPreferenceKeys.pluginWebSearchJinaCountry) private var jinaCountry = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchJinaLocale) private var jinaLocale = "" @AppStorage(AppPreferenceKeys.pluginWebSearchFirecrawlExtractContent) private var firecrawlExtractContent = true + @AppStorage(AppPreferenceKeys.pluginWebSearchFirecrawlCountry) private var firecrawlCountry = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchFirecrawlLanguage) private var firecrawlLanguage = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchFirecrawlSources) private var firecrawlSourcesRaw = "" @AppStorage(AppPreferenceKeys.pluginWebSearchTavilySearchDepth) private var tavilySearchDepth = "basic" @AppStorage(AppPreferenceKeys.pluginWebSearchTavilyTopic) private var tavilyTopic = "general" + @AppStorage(AppPreferenceKeys.pluginWebSearchTavilyCountry) private var tavilyCountry = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchTavilyAutoParameters) private var tavilyAutoParameters = false + @AppStorage(AppPreferenceKeys.pluginWebSearchPerplexityCountry) private var perplexityCountry = "" + @AppStorage(AppPreferenceKeys.pluginWebSearchPerplexityLanguage) private var perplexityLanguage = "" @State private var isExaKeyVisible = false @State private var isBraveKeyVisible = false @@ -60,14 +72,44 @@ struct WebSearchPluginSettingsView: View { var body: some View { formContentWithAPIKeyObservers - .onChange(of: exaSearchTypeRaw) { _, _ in notifyCredentialsChanged() } - .onChange(of: braveCountry) { _, _ in notifyCredentialsChanged() } - .onChange(of: braveLanguage) { _, _ in notifyCredentialsChanged() } - .onChange(of: braveSafesearch) { _, _ in notifyCredentialsChanged() } - .onChange(of: jinaReadPages) { _, _ in notifyCredentialsChanged() } - .onChange(of: firecrawlExtractContent) { _, _ in notifyCredentialsChanged() } - .onChange(of: tavilySearchDepth) { _, _ in notifyCredentialsChanged() } - .onChange(of: tavilyTopic) { _, _ in notifyCredentialsChanged() } + .modifier(ExaProviderObservers( + exaSearchTypeRaw: exaSearchTypeRaw, + exaCategory: exaCategory, + exaUserLocation: exaUserLocation, + exaModeration: exaModeration, + onChange: notifyCredentialsChanged + )) + .modifier(BraveProviderObservers( + braveCountry: braveCountry, + braveLanguage: braveLanguage, + braveSafesearch: braveSafesearch, + onChange: notifyCredentialsChanged + )) + .modifier(JinaProviderObservers( + jinaReadPages: jinaReadPages, + jinaCountry: jinaCountry, + jinaLocale: jinaLocale, + onChange: notifyCredentialsChanged + )) + .modifier(FirecrawlProviderObservers( + firecrawlExtractContent: firecrawlExtractContent, + firecrawlCountry: firecrawlCountry, + firecrawlLanguage: firecrawlLanguage, + firecrawlSourcesRaw: firecrawlSourcesRaw, + onChange: notifyCredentialsChanged + )) + .modifier(TavilyProviderObservers( + tavilySearchDepth: tavilySearchDepth, + tavilyTopic: tavilyTopic, + tavilyCountry: tavilyCountry, + tavilyAutoParameters: tavilyAutoParameters, + onChange: notifyCredentialsChanged + )) + .modifier(PerplexityProviderObservers( + perplexityCountry: perplexityCountry, + perplexityLanguage: perplexityLanguage, + onChange: notifyCredentialsChanged + )) .onAppear { initializeCredentialEditorProviderIfNeeded() } @@ -184,13 +226,25 @@ struct WebSearchPluginSettingsView: View { WebSearchAdvancedProviderSettingsView( provider: defaultProvider, exaSearchTypeRaw: $exaSearchTypeRaw, + exaCategory: $exaCategory, + exaUserLocation: $exaUserLocation, + exaModeration: $exaModeration, braveCountry: $braveCountry, braveLanguage: $braveLanguage, braveSafesearch: $braveSafesearch, jinaReadPages: $jinaReadPages, + jinaCountry: $jinaCountry, + jinaLocale: $jinaLocale, firecrawlExtractContent: $firecrawlExtractContent, + firecrawlCountry: $firecrawlCountry, + firecrawlLanguage: $firecrawlLanguage, + firecrawlSourcesRaw: $firecrawlSourcesRaw, tavilySearchDepth: $tavilySearchDepth, - tavilyTopic: $tavilyTopic + tavilyTopic: $tavilyTopic, + tavilyCountry: $tavilyCountry, + tavilyAutoParameters: $tavilyAutoParameters, + perplexityCountry: $perplexityCountry, + perplexityLanguage: $perplexityLanguage ) } diff --git a/Tests/JinTests/BraveSearchAPITests.swift b/Tests/JinTests/BraveSearchAPITests.swift index 394bffa5..62ae2666 100644 --- a/Tests/JinTests/BraveSearchAPITests.swift +++ b/Tests/JinTests/BraveSearchAPITests.swift @@ -66,5 +66,29 @@ final class BraveSearchAPITests: XCTestCase { let queryItems = components.queryItems ?? [] XCTAssertFalse(queryItems.contains(where: { $0.name == "extra_snippets" })) } + + func testMakeWebSearchURLPassesCustomDateRangeFreshnessVerbatim() throws { + let url = try XCTUnwrap( + BraveSearchAPI.makeWebSearchURL( + query: "test", + count: 10, + freshness: "2026-01-01to2026-02-01" + ) + ) + + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let queryItems = components.queryItems ?? [] + XCTAssertTrue(queryItems.contains(where: { + $0.name == "freshness" && $0.value == "2026-01-01to2026-02-01" + })) + } + + func testBraveDateRangeFreshnessProducesUTCWindow() { + let now = ISO8601DateFormatter().date(from: "2026-05-09T12:34:56Z")! + + let value = BuiltinSearchToolHub.braveDateRangeFreshness(recencyDays: 7, now: now) + + XCTAssertEqual(value, "2026-05-02to2026-05-09") + } } diff --git a/Tests/JinTests/BuiltinSearchExaPayloadTests.swift b/Tests/JinTests/BuiltinSearchExaPayloadTests.swift new file mode 100644 index 00000000..ba5bb08d --- /dev/null +++ b/Tests/JinTests/BuiltinSearchExaPayloadTests.swift @@ -0,0 +1,248 @@ +import XCTest +@testable import Jin + +final class BuiltinSearchExaPayloadTests: XCTestCase { + + // MARK: - Search type + + func testExaBodyEmitsNewDeepLiteSearchType() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(exaSearchType: .deepLite), + overrides: nil + ) + + XCTAssertEqual(body["type"] as? String, "deep-lite") + } + + func testExaBodyEmitsNewDeepReasoningSearchType() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(exaSearchType: .deepReasoning), + overrides: nil + ) + + XCTAssertEqual(body["type"] as? String, "deep-reasoning") + } + + // MARK: - Category / userLocation / moderation + + func testExaBodyOmitsOptionalFieldsByDefault() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertNil(body["category"]) + XCTAssertNil(body["userLocation"]) + XCTAssertNil(body["moderation"]) + } + + func testExaBodyEmitsCategoryWhenSetInSettings() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(exaCategory: "research paper"), + overrides: nil + ) + + XCTAssertEqual(body["category"] as? String, "research paper") + } + + func testExaBodyOverrideCategoryWinsOverSettings() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(exaCategory: "company"), + overrides: SearchPluginControls(exaCategory: "people") + ) + + XCTAssertEqual(body["category"] as? String, "people") + } + + func testExaBodyDropsUnknownCategory() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(exaCategory: "garbage"), + overrides: nil + ) + + XCTAssertNil(body["category"], "Invalid categories must be silently dropped to avoid 400s.") + } + + func testExaBodyEmitsUserLocation() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(exaUserLocation: "DE"), + overrides: nil + ) + + XCTAssertEqual(body["userLocation"] as? String, "DE") + } + + func testExaBodyEmitsModerationWhenEnabled() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(exaModeration: true), + overrides: nil + ) + + XCTAssertEqual(body["moderation"] as? Bool, true) + } + + // MARK: - Structured contents + + func testExaBodyOmitsContentsByDefault() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertNil(body["contents"]) + } + + func testExaBodyEmitsStructuredContentsWhenIncludeRawContent() throws { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(includeRawContent: true), + settings: makeSettings(), + overrides: nil + ) + + let contents = try XCTUnwrap(body["contents"] as? [String: Any]) + let text = try XCTUnwrap(contents["text"] as? [String: Any]) + XCTAssertEqual(text["maxCharacters"] as? Int, 8_000) + XCTAssertEqual(text["verbosity"] as? String, "compact") + XCTAssertNil(text["maxAgeHours"]) + } + + func testExaBodyEmitsMaxAgeHoursDerivedFromRecency() throws { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(recencyDays: 7, includeRawContent: true), + settings: makeSettings(), + overrides: nil + ) + + let contents = try XCTUnwrap(body["contents"] as? [String: Any]) + let text = try XCTUnwrap(contents["text"] as? [String: Any]) + XCTAssertNil(text["maxAgeHours"]) + XCTAssertEqual(contents["maxAgeHours"] as? Int, 168) + } + + // MARK: - Recency / domains + + func testExaBodyEmitsStartPublishedDateWhenRecencySet() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(recencyDays: 30), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertNotNil(body["startPublishedDate"] as? String) + } + + func testExaBodyEmitsDomainArrays() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs(includeDomains: ["a.com"], excludeDomains: ["b.com"]), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertEqual(body["includeDomains"] as? [String], ["a.com"]) + XCTAssertEqual(body["excludeDomains"] as? [String], ["b.com"]) + } + + func testExaBodySuppressesUnsupportedFiltersForCompanyCategory() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs( + recencyDays: 7, + includeDomains: ["exa.ai"], + excludeDomains: ["example.com"] + ), + settings: makeSettings(exaCategory: "company"), + overrides: nil + ) + + XCTAssertEqual(body["category"] as? String, "company") + XCTAssertEqual(body["includeDomains"] as? [String], ["exa.ai"]) + XCTAssertNil(body["excludeDomains"]) + XCTAssertNil(body["startPublishedDate"]) + } + + func testExaBodyFiltersPeopleIncludeDomainsToLinkedInAndDropsUnsupportedFilters() { + let body = BuiltinSearchToolHub.makeExaRequestBody( + args: makeArgs( + recencyDays: 7, + includeDomains: ["linkedin.com", "example.com", "www.linkedin.com"], + excludeDomains: ["example.com"] + ), + settings: makeSettings(exaCategory: "people"), + overrides: nil + ) + + XCTAssertEqual(body["category"] as? String, "people") + XCTAssertEqual(body["includeDomains"] as? [String], ["linkedin.com", "www.linkedin.com"]) + XCTAssertNil(body["excludeDomains"]) + XCTAssertNil(body["startPublishedDate"]) + } + + // MARK: - Fixtures + + private func makeArgs( + query: String = "swift", + maxResults: Int = 8, + recencyDays: Int? = nil, + includeDomains: [String] = [], + excludeDomains: [String] = [], + includeRawContent: Bool = false + ) -> BuiltinSearchToolHub.ResolvedArguments { + BuiltinSearchToolHub.ResolvedArguments( + query: query, + maxResults: maxResults, + recencyDays: recencyDays, + includeRawContent: includeRawContent, + fetchPageContent: false, + includeDomains: includeDomains, + excludeDomains: excludeDomains + ) + } + + private func makeSettings( + exaSearchType: ExaSearchType? = nil, + exaCategory: String? = nil, + exaUserLocation: String? = nil, + exaModeration: Bool = false + ) -> WebSearchPluginSettings { + WebSearchPluginSettings( + isEnabled: true, + defaultProvider: .exa, + defaultMaxResults: 8, + defaultRecencyDays: nil, + exaAPIKey: "", + braveAPIKey: "", + jinaAPIKey: "", + firecrawlAPIKey: "", + exaSearchType: exaSearchType, + exaCategory: exaCategory, + exaUserLocation: exaUserLocation, + exaModeration: exaModeration, + braveCountry: nil, + braveLanguage: nil, + braveSafesearch: nil, + jinaReadPages: false, + jinaCountry: nil, + jinaLocale: nil, + firecrawlExtractContent: false, + firecrawlCountry: nil, + firecrawlLanguage: nil, + firecrawlSources: [], + tavilyAPIKey: "", + perplexityAPIKey: "", + tavilySearchDepth: nil, + tavilyTopic: nil, + tavilyCountry: nil, + tavilyAutoParameters: false, + perplexityCountry: nil, + perplexityLanguage: nil + ) + } +} diff --git a/Tests/JinTests/BuiltinSearchFirecrawlPayloadTests.swift b/Tests/JinTests/BuiltinSearchFirecrawlPayloadTests.swift new file mode 100644 index 00000000..34679dd3 --- /dev/null +++ b/Tests/JinTests/BuiltinSearchFirecrawlPayloadTests.swift @@ -0,0 +1,230 @@ +import XCTest +@testable import Jin + +final class BuiltinSearchFirecrawlPayloadTests: XCTestCase { + + // MARK: - Country + + func testFirecrawlBodyUsesFirecrawlCountryNotBraveCountry() throws { + let settings = makeSettings(braveCountry: "US", firecrawlCountry: "DE") + + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: settings, + overrides: nil + ) + + XCTAssertEqual(body["country"] as? String, "DE", + "Firecrawl must read its own country pref, not Brave's.") + } + + func testFirecrawlBodyOmitsCountryWhenOnlyBraveCountrySet() throws { + let settings = makeSettings(braveCountry: "US", firecrawlCountry: nil) + + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: settings, + overrides: nil + ) + + XCTAssertNil(body["country"], "Brave country must not bleed into Firecrawl.") + } + + func testFirecrawlBodyHonoursOverrideCountry() { + let settings = makeSettings(firecrawlCountry: "DE") + let overrides = SearchPluginControls(firecrawlCountry: "JP") + + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: settings, + overrides: overrides + ) + + XCTAssertEqual(body["country"] as? String, "JP") + } + + // MARK: - Domain operators + + func testFirecrawlBodyAugmentsQueryWithSiteOperators() { + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs( + query: "swift concurrency", + includeDomains: ["apple.com", "swift.org"], + excludeDomains: ["medium.com"] + ), + settings: makeSettings(), + overrides: nil + ) + + let augmented = body["query"] as? String + XCTAssertEqual(augmented, "swift concurrency (site:apple.com OR site:swift.org) -site:medium.com") + } + + func testFirecrawlBodyOmitsParensForSingleIncludeDomain() { + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift", includeDomains: ["apple.com"]), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertEqual(body["query"] as? String, "swift site:apple.com") + } + + func testFirecrawlBodyDoesNotAugmentWhenNoDomainFilters() { + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertEqual(body["query"] as? String, "swift") + } + + // MARK: - Sources + + func testFirecrawlBodyOmitsSourcesWhenUnset() { + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertNil(body["sources"]) + } + + func testFirecrawlBodyEncodesSourcesAsTypeObjects() { + let settings = makeSettings(firecrawlSources: [.web, .news]) + + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: settings, + overrides: nil + ) + + let sources = try? XCTUnwrap(body["sources"] as? [[String: String]]) + XCTAssertEqual(sources, [["type": "web"], ["type": "news"]]) + } + + // MARK: - Defensive flags + + func testFirecrawlBodyAlwaysSendsIgnoreInvalidURLs() { + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertEqual(body["ignoreInvalidURLs"] as? Bool, true) + } + + func testFirecrawlBodyIncludesLanguageWhenSet() { + let settings = makeSettings(firecrawlLanguage: "en") + + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift"), + settings: settings, + overrides: nil + ) + + XCTAssertEqual(body["lang"] as? String, "en") + } + + func testFirecrawlBodyMapsRecencyDaysToTBS() { + let body = BuiltinSearchToolHub.makeFirecrawlRequestBody( + args: makeArgs(query: "swift", recencyDays: 7), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertEqual(body["tbs"] as? String, "qdr:w") + } + + // MARK: - Result mapping + + func testFirecrawlRowsDeduplicateByURLBeforeApplyingCap() { + let rows = BuiltinSearchToolHub.makeFirecrawlRows( + from: [ + ["url": "https://example.com/a", "title": "A"], + ["url": "https://example.com/a", "title": "A duplicate"], + ["url": "https://example.com/b", "title": "B"] + ], + maxResults: 2 + ) + + XCTAssertEqual(rows.map(\.url), ["https://example.com/a", "https://example.com/b"]) + } + + func testFirecrawlRowsUseImageURLWhenURLIsMissing() { + let rows = BuiltinSearchToolHub.makeFirecrawlRows( + from: [ + ["imageUrl": "https://example.com/image.png", "title": "Image"] + ], + maxResults: 1 + ) + + XCTAssertEqual(rows.first?.url, "https://example.com/image.png") + XCTAssertEqual(rows.first?.source, "example.com") + } + + // MARK: - Fixtures + + private func makeArgs( + query: String, + maxResults: Int = 8, + recencyDays: Int? = nil, + includeDomains: [String] = [], + excludeDomains: [String] = [], + includeRawContent: Bool = false + ) -> BuiltinSearchToolHub.ResolvedArguments { + BuiltinSearchToolHub.ResolvedArguments( + query: query, + maxResults: maxResults, + recencyDays: recencyDays, + includeRawContent: includeRawContent, + fetchPageContent: false, + includeDomains: includeDomains, + excludeDomains: excludeDomains + ) + } + + private func makeSettings( + braveCountry: String? = nil, + firecrawlCountry: String? = nil, + firecrawlLanguage: String? = nil, + firecrawlSources: [FirecrawlSourceKind] = [], + firecrawlExtractContent: Bool = false + ) -> WebSearchPluginSettings { + WebSearchPluginSettings( + isEnabled: true, + defaultProvider: .firecrawl, + defaultMaxResults: 8, + defaultRecencyDays: nil, + exaAPIKey: "", + braveAPIKey: "", + jinaAPIKey: "", + firecrawlAPIKey: "", + exaSearchType: nil, + exaCategory: nil, + exaUserLocation: nil, + exaModeration: false, + braveCountry: braveCountry, + braveLanguage: nil, + braveSafesearch: nil, + jinaReadPages: false, + jinaCountry: nil, + jinaLocale: nil, + firecrawlExtractContent: firecrawlExtractContent, + firecrawlCountry: firecrawlCountry, + firecrawlLanguage: firecrawlLanguage, + firecrawlSources: firecrawlSources, + tavilyAPIKey: "", + perplexityAPIKey: "", + tavilySearchDepth: nil, + tavilyTopic: nil, + tavilyCountry: nil, + tavilyAutoParameters: false, + perplexityCountry: nil, + perplexityLanguage: nil + ) + } +} diff --git a/Tests/JinTests/BuiltinSearchJinaRequestTests.swift b/Tests/JinTests/BuiltinSearchJinaRequestTests.swift new file mode 100644 index 00000000..507c125d --- /dev/null +++ b/Tests/JinTests/BuiltinSearchJinaRequestTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import Jin + +final class BuiltinSearchJinaRequestTests: XCTestCase { + + func testJinaRequestUsesPOSTToCanonicalEndpoint() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(), + settings: makeSettings(), + apiKey: "key" + ) + + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://s.jina.ai/") + } + + func testJinaRequestSetsBearerAuthAndJSONContentType() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(), + settings: makeSettings(), + apiKey: "abc123" + ) + + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer abc123") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") + } + + func testJinaRequestSendsNoContentHeaderWhenFetchPageContentDisabled() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(fetchPageContent: false), + settings: makeSettings(), + apiKey: "key" + ) + + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Respond-With"), "no-content", + "Fast path should request a content-free response.") + } + + func testJinaRequestOmitsNoContentHeaderWhenFetchPageContentEnabled() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(fetchPageContent: true), + settings: makeSettings(), + apiKey: "key" + ) + + XCTAssertNil(request.value(forHTTPHeaderField: "X-Respond-With")) + } + + func testJinaRequestSendsLinksAndAltHeadersForRawContent() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(includeRawContent: true), + settings: makeSettings(), + apiKey: "key" + ) + + XCTAssertEqual(request.value(forHTTPHeaderField: "X-With-Generated-Alt"), "true") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-With-Links-Summary"), "true") + } + + func testJinaRequestSetsXSiteForFirstIncludeDomain() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(includeDomains: ["apple.com", "swift.org"]), + settings: makeSettings(), + apiKey: "key" + ) + + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Site"), "apple.com") + } + + func testJinaRequestAppendsRemainingIncludeDomainsToBodyQuery() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(query: "swift", includeDomains: ["apple.com", "swift.org", "kavsoft.dev"]), + settings: makeSettings(), + apiKey: "key" + ) + + let body = try XCTUnwrap(request.httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + let q = try XCTUnwrap(json?["q"] as? String) + XCTAssertEqual(q, "swift (site:swift.org OR site:kavsoft.dev)") + } + + func testJinaRequestLeavesSingleSpilloverDomainUngrouped() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(query: "swift", includeDomains: ["apple.com", "swift.org"]), + settings: makeSettings(), + apiKey: "key" + ) + + let body = try XCTUnwrap(request.httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + let q = try XCTUnwrap(json?["q"] as? String) + XCTAssertEqual(q, "swift site:swift.org") + } + + func testJinaRequestUsesCleanQueryWhenNoIncludeDomains() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(query: "swift"), + settings: makeSettings(), + apiKey: "key" + ) + + let body = try XCTUnwrap(request.httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["q"] as? String, "swift") + } + + func testJinaRequestCarriesLocaleAndCountryWhenSet() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(), + settings: makeSettings(jinaCountry: "DE", jinaLocale: "de-DE"), + apiKey: "key" + ) + + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Locale"), "de-DE") + XCTAssertNil(request.value(forHTTPHeaderField: "X-Country")) + + let body = try XCTUnwrap(request.httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["gl"] as? String, "DE") + } + + func testJinaRequestUsesBrowserEngineByDefault() throws { + let request = try BuiltinSearchToolHub.makeJinaRequest( + args: makeArgs(), + settings: makeSettings(), + apiKey: "key" + ) + + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Engine"), "browser") + } + + // MARK: - Response parsing + + func testJinaResponseExtractorHandlesDataKey() { + let response: [String: Any] = [ + "data": [ + ["url": "https://a.com", "title": "A", "content": "..."] + ] + ] + + let results = BuiltinSearchToolHub.extractJinaResults(from: response) + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?["url"] as? String, "https://a.com") + } + + func testJinaResponseExtractorHandlesBareArray() { + let response: [[String: Any]] = [["url": "https://a.com"]] + let results = BuiltinSearchToolHub.extractJinaResults(from: response) + XCTAssertEqual(results.count, 1) + } + + // MARK: - Fixtures + + private func makeArgs( + query: String = "swift", + fetchPageContent: Bool = false, + includeRawContent: Bool = false, + includeDomains: [String] = [] + ) -> BuiltinSearchToolHub.ResolvedArguments { + BuiltinSearchToolHub.ResolvedArguments( + query: query, + maxResults: 5, + recencyDays: nil, + includeRawContent: includeRawContent, + fetchPageContent: fetchPageContent, + includeDomains: includeDomains, + excludeDomains: [] + ) + } + + private func makeSettings( + jinaCountry: String? = nil, + jinaLocale: String? = nil + ) -> WebSearchPluginSettings { + WebSearchPluginSettings( + isEnabled: true, + defaultProvider: .jina, + defaultMaxResults: 5, + defaultRecencyDays: nil, + exaAPIKey: "", + braveAPIKey: "", + jinaAPIKey: "", + firecrawlAPIKey: "", + exaSearchType: nil, + exaCategory: nil, + exaUserLocation: nil, + exaModeration: false, + braveCountry: nil, + braveLanguage: nil, + braveSafesearch: nil, + jinaReadPages: false, + jinaCountry: jinaCountry, + jinaLocale: jinaLocale, + firecrawlExtractContent: false, + firecrawlCountry: nil, + firecrawlLanguage: nil, + firecrawlSources: [], + tavilyAPIKey: "", + perplexityAPIKey: "", + tavilySearchDepth: nil, + tavilyTopic: nil, + tavilyCountry: nil, + tavilyAutoParameters: false, + perplexityCountry: nil, + perplexityLanguage: nil + ) + } +} diff --git a/Tests/JinTests/BuiltinSearchPerplexityPayloadTests.swift b/Tests/JinTests/BuiltinSearchPerplexityPayloadTests.swift new file mode 100644 index 00000000..94c18ccc --- /dev/null +++ b/Tests/JinTests/BuiltinSearchPerplexityPayloadTests.swift @@ -0,0 +1,132 @@ +import XCTest +@testable import Jin + +final class BuiltinSearchPerplexityPayloadTests: XCTestCase { + + func testPerplexityBodyEmitsPreciseDateFilterReplacingRecencyFilter() { + let body = BuiltinSearchToolHub.makePerplexityRequestBody( + args: makeArgs(recencyDays: 7), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertNil(body["search_recency_filter"], + "Coarse recency filter must be replaced by precise date filter.") + XCTAssertNotNil(body["search_after_date_filter"] as? String) + } + + func testPerplexityDateFilterFormatsAsMMDDYYYYInUTC() { + let now = ISO8601DateFormatter().date(from: "2026-05-09T12:34:56Z")! + let value = BuiltinSearchToolHub.perplexityDateFilter(daysAgo: 7, now: now) + XCTAssertEqual(value, "05/02/2026") + } + + func testPerplexityBodyEmitsCountryWhenSet() { + let body = BuiltinSearchToolHub.makePerplexityRequestBody( + args: makeArgs(), + settings: makeSettings(perplexityCountry: "DE"), + overrides: nil + ) + + XCTAssertEqual(body["country"] as? String, "DE") + } + + func testPerplexityBodyEmitsSearchLanguageFilterAsArrayWhenSet() { + let body = BuiltinSearchToolHub.makePerplexityRequestBody( + args: makeArgs(), + settings: makeSettings(perplexityLanguage: "en"), + overrides: nil + ) + + XCTAssertEqual(body["search_language_filter"] as? [String], ["en"]) + } + + func testPerplexityBodyEmitsMaxTokensOnlyForRawContent() { + let withRaw = BuiltinSearchToolHub.makePerplexityRequestBody( + args: makeArgs(includeRawContent: true), + settings: makeSettings(), + overrides: nil + ) + XCTAssertEqual(withRaw["max_tokens"] as? Int, 4_096) + + let withoutRaw = BuiltinSearchToolHub.makePerplexityRequestBody( + args: makeArgs(includeRawContent: false), + settings: makeSettings(), + overrides: nil + ) + XCTAssertNil(withoutRaw["max_tokens"]) + } + + func testPerplexityDomainFilterFavoursIncludeOverExclude() { + let filter = BuiltinSearchToolHub.perplexityDomainFilter( + includeDomains: ["a.com"], + excludeDomains: ["b.com"] + ) + + XCTAssertEqual(filter, ["a.com"]) + } + + func testPerplexityDomainFilterFallsBackToNegatedExcludes() { + let filter = BuiltinSearchToolHub.perplexityDomainFilter( + includeDomains: [], + excludeDomains: ["b.com", "c.com"] + ) + + XCTAssertEqual(filter, ["-b.com", "-c.com"]) + } + + // MARK: - Fixtures + + private func makeArgs( + recencyDays: Int? = nil, + includeRawContent: Bool = false + ) -> BuiltinSearchToolHub.ResolvedArguments { + BuiltinSearchToolHub.ResolvedArguments( + query: "swift", + maxResults: 8, + recencyDays: recencyDays, + includeRawContent: includeRawContent, + fetchPageContent: false, + includeDomains: [], + excludeDomains: [] + ) + } + + private func makeSettings( + perplexityCountry: String? = nil, + perplexityLanguage: String? = nil + ) -> WebSearchPluginSettings { + WebSearchPluginSettings( + isEnabled: true, + defaultProvider: .perplexity, + defaultMaxResults: 8, + defaultRecencyDays: nil, + exaAPIKey: "", + braveAPIKey: "", + jinaAPIKey: "", + firecrawlAPIKey: "", + exaSearchType: nil, + exaCategory: nil, + exaUserLocation: nil, + exaModeration: false, + braveCountry: nil, + braveLanguage: nil, + braveSafesearch: nil, + jinaReadPages: false, + jinaCountry: nil, + jinaLocale: nil, + firecrawlExtractContent: false, + firecrawlCountry: nil, + firecrawlLanguage: nil, + firecrawlSources: [], + tavilyAPIKey: "", + perplexityAPIKey: "", + tavilySearchDepth: nil, + tavilyTopic: nil, + tavilyCountry: nil, + tavilyAutoParameters: false, + perplexityCountry: perplexityCountry, + perplexityLanguage: perplexityLanguage + ) + } +} diff --git a/Tests/JinTests/BuiltinSearchTavilyPayloadTests.swift b/Tests/JinTests/BuiltinSearchTavilyPayloadTests.swift new file mode 100644 index 00000000..1236a75d --- /dev/null +++ b/Tests/JinTests/BuiltinSearchTavilyPayloadTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import Jin + +final class BuiltinSearchTavilyPayloadTests: XCTestCase { + + func testTavilyBodyOmitsCountryByDefault() { + let body = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertNil(body["country"]) + } + + func testTavilyBodyEmitsCountryOnlyForGeneralTopic() { + let general = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(tavilyCountry: "DE", tavilyTopic: "general"), + overrides: nil + ) + XCTAssertEqual(general["country"] as? String, "germany") + + let news = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(tavilyCountry: "DE", tavilyTopic: "news"), + overrides: nil + ) + XCTAssertNil(news["country"], "Tavily restricts country to general topic.") + } + + func testTavilyBodyMapsCountryNameAndDropsUnsupportedCountry() { + let named = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(tavilyCountry: " United States "), + overrides: nil + ) + XCTAssertEqual(named["country"] as? String, "united states") + + let unsupported = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(tavilyCountry: "XX"), + overrides: nil + ) + XCTAssertNil(unsupported["country"]) + } + + func testTavilyBodyEmitsAutoParametersOnlyWhenEnabled() { + let off = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(), + overrides: nil + ) + XCTAssertNil(off["auto_parameters"]) + + let on = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(tavilyAutoParameters: true), + overrides: nil + ) + XCTAssertEqual(on["auto_parameters"] as? Bool, true) + } + + func testTavilyAutoParametersOmitDefaultedDepthTopicAndDependentFields() { + let body = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings( + tavilyCountry: "DE", + tavilyAutoParameters: true, + tavilySearchDepth: "basic", + tavilyTopic: "general" + ), + overrides: nil + ) + + XCTAssertEqual(body["auto_parameters"] as? Bool, true) + XCTAssertNil(body["search_depth"]) + XCTAssertNil(body["topic"]) + XCTAssertNil(body["country"]) + XCTAssertNil(body["chunks_per_source"]) + } + + func testTavilyAutoParametersKeepExplicitDepthAndTopicOverrides() { + let body = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings( + tavilyCountry: "DE", + tavilyAutoParameters: true, + tavilySearchDepth: "basic", + tavilyTopic: "news" + ), + overrides: SearchPluginControls(tavilySearchDepth: "advanced", tavilyTopic: "general") + ) + + XCTAssertEqual(body["search_depth"] as? String, "advanced") + XCTAssertEqual(body["topic"] as? String, "general") + XCTAssertEqual(body["country"] as? String, "germany") + XCTAssertEqual(body["chunks_per_source"] as? Int, 3) + } + + func testTavilyBodyEmitsChunksPerSourceForAdvancedDepth() { + let advanced = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(tavilySearchDepth: "advanced"), + overrides: nil + ) + XCTAssertEqual(advanced["chunks_per_source"] as? Int, 3) + + let basic = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(tavilySearchDepth: "basic"), + overrides: nil + ) + XCTAssertNil(basic["chunks_per_source"]) + } + + func testTavilyBodyEmitsStartAndEndDatesForRecency() { + let body = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(recencyDays: 7), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertNotNil(body["start_date"] as? String) + XCTAssertNotNil(body["end_date"] as? String) + XCTAssertNil(body["time_range"], "time_range branch is dead after recency cleanup.") + } + + func testTavilyBodyDefaultsTopicToGeneralAndDepthToBasic() { + let body = BuiltinSearchToolHub.makeTavilyRequestBody( + args: makeArgs(), + settings: makeSettings(), + overrides: nil + ) + + XCTAssertEqual(body["topic"] as? String, "general") + XCTAssertEqual(body["search_depth"] as? String, "basic") + } + + func testTavilyDepthValueNormalizesUltraFast() { + XCTAssertEqual(BuiltinSearchToolHub.tavilyDepthValue("ultra-fast"), "ultra-fast") + XCTAssertEqual(BuiltinSearchToolHub.tavilyDepthValue("ultra_fast"), "ultra-fast") + XCTAssertEqual(BuiltinSearchToolHub.tavilyDepthValue("garbage"), "basic") + } + + // MARK: - Fixtures + + private func makeArgs( + recencyDays: Int? = nil + ) -> BuiltinSearchToolHub.ResolvedArguments { + BuiltinSearchToolHub.ResolvedArguments( + query: "swift", + maxResults: 8, + recencyDays: recencyDays, + includeRawContent: false, + fetchPageContent: false, + includeDomains: [], + excludeDomains: [] + ) + } + + private func makeSettings( + tavilyCountry: String? = nil, + tavilyAutoParameters: Bool = false, + tavilySearchDepth: String? = nil, + tavilyTopic: String? = nil + ) -> WebSearchPluginSettings { + WebSearchPluginSettings( + isEnabled: true, + defaultProvider: .tavily, + defaultMaxResults: 8, + defaultRecencyDays: nil, + exaAPIKey: "", + braveAPIKey: "", + jinaAPIKey: "", + firecrawlAPIKey: "", + exaSearchType: nil, + exaCategory: nil, + exaUserLocation: nil, + exaModeration: false, + braveCountry: nil, + braveLanguage: nil, + braveSafesearch: nil, + jinaReadPages: false, + jinaCountry: nil, + jinaLocale: nil, + firecrawlExtractContent: false, + firecrawlCountry: nil, + firecrawlLanguage: nil, + firecrawlSources: [], + tavilyAPIKey: "", + perplexityAPIKey: "", + tavilySearchDepth: tavilySearchDepth, + tavilyTopic: tavilyTopic, + tavilyCountry: tavilyCountry, + tavilyAutoParameters: tavilyAutoParameters, + perplexityCountry: nil, + perplexityLanguage: nil + ) + } +} diff --git a/Tests/JinTests/BuiltinSearchToolHubTests.swift b/Tests/JinTests/BuiltinSearchToolHubTests.swift index 7b3b5928..791b1739 100644 --- a/Tests/JinTests/BuiltinSearchToolHubTests.swift +++ b/Tests/JinTests/BuiltinSearchToolHubTests.swift @@ -179,6 +179,38 @@ final class BuiltinSearchToolHubTests: XCTestCase { XCTAssertTrue(rows.isEmpty) } + func testJinaSearchReturnsEmptyResultWhenMaxResultsIsZero() async throws { + configurePluginDefaults(defaultProvider: .jina, jinaKey: "jina-key") + + let controls = GenerationControls( + webSearch: WebSearchControls(enabled: true), + searchPlugin: SearchPluginControls(provider: .jina, maxResults: 0) + ) + + let (definitions, routes) = await BuiltinSearchToolHub.shared.toolDefinitions( + for: controls, + useBuiltinSearch: true, + defaults: defaults + ) + + let tool = try XCTUnwrap(definitions.first) + let result = try await BuiltinSearchToolHub.shared.executeTool( + functionName: tool.name, + arguments: [ + "query": AnyCodable("swift") + ], + routes: routes + ) + + XCTAssertFalse(result.isError) + let data = Data(result.text.utf8) + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["provider"] as? String, SearchPluginProvider.jina.rawValue) + XCTAssertEqual(json["resultCount"] as? Int, 0) + let rows = try XCTUnwrap(json["results"] as? [[String: Any]]) + XCTAssertTrue(rows.isEmpty) + } + private func configurePluginDefaults( defaultProvider: SearchPluginProvider, exaKey: String = "", @@ -204,6 +236,14 @@ final class BuiltinSearchToolHubTests: XCTestCase { XCTAssertNil(ExaSearchType.resolved(from: nil)) } + func testExaSearchTypeIncludesNewDeepVariants() { + XCTAssertEqual(ExaSearchType.resolved(from: "deep-lite"), .deepLite) + XCTAssertEqual(ExaSearchType.resolved(from: "deep-reasoning"), .deepReasoning) + XCTAssertEqual(ExaSearchType.resolved(from: "deep"), .deep) + XCTAssertTrue(ExaSearchType.publicCases.contains(.deepLite)) + XCTAssertTrue(ExaSearchType.publicCases.contains(.deepReasoning)) + } + func testWebSearchPluginSettingsLoadMapsLegacyExaType() { defaults.set("keyword", forKey: AppPreferenceKeys.pluginWebSearchExaSearchType) defaults.set(true, forKey: AppPreferenceKeys.pluginWebSearchEnabled) diff --git a/Tests/JinTests/ChatAuxiliaryControlSupportTests.swift b/Tests/JinTests/ChatAuxiliaryControlSupportTests.swift index d12f4701..bd1a4390 100644 --- a/Tests/JinTests/ChatAuxiliaryControlSupportTests.swift +++ b/Tests/JinTests/ChatAuxiliaryControlSupportTests.swift @@ -1739,15 +1739,27 @@ final class ChatAuxiliaryControlSupportTests: XCTestCase { jinaAPIKey: "", firecrawlAPIKey: "", exaSearchType: nil, + exaCategory: nil, + exaUserLocation: nil, + exaModeration: false, braveCountry: nil, braveLanguage: nil, braveSafesearch: nil, jinaReadPages: jinaReadPages, + jinaCountry: nil, + jinaLocale: nil, firecrawlExtractContent: firecrawlExtractContent, + firecrawlCountry: nil, + firecrawlLanguage: nil, + firecrawlSources: [], tavilyAPIKey: "", perplexityAPIKey: "", tavilySearchDepth: nil, - tavilyTopic: nil + tavilyTopic: nil, + tavilyCountry: nil, + tavilyAutoParameters: false, + perplexityCountry: nil, + perplexityLanguage: nil ) } } diff --git a/Tests/JinTests/WebSearchPluginSettingsStoreTests.swift b/Tests/JinTests/WebSearchPluginSettingsStoreTests.swift index 6989372f..9a1e27c9 100644 --- a/Tests/JinTests/WebSearchPluginSettingsStoreTests.swift +++ b/Tests/JinTests/WebSearchPluginSettingsStoreTests.swift @@ -39,4 +39,85 @@ final class WebSearchPluginSettingsStoreTests: XCTestCase { XCTAssertEqual(settings.defaultMaxResults, 8) XCTAssertNil(settings.defaultRecencyDays) } + + func testLoadCarriesNewExaPreferences() { + defaults.set("research paper", forKey: AppPreferenceKeys.pluginWebSearchExaCategory) + defaults.set("DE", forKey: AppPreferenceKeys.pluginWebSearchExaUserLocation) + defaults.set(true, forKey: AppPreferenceKeys.pluginWebSearchExaModeration) + + let settings = WebSearchPluginSettingsStore.load(defaults: defaults) + + XCTAssertEqual(settings.exaCategory, "research paper") + XCTAssertEqual(settings.exaUserLocation, "DE") + XCTAssertTrue(settings.exaModeration) + } + + func testLoadFirecrawlCountryIsIndependentFromBraveCountry() { + defaults.set("US", forKey: AppPreferenceKeys.pluginWebSearchBraveCountry) + defaults.set("DE", forKey: AppPreferenceKeys.pluginWebSearchFirecrawlCountry) + + let settings = WebSearchPluginSettingsStore.load(defaults: defaults) + + XCTAssertEqual(settings.braveCountry, "US") + XCTAssertEqual(settings.firecrawlCountry, "DE") + } + + func testLoadFirecrawlSourcesDecodesJSONArray() throws { + let raw = WebSearchPluginSettingsStore.encodeFirecrawlSources([.web, .news]) + defaults.set(raw, forKey: AppPreferenceKeys.pluginWebSearchFirecrawlSources) + + let settings = WebSearchPluginSettingsStore.load(defaults: defaults) + + XCTAssertEqual(settings.firecrawlSources, [.web, .news]) + } + + func testLoadFirecrawlSourcesDefaultsToEmptyForGarbage() { + defaults.set("not-json", forKey: AppPreferenceKeys.pluginWebSearchFirecrawlSources) + + let settings = WebSearchPluginSettingsStore.load(defaults: defaults) + + XCTAssertEqual(settings.firecrawlSources, []) + } + + func testFirecrawlSourceSelectionDefaultsToWebForEmptyOrInvalidStorage() { + XCTAssertEqual(WebSearchPluginSettingsStore.firecrawlSourceSelection(from: ""), [.web]) + XCTAssertEqual(WebSearchPluginSettingsStore.firecrawlSourceSelection(from: "not-json"), [.web]) + XCTAssertEqual(WebSearchPluginSettingsStore.firecrawlSourceSelection(from: "[]"), [.web]) + } + + func testFirecrawlSourceSelectionUsesDecodedSourcesWhenPresent() { + let raw = WebSearchPluginSettingsStore.encodeFirecrawlSources([.news, .images]) + + XCTAssertEqual(WebSearchPluginSettingsStore.firecrawlSourceSelection(from: raw), [.news, .images]) + } + + func testLoadCarriesNewTavilyPreferences() { + defaults.set("DE", forKey: AppPreferenceKeys.pluginWebSearchTavilyCountry) + defaults.set(true, forKey: AppPreferenceKeys.pluginWebSearchTavilyAutoParameters) + + let settings = WebSearchPluginSettingsStore.load(defaults: defaults) + + XCTAssertEqual(settings.tavilyCountry, "DE") + XCTAssertTrue(settings.tavilyAutoParameters) + } + + func testLoadCarriesNewPerplexityPreferences() { + defaults.set("DE", forKey: AppPreferenceKeys.pluginWebSearchPerplexityCountry) + defaults.set("en", forKey: AppPreferenceKeys.pluginWebSearchPerplexityLanguage) + + let settings = WebSearchPluginSettingsStore.load(defaults: defaults) + + XCTAssertEqual(settings.perplexityCountry, "DE") + XCTAssertEqual(settings.perplexityLanguage, "en") + } + + func testLoadCarriesNewJinaPreferences() { + defaults.set("DE", forKey: AppPreferenceKeys.pluginWebSearchJinaCountry) + defaults.set("de-DE", forKey: AppPreferenceKeys.pluginWebSearchJinaLocale) + + let settings = WebSearchPluginSettingsStore.load(defaults: defaults) + + XCTAssertEqual(settings.jinaCountry, "DE") + XCTAssertEqual(settings.jinaLocale, "de-DE") + } }