From 0ccb0c1878a55d41a6b69a5e5750251547828d3c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 8 Aug 2025 23:56:54 +0800 Subject: [PATCH 01/11] Add SerpAPI --- .../SearchFunction.swift | 23 ++---- ...ngSearchView.swift => WebSearchView.swift} | 0 Tool/Package.swift | 6 +- Tool/Sources/Preferences/Keys.swift | 35 ++++++++- .../SearchServices}/BingSearchService.swift | 45 ++++++----- .../SearchServices/SerpAPISearchService.swift | 76 +++++++++++++++++++ .../WebSearchService/WebSearchService.swift | 70 +++++++++++++++++ 7 files changed, 217 insertions(+), 38 deletions(-) rename Core/Sources/HostApp/AccountSettings/{BingSearchView.swift => WebSearchView.swift} (100%) rename Tool/Sources/{BingSearchService => WebSearchService/SearchServices}/BingSearchService.swift (68%) create mode 100644 Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift create mode 100644 Tool/Sources/WebSearchService/WebSearchService.swift diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 56719f5c..3b9c1289 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -1,8 +1,8 @@ -import BingSearchService import ChatBasic import Foundation import OpenAIService import Preferences +import WebSearchService struct SearchFunction: ChatGPTFunction { static let dateFormatter = { @@ -17,13 +17,13 @@ struct SearchFunction: ChatGPTFunction { } struct Result: ChatGPTFunctionResult { - var result: BingSearchResult + var result: WebSearchResult var botReadableContent: String { - result.webPages.value.enumerated().map { + result.webPages.enumerated().map { let (index, page) = $0 return """ - \(index + 1). \(page.name) \(page.url) + \(index + 1). \(page.title) \(page.urlString) \(page.snippet) """ }.joined(separator: "\n") @@ -72,22 +72,15 @@ struct SearchFunction: ChatGPTFunction { await reportProgress("Searching \(arguments.query)") do { - let bingSearch = BingSearchService( - subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey), - searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) - ) + let search = WebSearchService(provider: .userPreferred) - let result = try await bingSearch.search( - query: arguments.query, - numberOfResult: maxTokens > 5000 ? 5 : 3, - freshness: arguments.freshness - ) + let result = try await search.search(query: arguments.query) await reportProgress(""" Finish searching \(arguments.query) \( - result.webPages.value - .map { "- [\($0.name)](\($0.url))" } + result.webPages + .map { "- [\($0.title)](\($0.urlString))" } .joined(separator: "\n") ) """) diff --git a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift similarity index 100% rename from Core/Sources/HostApp/AccountSettings/BingSearchView.swift rename to Core/Sources/HostApp/AccountSettings/WebSearchView.swift diff --git a/Tool/Package.swift b/Tool/Package.swift index a2020a9e..cc2337e3 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -10,7 +10,7 @@ let package = Package( .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), .library(name: "LangChain", targets: ["LangChain"]), - .library(name: "ExternalServices", targets: ["BingSearchService"]), + .library(name: "ExternalServices", targets: ["WebSearchService"]), .library(name: "Preferences", targets: ["Preferences", "Configs"]), .library(name: "Logger", targets: ["Logger"]), .library(name: "OpenAIService", targets: ["OpenAIService"]), @@ -52,7 +52,7 @@ let package = Package( .library(name: "CommandHandler", targets: ["CommandHandler"]), .library(name: "CodeDiff", targets: ["CodeDiff"]), .library(name: "BuiltinExtension", targets: ["BuiltinExtension"]), - .library(name: "BingSearchService", targets: ["BingSearchService"]), + .library(name: "WebSearchService", targets: ["WebSearchService"]), .library( name: "CustomCommandTemplateProcessor", targets: ["CustomCommandTemplateProcessor"] @@ -382,7 +382,7 @@ let package = Package( ] ), - .target(name: "BingSearchService"), + .target(name: "WebSearchService", dependencies: ["Preferences"]), .target(name: "SuggestionProvider", dependencies: [ "SuggestionBasic", diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index b84c2835..9755e400 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -582,14 +582,43 @@ public extension UserDefaultPreferenceKeys { } } -// MARK: - Bing Search +// MARK: - Search public extension UserDefaultPreferenceKeys { - var bingSearchSubscriptionKey: PreferenceKey { + enum SearchProvider: String, Codable, CaseIterable { + case serpAPI + case headlessBrowser + } + + enum SerpAPIEngine: String, Codable, CaseIterable { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + enum HeadlessBrowserEngine: String, Codable, CaseIterable { + case google + case baidu + } + + var searchProvider: PreferenceKey { + .init(defaultValue: .headlessBrowser, key: "SearchProvider") + } + + var serpAPIEngine: PreferenceKey { + .init(defaultValue: .google, key: "SerpAPIEngine") + } + + var headlessBrowserEngine: PreferenceKey { + .init(defaultValue: .google, key: "HeadlessBrowserEngine") + } + + var bingSearchSubscriptionKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "BingSearchSubscriptionKey") } - var bingSearchEndpoint: PreferenceKey { + var bingSearchEndpoint: DeprecatedPreferenceKey { .init( defaultValue: "https://api.bing.microsoft.com/v7.0/search/", key: "BingSearchEndpoint" diff --git a/Tool/Sources/BingSearchService/BingSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift similarity index 68% rename from Tool/Sources/BingSearchService/BingSearchService.swift rename to Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift index e185d268..0f373168 100644 --- a/Tool/Sources/BingSearchService/BingSearchService.swift +++ b/Tool/Sources/WebSearchService/SearchServices/BingSearchService.swift @@ -1,19 +1,19 @@ import Foundation -public struct BingSearchResult: Codable { - public var webPages: WebPages +struct BingSearchResult: Codable { + var webPages: WebPages - public struct WebPages: Codable { - public var webSearchUrl: String - public var totalEstimatedMatches: Int - public var value: [WebPageValue] + struct WebPages: Codable { + var webSearchUrl: String + var totalEstimatedMatches: Int + var value: [WebPageValue] - public struct WebPageValue: Codable { - public var id: String - public var name: String - public var url: String - public var displayUrl: String - public var snippet: String + struct WebPageValue: Codable { + var id: String + var name: String + var url: String + var displayUrl: String + var snippet: String } } } @@ -42,16 +42,27 @@ enum BingSearchError: Error, LocalizedError { } } -public struct BingSearchService { - public var subscriptionKey: String - public var searchURL: String +struct BingSearchService: SearchService { + var subscriptionKey: String + var searchURL: String - public init(subscriptionKey: String, searchURL: String) { + init(subscriptionKey: String, searchURL: String) { self.subscriptionKey = subscriptionKey self.searchURL = searchURL } - public func search( + func search(query: String) async throws -> WebSearchResult { + let result = try await search(query: query, numberOfResult: 10) + return WebSearchResult(webPages: result.webPages.value.map { + WebSearchResult.WebPage( + urlString: $0.url, + title: $0.name, + snippet: $0.snippet + ) + }) + } + + func search( query: String, numberOfResult: Int, freshness: String? = nil diff --git a/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift new file mode 100644 index 00000000..5601cb03 --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift @@ -0,0 +1,76 @@ +import Foundation + +struct SerpAPIResponse: Codable { + var searchMetadata: SearchMetadata + var organicResults: [OrganicResult] + + struct SearchMetadata: Codable { + var id: String + var status: String + var jsonEndpoint: String + var createdAt: String + var processedAt: String + var totalTimeTaken: Double + } + + struct OrganicResult: Codable { + var position: Int + var title: String + var link: String + var snippet: String + + func toWebSearchResult() -> WebSearchResult.WebPage { + return WebSearchResult.WebPage(urlString: link, title: title, snippet: snippet) + } + } + + func toWebSearchResult() -> WebSearchResult { + return WebSearchResult(webPages: organicResults.map { $0.toWebSearchResult() }) + } +} + +struct SerpAPISearchService: SearchService { + let engine: WebSearchProvider.SerpAPIEngine + let endpoint: URL = .init(string: "https://serpapi.com/search.json")! + let apiKey: String + + init(engine: WebSearchProvider.SerpAPIEngine, apiKey: String) { + self.engine = engine + self.apiKey = apiKey + } + + func search(query: String) async throws -> WebSearchResult { + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + var urlComponents = URLComponents(url: endpoint, resolvingAgainstBaseURL: true)! + urlComponents.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "engine", value: engine.rawValue), + URLQueryItem(name: "api_key", value: apiKey) + ] + + guard let url = urlComponents.url else { + throw URLError(.badURL) + } + + request = URLRequest(url: url) + request.httpMethod = "GET" + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + // Parse the response into WebSearchResult + let decoder = JSONDecoder() + + do { + let searchResponse = try decoder.decode(SerpAPIResponse.self, from: data) + return searchResponse.toWebSearchResult() + } catch { + throw error + } + } +} + diff --git a/Tool/Sources/WebSearchService/WebSearchService.swift b/Tool/Sources/WebSearchService/WebSearchService.swift new file mode 100644 index 00000000..ce2b1db2 --- /dev/null +++ b/Tool/Sources/WebSearchService/WebSearchService.swift @@ -0,0 +1,70 @@ +import Foundation +import Preferences + +public enum WebSearchProvider { + public enum SerpAPIEngine: String { + case google + case baidu + case bing + case duckDuckGo = "duckduckgo" + } + + public enum HeadlessBrowserEngine: String { + case google + case baidu + } + + case serpAPI(SerpAPIEngine, apiKey: String) + case headlessBrowser(HeadlessBrowserEngine) + + public static var userPreferred: WebSearchProvider { + switch UserDefaults.shared.value(for: \.searchProvider) { + case .headlessBrowser: + return .headlessBrowser(.init( + rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine) + .rawValue + ) ?? .google) + case .serpAPI: + return .serpAPI(.init( + rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine) + .rawValue + ) ?? .google, apiKey: "") + } + } +} + +public struct WebSearchResult { + public struct WebPage { + public var urlString: String + public var title: String + public var snippet: String + } + + public var webPages: [WebPage] +} + +public protocol SearchService { + func search(query: String) async throws -> WebSearchResult +} + +public struct WebSearchService { + let service: SearchService + + init(service: SearchService) { + self.service = service + } + + public init(provider: WebSearchProvider) { + switch provider { + case let .serpAPI(engine, apiKey): + service = SerpAPISearchService(engine: engine, apiKey: apiKey) + case let .headlessBrowser(engine): + fatalError() + } + } + + public func search(query: String) async throws -> WebSearchResult { + return try await service.search(query: query) + } +} + From 0ac411135ec51722c7150e432950fb43f64e4079 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 9 Aug 2025 23:53:48 +0800 Subject: [PATCH 02/11] Add search with headless browser --- TestPlan.xctestplan | 97 +++++---- Tool/Package.swift | 8 +- Tool/Sources/Preferences/Keys.swift | 2 + Tool/Sources/WebScrapper/WebScrapper.swift | 159 ++++++++++++++ .../HeadlessBrowserSearchService.swift | 197 ++++++++++++++++++ .../WebSearchService/WebSearchService.swift | 7 +- .../HeadlessBrowserSearchServiceTests.swift | 50 +++++ 7 files changed, 472 insertions(+), 48 deletions(-) create mode 100644 Tool/Sources/WebScrapper/WebScrapper.swift create mode 100644 Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift create mode 100644 Tool/Tests/WebSearchServiceTests/HeadlessBrowserSearchServiceTests.swift diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 56634aa7..f015c57e 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -24,16 +24,16 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Tool", - "identifier" : "OpenAIServiceTests", - "name" : "OpenAIServiceTests" + "containerPath" : "container:Core", + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "KeyBindingManagerTests", - "name" : "KeyBindingManagerTests" + "containerPath" : "container:Tool", + "identifier" : "OpenAIServiceTests", + "name" : "OpenAIServiceTests" } }, { @@ -46,22 +46,15 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "SharedUIComponentsTests", - "name" : "SharedUIComponentsTests" + "identifier" : "WebSearchServiceTests", + "name" : "WebSearchServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionBasicTests", - "name" : "SuggestionBasicTests" - } - }, - { - "target" : { - "containerPath" : "container:Core", - "identifier" : "PromptToCodeServiceTests", - "name" : "PromptToCodeServiceTests" + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" } }, { @@ -74,36 +67,36 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "FocusedCodeFinderTests", - "name" : "FocusedCodeFinderTests" + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ActiveDocumentChatContextCollectorTests", - "name" : "ActiveDocumentChatContextCollectorTests" + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "XcodeInspectorTests", - "name" : "XcodeInspectorTests" + "identifier" : "FocusedCodeFinderTests", + "name" : "FocusedCodeFinderTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "GitHubCopilotServiceTests", - "name" : "GitHubCopilotServiceTests" + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "SuggestionWidgetTests", - "name" : "SuggestionWidgetTests" + "identifier" : "PromptToCodeServiceTests", + "name" : "PromptToCodeServiceTests" } }, { @@ -113,13 +106,6 @@ "name" : "TokenEncoderTests" } }, - { - "target" : { - "containerPath" : "container:Core", - "identifier" : "ChatServiceTests", - "name" : "ChatServiceTests" - } - }, { "target" : { "containerPath" : "container:Tool", @@ -130,22 +116,36 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "KeychainTests", - "name" : "KeychainTests" + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ServiceTests", - "name" : "ServiceTests" + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ASTParserTests", - "name" : "ASTParserTests" + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" } }, { @@ -157,16 +157,23 @@ }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "CodeDiffTests", - "name" : "CodeDiffTests" + "containerPath" : "container:Core", + "identifier" : "ServiceTests", + "name" : "ServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ServiceUpdateMigrationTests", - "name" : "ServiceUpdateMigrationTests" + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "KeychainTests", + "name" : "KeychainTests" } } ], diff --git a/Tool/Package.swift b/Tool/Package.swift index cc2337e3..30301347 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -53,6 +53,7 @@ let package = Package( .library(name: "CodeDiff", targets: ["CodeDiff"]), .library(name: "BuiltinExtension", targets: ["BuiltinExtension"]), .library(name: "WebSearchService", targets: ["WebSearchService"]), + .library(name: "WebScrapper", targets: ["WebScrapper"]), .library( name: "CustomCommandTemplateProcessor", targets: ["CustomCommandTemplateProcessor"] @@ -382,7 +383,12 @@ let package = Package( ] ), - .target(name: "WebSearchService", dependencies: ["Preferences"]), + .target(name: "WebScrapper", dependencies: [ + .product(name: "SwiftSoup", package: "SwiftSoup"), + ]), + + .target(name: "WebSearchService", dependencies: ["Preferences", "WebScrapper", "Keychain"]), + .testTarget(name: "WebSearchServiceTests", dependencies: ["WebSearchService"]), .target(name: "SuggestionProvider", dependencies: [ "SuggestionBasic", diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 9755e400..a1de6424 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -600,6 +600,8 @@ public extension UserDefaultPreferenceKeys { enum HeadlessBrowserEngine: String, Codable, CaseIterable { case google case baidu + case bing + case duckDuckGo = "duckduckgo" } var searchProvider: PreferenceKey { diff --git a/Tool/Sources/WebScrapper/WebScrapper.swift b/Tool/Sources/WebScrapper/WebScrapper.swift new file mode 100644 index 00000000..78f0a120 --- /dev/null +++ b/Tool/Sources/WebScrapper/WebScrapper.swift @@ -0,0 +1,159 @@ +import Foundation +import SwiftSoup +import WebKit + +@MainActor +public final class WebScrapper { + final class NavigationDelegate: NSObject, WKNavigationDelegate { + weak var scrapper: WebScrapper? + + public nonisolated func webView(_: WKWebView, didFinish _: WKNavigation!) { + Task { @MainActor in + self.scrapper?.webViewDidFinishLoading = true + } + } + + public nonisolated func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError error: Error + ) { + Task { @MainActor in + self.scrapper?.navigationError = error + self.scrapper?.webViewDidFinishLoading = true + } + } + } + + public var webView: WKWebView + + var webViewDidFinishLoading = false + var navigationError: (any Error)? + let navigationDelegate: NavigationDelegate = NavigationDelegate() + + enum WebScrapperError: Error { + case retry + } + + public init() async { + let jsonRuleList = ###""" + [ + { + "trigger": { + "url-filter": ".*", + "resource-type": ["style-sheet"] + }, + "action": { + "type": "block" + } + }, + { + "trigger": { + "url-filter": ".*", + "resource-type": ["font"] + }, + "action": { + "type": "block" + } + }, + { + "trigger": { + "url-filter": ".*", + "resource-type": ["image"] + }, + "action": { + "type": "block" + } + }, + { + "trigger": { + "url-filter": ".*", + "resource-type": ["media"] + }, + "action": { + "type": "block" + } + } + ] + """### + + let list = try? await WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: "web-scrapping", + encodedContentRuleList: jsonRuleList + ) + + let configuration = WKWebViewConfiguration() + if let list { + configuration.userContentController.add(list) + } + configuration.allowsAirPlayForMediaPlayback = false + configuration.mediaTypesRequiringUserActionForPlayback = .all + configuration.defaultWebpagePreferences.preferredContentMode = .desktop + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.websiteDataStore = .nonPersistent() + configuration + .applicationNameForUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15" + + if #available(iOS 17.0, macOS 14.0, *) { + configuration.allowsInlinePredictions = false + } + + // The web page need the web view to have a size to load correctly. + let webView = WKWebView( + frame: .init(x: 0, y: 0, width: 800, height: 5000), + configuration: configuration + ) + self.webView = webView + navigationDelegate.scrapper = self + webView.navigationDelegate = navigationDelegate + } + + public func fetch( + url: URL, + validate: @escaping (SwiftSoup.Document) -> Bool = { _ in true }, + timeout: TimeInterval = 15, + retryLimit: Int = 50 + ) async throws -> String { + webViewDidFinishLoading = false + navigationError = nil + var retryCount = 0 + _ = webView.load(.init(url: url)) + while !webViewDidFinishLoading { + try await Task.sleep(nanoseconds: 10_000_000) + } + let deadline = Date().addingTimeInterval(timeout) + if let navigationError { throw navigationError } + while retryCount < retryLimit, Date() < deadline { + if let html = try? await getHTML(), !html.isEmpty, + let document = try? SwiftSoup.parse(html, url.path), + validate(document) + { + return html + } + retryCount += 1 + try await Task.sleep(nanoseconds: 100_000_000) + } + + throw CancellationError() + } + + func getHTML() async throws -> String { + do { + let isReady = try await webView.evaluateJavaScript(checkIfReady) as? Bool ?? false + if !isReady { throw WebScrapperError.retry } + return try await webView.evaluateJavaScript(getHTMLText) as? String ?? "" + } catch { + throw WebScrapperError.retry + } + } +} + +private let getHTMLText = """ +document.documentElement.outerHTML; +""" + +private let checkIfReady = """ +document.readyState === "ready" || document.readyState === "complete"; +""" + diff --git a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift new file mode 100644 index 00000000..108aa17c --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift @@ -0,0 +1,197 @@ +import Foundation +import SwiftSoup +import WebKit +import WebScrapper + +struct HeadlessBrowserSearchService: SearchService { + let engine: WebSearchProvider.HeadlessBrowserEngine + + func search(query: String) async throws -> WebSearchResult { + let queryEncoded = query + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = switch engine { + case .google: + URL(string: "https://www.google.com/search?q=\(queryEncoded)")! + case .baidu: + URL(string: "https://www.baidu.com/s?wd=\(queryEncoded)")! + case .duckDuckGo: + URL(string: "https://duckduckgo.com/?q=\(queryEncoded)")! + case .bing: + URL(string: "https://www.bing.com/search?q=\(queryEncoded)")! + } + + let scrapper = await WebScrapper() + let html = try await scrapper.fetch(url: url) { document in + switch engine { + case .google: + return GoogleSearchResultParser.validate(document: document) + case .baidu: + return BaiduSearchResultParser.validate(document: document) + case .duckDuckGo: + return DuckDuckGoSearchResultParser.validate(document: document) + case .bing: + return BingSearchResultParser.validate(document: document) + } + } + + switch engine { + case .google: + return try GoogleSearchResultParser.parse(html: html) + case .baidu: + return BaiduSearchResultParser.parse(html: html) + case .duckDuckGo: + return DuckDuckGoSearchResultParser.parse(html: html) + case .bing: + return BingSearchResultParser.parse(html: html) + } + } +} + +enum GoogleSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + guard let _ = try? document.select("#rso").first + else { return false } + return true + } + + static func parse(html: String) throws -> WebSearchResult { + let document = try SwiftSoup.parse(html) + let searchResult = try document.select("#rso").first + + guard let searchResult else { return .init(webPages: []) } + + var results: [WebSearchResult.WebPage] = [] + for element in searchResult.children() { + if let title = try? element.select("h3").text(), + let link = try? element.select("a").attr("href"), + !link.isEmpty, + // A magic class name. + let snippet = try? element.select("div.VwiC3b").first()?.text() + ?? element.select("span.st").first()?.text() + { + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + + return WebSearchResult(webPages: results) + } +} + +enum BaiduSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select("#content_left").first()) != nil + } + + static func parse(html: String) -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let elements = try? document?.select("#content_left").first()?.children() + + var results: [WebSearchResult.WebPage] = [] + if let elements = elements { + for element in elements { + if let titleElement = try? element.select("h3").first(), + let link = try? element.select("a").attr("href"), + link.hasPrefix("http") + { + let title = (try? titleElement.text()) ?? "" + let snippet = { + let abstract = try? element.select("div[data-module=\"abstract\"]").text() + if let abstract, !abstract.isEmpty { + return abstract + } + return (try? titleElement.nextElementSibling()?.text()) ?? "" + }() + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + } + + return WebSearchResult(webPages: results) + } +} + +enum DuckDuckGoSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select(".react-results--main").first()) != nil + } + + static func parse(html: String) -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let body = document?.body() + + var results: [WebSearchResult.WebPage] = [] + + if let reactResults = try? body?.select(".react-results--main") { + for object in reactResults { + for element in object.children() { + if let linkElement = try? element.select("a[data-testid=\"result-title-a\"]"), + let link = try? linkElement.attr("href"), + link.hasPrefix("http"), + let titleElement = try? element.select("span").first() + { + let title = (try? titleElement.select("span").first()?.text()) ?? "" + let snippet = ( + try? element.select("[data-result=snippet]").first()?.text() + ) ?? "" + + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + } + } + + return WebSearchResult(webPages: results) + } +} + +enum BingSearchResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + return (try? document.select("#b_results").first()) != nil + } + + static func parse(html: String) -> WebSearchResult { + let document = try? SwiftSoup.parse(html) + let searchResults = try? document?.select("#b_results").first() + + var results: [WebSearchResult.WebPage] = [] + if let elements = try? searchResults?.select("li.b_algo") { + for element in elements { + if let titleElement = try? element.select("h2").first(), + let linkElement = try? titleElement.select("a").first(), + let link = try? linkElement.attr("href"), + link.hasPrefix("http") + { + let title = (try? titleElement.text()) ?? "" + let snippet = { + if let it = try? element.select(".b_caption p").first()?.text(), + !it.isEmpty { return it } + if let it = try? element.select(".b_lineclamp2").first()?.text(), + !it.isEmpty { return it } + return (try? element.select("p").first()?.text()) ?? "" + }() + + results.append(WebSearchResult.WebPage( + urlString: link, + title: title, + snippet: snippet + )) + } + } + } + + return WebSearchResult(webPages: results) + } +} + diff --git a/Tool/Sources/WebSearchService/WebSearchService.swift b/Tool/Sources/WebSearchService/WebSearchService.swift index ce2b1db2..958fff93 100644 --- a/Tool/Sources/WebSearchService/WebSearchService.swift +++ b/Tool/Sources/WebSearchService/WebSearchService.swift @@ -1,5 +1,6 @@ import Foundation import Preferences +import Keychain public enum WebSearchProvider { public enum SerpAPIEngine: String { @@ -12,6 +13,8 @@ public enum WebSearchProvider { public enum HeadlessBrowserEngine: String { case google case baidu + case bing + case duckDuckGo = "duckduckgo" } case serpAPI(SerpAPIEngine, apiKey: String) @@ -28,7 +31,7 @@ public enum WebSearchProvider { return .serpAPI(.init( rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine) .rawValue - ) ?? .google, apiKey: "") + ) ?? .google, apiKey: (try? Keychain.apiKey.get("SerpAPIKey")) ?? "") } } } @@ -59,7 +62,7 @@ public struct WebSearchService { case let .serpAPI(engine, apiKey): service = SerpAPISearchService(engine: engine, apiKey: apiKey) case let .headlessBrowser(engine): - fatalError() + service = HeadlessBrowserSearchService(engine: engine) } } diff --git a/Tool/Tests/WebSearchServiceTests/HeadlessBrowserSearchServiceTests.swift b/Tool/Tests/WebSearchServiceTests/HeadlessBrowserSearchServiceTests.swift new file mode 100644 index 00000000..3a66f0e9 --- /dev/null +++ b/Tool/Tests/WebSearchServiceTests/HeadlessBrowserSearchServiceTests.swift @@ -0,0 +1,50 @@ +import Foundation +import XCTest + +@testable import WebSearchService + +class HeadlessBrowserSearchServiceTests: XCTestCase { + func test_search_on_google() async throws { + let search = HeadlessBrowserSearchService(engine: .google) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } + + func test_search_on_baidu() async throws { + let search = HeadlessBrowserSearchService(engine: .baidu) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } + + func test_search_on_duckDuckGo() async throws { + let search = HeadlessBrowserSearchService(engine: .duckDuckGo) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } + + func test_search_on_bing() async throws { + let search = HeadlessBrowserSearchService(engine: .bing) + + do { + let result = try await search.search(query: "Snoopy") + XCTAssertFalse(result.webPages.isEmpty, "Expected non-empty search result") + } catch { + XCTFail("Search failed with error: \(error)") + } + } +} From da1d8c4821970e94311cab19e2c42748fa99aa2c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 00:09:19 +0800 Subject: [PATCH 03/11] Add search for apple documentations --- .../AppleDocumentationSearchService.swift | 60 +++++++++++++++++++ .../WebSearchService/WebSearchService.swift | 3 + 2 files changed, 63 insertions(+) create mode 100644 Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift diff --git a/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift new file mode 100644 index 00000000..680c4fb6 --- /dev/null +++ b/Tool/Sources/WebSearchService/SearchServices/AppleDocumentationSearchService.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftSoup +import WebKit +import WebScrapper + +struct AppleDocumentationSearchService: SearchService { + func search(query: String) async throws -> WebSearchResult { + let queryEncoded = query + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = URL(string: "https://developer.apple.com/search/?q=\(queryEncoded)")! + + let scrapper = await WebScrapper() + let html = try await scrapper.fetch(url: url) { document in + DeveloperDotAppleResultParser.validate(document: document) + } + + return try DeveloperDotAppleResultParser.parse(html: html) + } +} + +enum DeveloperDotAppleResultParser { + static func validate(document: SwiftSoup.Document) -> Bool { + guard let _ = try? document.select("ul.search-results").first + else { return false } + return true + } + + static func parse(html: String) throws -> WebSearchResult { + let document = try SwiftSoup.parse(html) + let searchResult = try? document.select("ul.search-results").first + + guard let searchResult else { return .init(webPages: []) } + + var results: [WebSearchResult.WebPage] = [] + for element in searchResult.children() { + if let titleElement = try? element.select("p.result-title"), + let link = try? titleElement.select("a").attr("href"), + !link.isEmpty + { + let title = (try? titleElement.text()) ?? "" + let snippet = (try? element.select("p.result-description").text()) + ?? (try? element.select("ul.breadcrumb-list").text()) + ?? "" + results.append(WebSearchResult.WebPage( + urlString: { + if link.hasPrefix("/") { + return "https://developer.apple.com\(link)" + } + return link + }(), + title: title, + snippet: snippet + )) + } + } + + return WebSearchResult(webPages: results) + } +} + diff --git a/Tool/Sources/WebSearchService/WebSearchService.swift b/Tool/Sources/WebSearchService/WebSearchService.swift index 958fff93..d1379ab0 100644 --- a/Tool/Sources/WebSearchService/WebSearchService.swift +++ b/Tool/Sources/WebSearchService/WebSearchService.swift @@ -19,6 +19,7 @@ public enum WebSearchProvider { case serpAPI(SerpAPIEngine, apiKey: String) case headlessBrowser(HeadlessBrowserEngine) + case appleDocumentation public static var userPreferred: WebSearchProvider { switch UserDefaults.shared.value(for: \.searchProvider) { @@ -63,6 +64,8 @@ public struct WebSearchService { service = SerpAPISearchService(engine: engine, apiKey: apiKey) case let .headlessBrowser(engine): service = HeadlessBrowserSearchService(engine: engine) + case .appleDocumentation: + service = AppleDocumentationSearchService() } } From b68fd33210cd631734c343a067f21719fa9c74af Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 16:34:37 +0800 Subject: [PATCH 04/11] Fix SerpAPI --- Tool/Sources/Preferences/Keys.swift | 6 ++++- .../SearchServices/SerpAPISearchService.swift | 27 +++++++------------ .../WebSearchService/WebSearchService.swift | 13 +++++---- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index a1de6424..ec392514 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -586,8 +586,8 @@ public extension UserDefaultPreferenceKeys { public extension UserDefaultPreferenceKeys { enum SearchProvider: String, Codable, CaseIterable { - case serpAPI case headlessBrowser + case serpAPI } enum SerpAPIEngine: String, Codable, CaseIterable { @@ -612,6 +612,10 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: .google, key: "SerpAPIEngine") } + var serpAPIKeyName: PreferenceKey { + .init(defaultValue: "", key: "SerpAPIKeyName") + } + var headlessBrowserEngine: PreferenceKey { .init(defaultValue: .google, key: "HeadlessBrowserEngine") } diff --git a/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift index 5601cb03..0fa7a1ee 100644 --- a/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift +++ b/Tool/Sources/WebSearchService/SearchServices/SerpAPISearchService.swift @@ -1,31 +1,22 @@ import Foundation struct SerpAPIResponse: Codable { - var searchMetadata: SearchMetadata - var organicResults: [OrganicResult] - - struct SearchMetadata: Codable { - var id: String - var status: String - var jsonEndpoint: String - var createdAt: String - var processedAt: String - var totalTimeTaken: Double - } + var organic_results: [OrganicResult] struct OrganicResult: Codable { - var position: Int - var title: String - var link: String - var snippet: String + var position: Int? + var title: String? + var link: String? + var snippet: String? - func toWebSearchResult() -> WebSearchResult.WebPage { - return WebSearchResult.WebPage(urlString: link, title: title, snippet: snippet) + func toWebSearchResult() -> WebSearchResult.WebPage? { + guard let link, let title else { return nil } + return WebSearchResult.WebPage(urlString: link, title: title, snippet: snippet ?? "") } } func toWebSearchResult() -> WebSearchResult { - return WebSearchResult(webPages: organicResults.map { $0.toWebSearchResult() }) + return WebSearchResult(webPages: organic_results.compactMap { $0.toWebSearchResult() }) } } diff --git a/Tool/Sources/WebSearchService/WebSearchService.swift b/Tool/Sources/WebSearchService/WebSearchService.swift index d1379ab0..7eceade4 100644 --- a/Tool/Sources/WebSearchService/WebSearchService.swift +++ b/Tool/Sources/WebSearchService/WebSearchService.swift @@ -25,20 +25,19 @@ public enum WebSearchProvider { switch UserDefaults.shared.value(for: \.searchProvider) { case .headlessBrowser: return .headlessBrowser(.init( - rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine) - .rawValue + rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine).rawValue ) ?? .google) case .serpAPI: + let apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName) return .serpAPI(.init( - rawValue: UserDefaults.shared.value(for: \.headlessBrowserEngine) - .rawValue - ) ?? .google, apiKey: (try? Keychain.apiKey.get("SerpAPIKey")) ?? "") + rawValue: UserDefaults.shared.value(for: \.serpAPIEngine).rawValue + ) ?? .google, apiKey: (try? Keychain.apiKey.get(apiKeyName)) ?? "") } } } -public struct WebSearchResult { - public struct WebPage { +public struct WebSearchResult: Equatable { + public struct WebPage: Equatable { public var urlString: String public var title: String public var snippet: String From aa2397fb12417793744cb6f86cc4e1afed8ba0da Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 16:34:47 +0800 Subject: [PATCH 05/11] Add settings view for web search --- Core/Package.swift | 1 + .../AccountSettings/WebSearchView.swift | 240 ++++++++++++++++-- Core/Sources/HostApp/HostApp.swift | 9 + Core/Sources/HostApp/ServiceView.swift | 21 +- 4 files changed, 238 insertions(+), 33 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index 89c3bf38..f94f431a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -126,6 +126,7 @@ let package = Package( .product(name: "Toast", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "WebSearchService", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), diff --git a/Core/Sources/HostApp/AccountSettings/WebSearchView.swift b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift index 7504e828..a8cac790 100644 --- a/Core/Sources/HostApp/AccountSettings/WebSearchView.swift +++ b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift @@ -1,48 +1,242 @@ import AppKit import Client +import ComposableArchitecture import OpenAIService import Preferences import SuggestionBasic import SwiftUI +import WebSearchService -final class BingSearchViewSettings: ObservableObject { - @AppStorage(\.bingSearchSubscriptionKey) var bingSearchSubscriptionKey: String - @AppStorage(\.bingSearchEndpoint) var bingSearchEndpoint: String +@Reducer +struct WebSearchSettings { + struct TestResult: Identifiable, Equatable { + let id = UUID() + var duration: TimeInterval + var result: Result? + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + } + + @ObservableState + struct State: Equatable { + var apiKeySelection: APIKeySelection.State = .init() + var testResult: TestResult? + } + + enum Action: BindableAction { + case binding(BindingAction) + case appear + case test + case bringUpTestResult + case updateTestResult(TimeInterval, Result) + case apiKeySelection(APIKeySelection.Action) + } + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Reduce { state, action in + switch action { + case .binding: + return .none + case .appear: + state.testResult = nil + state.apiKeySelection.apiKeyName = UserDefaults.shared.value(for: \.serpAPIKeyName) + return .none + case .test: + return .run { send in + let searchService = WebSearchService(provider: .userPreferred) + await send(.bringUpTestResult) + let start = Date() + do { + let result = try await searchService.search(query: "Swift") + let duration = Date().timeIntervalSince(start) + await send(.updateTestResult(duration, .success(result))) + } catch { + let duration = Date().timeIntervalSince(start) + await send(.updateTestResult(duration, .failure(error))) + } + } + case .bringUpTestResult: + state.testResult = .init(duration: 0) + return .none + case let .updateTestResult(duration, result): + state.testResult?.duration = duration + state.testResult?.result = result + return .none + case let .apiKeySelection(action): + switch action { + case .binding(\APIKeySelection.State.apiKeyName): + UserDefaults.shared.set(state.apiKeySelection.apiKeyName, for: \.serpAPIKeyName) + return .none + default: + return .none + } + } + } + } +} + +final class WebSearchViewSettings: ObservableObject { + @AppStorage(\.serpAPIEngine) var serpAPIEngine + @AppStorage(\.headlessBrowserEngine) var headlessBrowserEngine + @AppStorage(\.searchProvider) var searchProvider init() {} } -struct BingSearchView: View { +struct WebSearchView: View { + @Perception.Bindable var store: StoreOf @Environment(\.openURL) var openURL - @StateObject var settings = BingSearchViewSettings() + @StateObject var settings = WebSearchViewSettings() var body: some View { - Form { - Button(action: { - let url = URL(string: "https://www.microsoft.com/bing/apis/bing-web-search-api")! - openURL(url) - }) { - Text("Apply for Subscription Key for Free") + WithPerceptionTracking { + ScrollView { + Form { + Section(header: Text("Search Provider")) { + Picker("Search Provider", selection: $settings.searchProvider) { + ForEach(UserDefaultPreferenceKeys.SearchProvider.allCases, id: \.self) { + provider in + Text(provider.rawValue).tag(provider) + } + } + .pickerStyle(.segmented) + } + + switch settings.searchProvider { + case .serpAPI: + serpAPIForm() + case .headlessBrowser: + headlessBrowserForm() + } + + Section { + Button("Test Search") { + store.send(.test) + } + } + } + .padding() } - - SecureField(text: $settings.bingSearchSubscriptionKey, prompt: Text("")) { - Text("Bing Search Subscription Key") + .sheet(item: $store.testResult) { testResult in + testResultView(testResult: testResult) } - .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + } + } + + @ViewBuilder + func serpAPIForm() -> some View { + Section(header: Text("SerpAPI")) { + Picker("Engine", selection: $settings.serpAPIEngine) { + ForEach( + UserDefaultPreferenceKeys.SerpAPIEngine.allCases, + id: \.self + ) { engine in + Text(engine.rawValue).tag(engine) + } + } + + WithPerceptionTracking { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + } + } + } - TextField( - text: $settings.bingSearchEndpoint, - prompt: Text("https://api.bing.microsoft.com/***") - ) { - Text("Bing Search Endpoint") - }.textFieldStyle(.roundedBorder) + @ViewBuilder + func headlessBrowserForm() -> some View { + Section(header: Text("Headless Browser")) { + Picker("Engine", selection: $settings.headlessBrowserEngine) { + ForEach( + UserDefaultPreferenceKeys.HeadlessBrowserEngine.allCases, + id: \.self + ) { engine in + Text(engine.rawValue).tag(engine) + } + } } } + + @ViewBuilder + func testResultView(testResult: WebSearchSettings.TestResult) -> some View { + VStack { + Text("Test Result") + .font(.headline) + .padding() + + if let result = testResult.result { + switch result { + case let .success(webSearchResult): + VStack(alignment: .leading) { + Text("Success (Completed in \(testResult.duration, specifier: "%.2f")s)") + .foregroundColor(.green) + + Text("Found \(webSearchResult.webPages.count) results:") + .padding(.top) + + ScrollView { + ForEach(webSearchResult.webPages, id: \.urlString) { page in + VStack(alignment: .leading) { + Text(page.title) + .font(.headline) + Text(page.urlString) + .font(.caption) + .foregroundColor(.blue) + Text(page.snippet) + .padding(.top, 2) + } + .padding(.vertical, 4) + Divider() + } + } + } + .padding() + case let .failure(error): + VStack(alignment: .leading) { + Text("Error (Completed in \(testResult.duration, specifier: "%.2f")s)") + .foregroundColor(.red) + Text(error.localizedDescription) + .padding(.top) + } + .padding() + } + } else { + VStack { + ProgressView() + } + .padding() + } + + Button("Close") { + store.testResult = nil + } + .padding() + } + .frame(minWidth: 400, minHeight: 300) + } +} + +// Helper struct to make TestResult identifiable for sheet presentation +private struct TestResultWrapper: Identifiable { + var id: UUID = .init() + var testResult: WebSearchSettings.TestResult } -struct BingSearchView_Previews: PreviewProvider { +struct WebSearchView_Previews: PreviewProvider { static var previews: some View { VStack(alignment: .leading, spacing: 8) { - BingSearchView() + WebSearchView(store: .init(initialState: .init(), reducer: { WebSearchSettings() })) } .frame(height: 800) .padding(.all, 8) diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index ce66cbcf..f2b90303 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -18,6 +18,7 @@ struct HostApp { var general = General.State() var chatModelManagement = ChatModelManagement.State() var embeddingModelManagement = EmbeddingModelManagement.State() + var webSearchSettings = WebSearchSettings.State() } enum Action { @@ -25,6 +26,7 @@ struct HostApp { case general(General.Action) case chatModelManagement(ChatModelManagement.Action) case embeddingModelManagement(EmbeddingModelManagement.Action) + case webSearchSettings(WebSearchSettings.Action) } @Dependency(\.toast) var toast @@ -45,6 +47,10 @@ struct HostApp { Scope(state: \.embeddingModelManagement, action: \.embeddingModelManagement) { EmbeddingModelManagement() } + + Scope(state: \.webSearchSettings, action: \.webSearchSettings) { + WebSearchSettings() + } Reduce { _, action in switch action { @@ -62,6 +68,9 @@ struct HostApp { case .embeddingModelManagement: return .none + + case .webSearchSettings: + return .none } } } diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 2fff4bcf..bf81eb51 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -17,7 +17,7 @@ struct ServiceView: View { subtitle: "Suggestion", image: "globe" ) - + ScrollView { CodeiumView().padding() }.sidebarItem( @@ -26,7 +26,7 @@ struct ServiceView: View { subtitle: "Suggestion", image: "globe" ) - + ChatModelManagementView(store: store.scope( state: \.chatModelManagement, action: \.chatModelManagement @@ -36,7 +36,7 @@ struct ServiceView: View { subtitle: "Chat, Modification", image: "globe" ) - + EmbeddingModelManagementView(store: store.scope( state: \.embeddingModelManagement, action: \.embeddingModelManagement @@ -46,16 +46,17 @@ struct ServiceView: View { subtitle: "Chat, Modification", image: "globe" ) - - ScrollView { - BingSearchView().padding() - }.sidebarItem( + + WebSearchView(store: store.scope( + state: \.webSearchSettings, + action: \.webSearchSettings + )).sidebarItem( tag: 4, - title: "Bing Search", - subtitle: "Search Chat Plugin", + title: "Web Search", + subtitle: "Chat, Modification", image: "globe" ) - + ScrollView { OtherSuggestionServicesView().padding() }.sidebarItem( From 66f200d242a328ccbff2fd66b98d3f873278d7f2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 16:54:29 +0800 Subject: [PATCH 06/11] Adjust UI --- .../AccountSettings/WebSearchView.swift | 88 +++++++++++++------ 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/WebSearchView.swift b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift index a8cac790..d34686f9 100644 --- a/Core/Sources/HostApp/AccountSettings/WebSearchView.swift +++ b/Core/Sources/HostApp/AccountSettings/WebSearchView.swift @@ -6,6 +6,7 @@ import Preferences import SuggestionBasic import SwiftUI import WebSearchService +import SharedUIComponents @Reducer struct WebSearchSettings { @@ -98,12 +99,18 @@ struct WebSearchView: View { var body: some View { WithPerceptionTracking { ScrollView { - Form { - Section(header: Text("Search Provider")) { + VStack(alignment: .leading) { + Form { Picker("Search Provider", selection: $settings.searchProvider) { ForEach(UserDefaultPreferenceKeys.SearchProvider.allCases, id: \.self) { provider in - Text(provider.rawValue).tag(provider) + switch provider { + case .serpAPI: + Text("Serp API").tag(provider) + case .headlessBrowser: + Text("Headless Browser").tag(provider) + } + } } .pickerStyle(.segmented) @@ -115,14 +122,21 @@ struct WebSearchView: View { case .headlessBrowser: headlessBrowserForm() } - - Section { + } + .padding() + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + Divider() + HStack { Button("Test Search") { store.send(.test) } + Spacer() } + .padding() } - .padding() + .background(.regularMaterial) } .sheet(item: $store.testResult) { testResult in testResultView(testResult: testResult) @@ -135,7 +149,12 @@ struct WebSearchView: View { @ViewBuilder func serpAPIForm() -> some View { - Section(header: Text("SerpAPI")) { + SubSection( + title: Text("Serp API Settings"), + description: """ + Use Serp API to do web search. Serp API is more reliable and faster than headless browser. But you need to provide an API key for it. + """ + ) { Picker("Engine", selection: $settings.serpAPIEngine) { ForEach( UserDefaultPreferenceKeys.SerpAPIEngine.allCases, @@ -144,7 +163,7 @@ struct WebSearchView: View { Text(engine.rawValue).tag(engine) } } - + WithPerceptionTracking { APIKeyPicker(store: store.scope( state: \.apiKeySelection, @@ -156,7 +175,12 @@ struct WebSearchView: View { @ViewBuilder func headlessBrowserForm() -> some View { - Section(header: Text("Headless Browser")) { + SubSection( + title: Text("Headless Browser Settings"), + description: """ + The app will open a webview in the background to do web search. This method uses a set of rules to extract information from the web page, if you notice that it stops working, please submit an issue to the developer. + """ + ) { Picker("Engine", selection: $settings.headlessBrowserEngine) { ForEach( UserDefaultPreferenceKeys.HeadlessBrowserEngine.allCases, @@ -172,8 +196,8 @@ struct WebSearchView: View { func testResultView(testResult: WebSearchSettings.TestResult) -> some View { VStack { Text("Test Result") + .padding(.top) .font(.headline) - .padding() if let result = testResult.result { switch result { @@ -183,18 +207,20 @@ struct WebSearchView: View { .foregroundColor(.green) Text("Found \(webSearchResult.webPages.count) results:") - .padding(.top) ScrollView { ForEach(webSearchResult.webPages, id: \.urlString) { page in - VStack(alignment: .leading) { - Text(page.title) - .font(.headline) - Text(page.urlString) - .font(.caption) - .foregroundColor(.blue) - Text(page.snippet) - .padding(.top, 2) + HStack { + VStack(alignment: .leading) { + Text(page.title) + .font(.headline) + Text(page.urlString) + .font(.caption) + .foregroundColor(.blue) + Text(page.snippet) + .padding(.top, 2) + } + Spacer(minLength: 0) } .padding(.vertical, 4) Divider() @@ -207,21 +233,27 @@ struct WebSearchView: View { Text("Error (Completed in \(testResult.duration, specifier: "%.2f")s)") .foregroundColor(.red) Text(error.localizedDescription) - .padding(.top) } - .padding() } } else { - VStack { - ProgressView() - } - .padding() + ProgressView().padding() } - Button("Close") { - store.testResult = nil + Spacer() + + VStack(spacing: 0) { + Divider() + + HStack { + Spacer() + + Button("Close") { + store.testResult = nil + } + .keyboardShortcut(.cancelAction) + } + .padding() } - .padding() } .frame(minWidth: 400, minHeight: 300) } From a3f4a70f0660a47ccc612745374f1004d8c93f3f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 17:14:07 +0800 Subject: [PATCH 07/11] Fix link extraction from baidu --- .../HeadlessBrowserSearchService.swift | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift index 108aa17c..db154664 100644 --- a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift +++ b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift @@ -38,7 +38,7 @@ struct HeadlessBrowserSearchService: SearchService { case .google: return try GoogleSearchResultParser.parse(html: html) case .baidu: - return BaiduSearchResultParser.parse(html: html) + return await BaiduSearchResultParser.parse(html: html) case .duckDuckGo: return DuckDuckGoSearchResultParser.parse(html: html) case .bing: @@ -85,8 +85,58 @@ enum BaiduSearchResultParser { static func validate(document: SwiftSoup.Document) -> Bool { return (try? document.select("#content_left").first()) != nil } + + static func getRealLink(from baiduLink: String) async -> String { + guard let url = URL(string: baiduLink) else { + return baiduLink + } - static func parse(html: String) -> WebSearchResult { + let config = URLSessionConfiguration.default + config.httpShouldSetCookies = true + config.httpCookieAcceptPolicy = .always + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.addValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + forHTTPHeaderField: "User-Agent" + ) + + let redirectCapturer = RedirectCapturer() + let session = URLSession( + configuration: config, + delegate: redirectCapturer, + delegateQueue: nil + ) + + do { + let _ = try await session.data(for: request) + + if let finalURL = redirectCapturer.finalURL { + return finalURL.absoluteString + } + + return baiduLink + } catch { + return baiduLink + } + } + + class RedirectCapturer: NSObject, URLSessionTaskDelegate { + var finalURL: URL? + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + finalURL = request.url + completionHandler(request) + } + } + static func parse(html: String) async -> WebSearchResult { let document = try? SwiftSoup.parse(html) let elements = try? document?.select("#content_left").first()?.children() @@ -97,6 +147,7 @@ enum BaiduSearchResultParser { let link = try? element.select("a").attr("href"), link.hasPrefix("http") { + let realLink = await getRealLink(from: link) let title = (try? titleElement.text()) ?? "" let snippet = { let abstract = try? element.select("div[data-module=\"abstract\"]").text() @@ -106,7 +157,7 @@ enum BaiduSearchResultParser { return (try? titleElement.nextElementSibling()?.text()) ?? "" }() results.append(WebSearchResult.WebPage( - urlString: link, + urlString: realLink, title: title, snippet: snippet )) From 3d910c6969e09eeee0ca5f1091c07fc5d2beec53 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 17:42:54 +0800 Subject: [PATCH 08/11] Fix bing link extraction --- .../HeadlessBrowserSearchService.swift | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift index db154664..949004ca 100644 --- a/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift +++ b/Tool/Sources/WebSearchService/SearchServices/HeadlessBrowserSearchService.swift @@ -85,7 +85,7 @@ enum BaiduSearchResultParser { static func validate(document: SwiftSoup.Document) -> Bool { return (try? document.select("#content_left").first()) != nil } - + static func getRealLink(from baiduLink: String) async -> String { guard let url = URL(string: baiduLink) else { return baiduLink @@ -136,6 +136,7 @@ enum BaiduSearchResultParser { completionHandler(request) } } + static func parse(html: String) async -> WebSearchResult { let document = try? SwiftSoup.parse(html) let elements = try? document?.select("#content_left").first()?.children() @@ -212,6 +213,41 @@ enum BingSearchResultParser { return (try? document.select("#b_results").first()) != nil } + static func getRealLink(from bingLink: String) -> String { + guard let url = URL(string: bingLink) else { return bingLink } + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems, + var uParam = queryItems.first(where: { $0.name == "u" })?.value + { + if uParam.hasPrefix("a1aHR") { + uParam.removeFirst() + uParam.removeFirst() + } + + func decode() -> String? { + guard let decodedData = Data(base64Encoded: uParam), + let decodedString = String(data: decodedData, encoding: .utf8) + else { return nil } + return decodedString + } + + if let decodedString = decode() { + return decodedString + } + uParam += "=" + if let decodedString = decode() { + return decodedString + } + uParam += "=" + if let decodedString = decode() { + return decodedString + } + } + + return bingLink + } + static func parse(html: String) -> WebSearchResult { let document = try? SwiftSoup.parse(html) let searchResults = try? document?.select("#b_results").first() @@ -224,6 +260,7 @@ enum BingSearchResultParser { let link = try? linkElement.attr("href"), link.hasPrefix("http") { + let link = getRealLink(from: link) let title = (try? titleElement.text()) ?? "" let snippet = { if let it = try? element.select(".b_caption p").first()?.text(), From 13e2cedf9cd0007126fd591f2b97c6511e615ef5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 21:25:54 +0800 Subject: [PATCH 09/11] Fix that chat window may move when switching screen --- .../WidgetWindowsController.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index e26fbaae..64386a82 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -159,7 +159,7 @@ private extension WidgetWindowsController { updateWindowLocation(animated: false, immediately: immediately) updateWindowOpacity(immediately: immediately) } - + await updateWidgetsAndNotifyChangeOfEditor(immediately: true) for await notification in await notifications.notifications() { @@ -274,7 +274,8 @@ extension WidgetWindowsController { let parent = focusElement.parent, let frame = parent.rect, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main + let windowContainingScreen = NSScreen.screens + .first(where: { $0.frame.contains(frame.origin) }) { let positionMode = UserDefaults.shared .value(for: \.suggestionWidgetPositionMode) @@ -286,7 +287,7 @@ extension WidgetWindowsController { var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen + activeScreen: windowContainingScreen ) switch suggestionMode { case .nearbyTextCursor: @@ -294,7 +295,7 @@ extension WidgetWindowsController { .NearbyTextCursor() .framesForSuggestionWindow( editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + activeScreen: windowContainingScreen, editor: focusElement, completionPanel: xcodeInspector.completionPanel ) @@ -306,7 +307,7 @@ extension WidgetWindowsController { var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + activeScreen: windowContainingScreen, editor: focusElement ) switch suggestionMode { @@ -315,7 +316,7 @@ extension WidgetWindowsController { .NearbyTextCursor() .framesForSuggestionWindow( editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, + activeScreen: windowContainingScreen, editor: focusElement, completionPanel: xcodeInspector.completionPanel ) @@ -395,8 +396,6 @@ extension WidgetWindowsController { let latestActiveXcode = await xcodeInspector.safe.latestActiveXcode let previousActiveApplication = xcodeInspector.previousActiveApplication await MainActor.run { - let state = store.withState { $0 } - if let activeApp, activeApp.isXcode { let application = activeApp.appElement /// We need this to hide the windows when Xcode is minimized. @@ -875,7 +874,7 @@ class WidgetWindow: CanBecomeKeyWindow { case normal(fullscreen: Bool) case switchingSpace } - + var hoveringLevel: NSWindow.Level = widgetLevel(0) var defaultCollectionBehavior: NSWindow.CollectionBehavior { From 90eea5aec0314699a2db4effe13973ecff5643d4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 23:00:42 +0800 Subject: [PATCH 10/11] Add reference list to modification --- .../FeatureReducers/PromptToCodePanel.swift | 20 +- .../PromptToCodePanelView.swift | 205 +++++++++++++++++- .../ModificationBasic/ModificationAgent.swift | 5 +- .../ModificationBasic/ModificationState.swift | 11 +- 4 files changed, 226 insertions(+), 15 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index f32474df..44b53f96 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -1,4 +1,5 @@ import AppKit +import ChatBasic import ComposableArchitecture import CustomAsyncAlgorithms import Dependencies @@ -17,7 +18,7 @@ public struct PromptToCodePanel { public enum FocusField: Equatable { case textField } - + public enum ClickedButton: Equatable { case accept case acceptAndContinue @@ -42,7 +43,7 @@ public struct PromptToCodePanel { public var generateDescriptionRequirement: Bool public var clickedButton: ClickedButton? - + public var isActiveDocument: Bool = false public var snippetPanels: IdentifiedArrayOf { @@ -94,6 +95,7 @@ public struct PromptToCodePanel { case acceptAndContinueButtonTapped case revealFileButtonClicked case statusUpdated([String]) + case referencesUpdated([ChatMessage.Reference]) case snippetPanel(IdentifiedActionOf) } @@ -129,11 +131,10 @@ public struct PromptToCodePanel { let copiedState = state let contextInputController = state.contextInputController state.promptToCodeState.isGenerating = true - state.promptToCodeState - .pushHistory(instruction: .init( - attributedString: contextInputController - .instruction - )) + state.promptToCodeState.pushHistory(instruction: .init( + attributedString: contextInputController.instruction + )) + state.promptToCodeState.references = [] let snippets = state.promptToCodeState.snippets return .run { send in @@ -141,6 +142,7 @@ public struct PromptToCodePanel { let context = await contextInputController.resolveContext(onStatusChange: { await send(.statusUpdated($0)) }) + await send(.referencesUpdated(context.references)) let agentFactory = context.agent ?? { SimpleModificationAgent() } _ = try await withThrowingTaskGroup(of: Void.self) { group in for (index, snippet) in snippets.enumerated() { @@ -278,6 +280,10 @@ public struct PromptToCodePanel { case let .statusUpdated(status): state.promptToCodeState.status = status return .none + + case let .referencesUpdated(references): + state.promptToCodeState.references = references + return .none } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 73c90e4b..d048f360 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -1,3 +1,4 @@ +import ChatBasic import Cocoa import ComposableArchitecture import MarkdownUI @@ -205,6 +206,7 @@ extension PromptToCodePanelView { var body: some View { HStack { + ReferencesButton(store: store) StopRespondingButton(store: store) ActionButtons(store: store) } @@ -239,6 +241,43 @@ extension PromptToCodePanelView { } } + struct ReferencesButton: View { + let store: StoreOf + @State var isReferencesPresented = false + @State var isReferencesHovered = false + + var body: some View { + if !store.promptToCodeState.references.isEmpty { + Button(action: { + isReferencesPresented.toggle() + }, label: { + HStack(spacing: 4) { + Image(systemName: "doc.text.magnifyingglass") + Text("\(store.promptToCodeState.references.count)") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + }) + .buttonStyle(.plain) + .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) { + ReferenceList(store: store) + } + .onHover { hovering in + withAnimation { + isReferencesHovered = hovering + } + } + } + } + } + struct ActionButtons: View { @Perception.Bindable var store: StoreOf @AppStorage(\.chatModels) var chatModels @@ -361,10 +400,10 @@ extension PromptToCodePanelView { } } } - + struct RevealButton: View { let store: StoreOf - + var body: some View { WithPerceptionTracking { Button(action: { @@ -887,6 +926,157 @@ extension PromptToCodePanelView { } } } + + struct ReferenceList: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach( + 0.. Void + + var body: some View { + Button(action: onClick) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + ReferenceIcon(kind: reference.kind) + .layoutPriority(2) + Text(reference.title) + .truncationMode(.middle) + .lineLimit(1) + .layoutPriority(1) + .foregroundStyle(isUsed ? .primary : .secondary) + } + Text(reference.content) + .lineLimit(3) + .truncationMode(.tail) + .foregroundStyle(.tertiary) + .foregroundStyle(isUsed ? .secondary : .tertiary) + } + .padding(.vertical, 4) + .padding(.leading, 4) + .padding(.trailing) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay { + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + } + + struct ReferenceIcon: View { + let kind: ChatMessage.Reference.Kind + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill({ + switch kind { + case let .symbol(symbol, _, _, _): + switch symbol { + case .class: + Color.purple + case .struct: + Color.purple + case .enum: + Color.purple + case .actor: + Color.purple + case .protocol: + Color.purple + case .extension: + Color.indigo + case .case: + Color.green + case .property: + Color.teal + case .typealias: + Color.orange + case .function: + Color.teal + case .method: + Color.blue + } + case .text: + Color.gray + case .webpage: + Color.blue + case .textFile: + Color.gray + case .other: + Color.gray + case .error: + Color.red + } + }()) + .frame(width: 26, height: 14) + .overlay(alignment: .center) { + Group { + switch kind { + case let .symbol(symbol, _, _, _): + switch symbol { + case .class: + Text("C") + case .struct: + Text("S") + case .enum: + Text("E") + case .actor: + Text("A") + case .protocol: + Text("Pr") + case .extension: + Text("Ex") + case .case: + Text("K") + case .property: + Text("P") + case .typealias: + Text("T") + case .function: + Text("𝑓") + case .method: + Text("M") + } + case .text: + Text("Txt") + case .webpage: + Text("Web") + case .other: + Text("*") + case .textFile: + Text("Txt") + case .error: + Text("Err") + } + } + .font(.system(size: 10).monospaced()) + .foregroundColor(.white) + } + } + } } // MARK: - Previews @@ -916,7 +1106,7 @@ extension PromptToCodePanelView { end: .init(line: 12, character: 2) ) ), - ], instruction: .init("Previous instruction")), + ], instruction: .init("Previous instruction"), references: []), ], snippets: [ .init( @@ -951,7 +1141,14 @@ extension PromptToCodePanelView { ), ], extraSystemPrompt: "", - isAttachedToTarget: true + isAttachedToTarget: true, + references: [ + ChatMessage.Reference( + title: "Foo", + content: "struct Foo { var foo: Int }", + kind: .symbol(.struct, uri: "file:///path/to/file.txt", startLine: 13, endLine: 13) + ), + ], )), instruction: nil, commandName: "Generate Code" diff --git a/Tool/Sources/ModificationBasic/ModificationAgent.swift b/Tool/Sources/ModificationBasic/ModificationAgent.swift index 3d6c8193..69a409f7 100644 --- a/Tool/Sources/ModificationBasic/ModificationAgent.swift +++ b/Tool/Sources/ModificationBasic/ModificationAgent.swift @@ -101,13 +101,16 @@ public enum ModificationAttachedTarget: Equatable { public struct ModificationHistoryNode { public var snippets: IdentifiedArrayOf public var instruction: NSAttributedString + public var references: [ChatMessage.Reference] public init( snippets: IdentifiedArrayOf, - instruction: NSAttributedString + instruction: NSAttributedString, + references: [ChatMessage.Reference] ) { self.snippets = snippets self.instruction = instruction + self.references = references } } diff --git a/Tool/Sources/ModificationBasic/ModificationState.swift b/Tool/Sources/ModificationBasic/ModificationState.swift index 40bbe8f0..51b7b28b 100644 --- a/Tool/Sources/ModificationBasic/ModificationState.swift +++ b/Tool/Sources/ModificationBasic/ModificationState.swift @@ -1,3 +1,4 @@ +import ChatBasic import Foundation import IdentifiedCollections import SuggestionBasic @@ -12,6 +13,7 @@ public struct ModificationState { public var extraSystemPrompt: String public var isAttachedToTarget: Bool = true public var status = [String]() + public var references: [ChatMessage.Reference] = [] public init( source: Source, @@ -20,7 +22,8 @@ public struct ModificationState { extraSystemPrompt: String, isAttachedToTarget: Bool, isGenerating: Bool = false, - status: [String] = [] + status: [String] = [], + references: [ChatMessage.Reference] = [] ) { self.history = history self.snippets = snippets @@ -29,6 +32,7 @@ public struct ModificationState { self.extraSystemPrompt = extraSystemPrompt self.source = source self.status = status + self.references = references } public init( @@ -58,16 +62,17 @@ public struct ModificationState { public mutating func popHistory() -> NSAttributedString? { if !history.isEmpty { let last = history.removeLast() + references = last.references snippets = last.snippets let instruction = last.instruction return instruction } - + return nil } public mutating func pushHistory(instruction: NSAttributedString) { - history.append(.init(snippets: snippets, instruction: instruction)) + history.append(.init(snippets: snippets, instruction: instruction, references: references)) let oldSnippets = snippets snippets = IdentifiedArrayOf() for var snippet in oldSnippets { From 66a28d0f71c2dec2663bcb9bb6d2e10f73c12e2e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 10 Aug 2025 23:35:52 +0800 Subject: [PATCH 11/11] Bump version --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index bfd1a944..6233a251 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,4 +1,4 @@ -APP_VERSION = 0.35.8 -APP_BUILD = 458 +APP_VERSION = 0.35.9 +APP_BUILD = 461 RELEASE_CHANNEL = RELEASE_NUMBER = 1