From 37ec23eda59a8ac2b73c751d95eade5725821c84 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 11 Jul 2023 19:04:00 +0800 Subject: [PATCH 01/94] Add BrowserChatTab --- .gitmodules | 3 ++ Copilot for Xcode.xcodeproj/project.pbxproj | 2 + Core/Package.swift | 42 +++++++++++++++++-- .../ChatGPTChatTab.swift | 3 +- .../ChatPanel.swift | 0 .../ChatProvider.swift | 0 .../ChatGPT => ChatGPTChatTab}/Styles.swift | 0 ...aphicalUserInterfaceController.swift.swift | 1 + .../Service/GUI/WidgetDataSource.swift | 1 - .../SuggestionWidget/ChatWindowView.swift | 7 ++-- .../FeatureReducers/WidgetFeature.swift | 3 +- .../SuggestionWidgetDataSource.swift | 1 - Pro | 1 + Tool/Package.swift | 15 +++++++ {Core => Tool}/Sources/ChatTab/ChatTab.swift | 0 15 files changed, 67 insertions(+), 12 deletions(-) rename Core/Sources/{ChatTab/ChatGPT => ChatGPTChatTab}/ChatGPTChatTab.swift (99%) rename Core/Sources/{ChatTab/ChatGPT => ChatGPTChatTab}/ChatPanel.swift (100%) rename Core/Sources/{ChatTab/ChatGPT => ChatGPTChatTab}/ChatProvider.swift (100%) rename Core/Sources/{ChatTab/ChatGPT => ChatGPTChatTab}/Styles.swift (100%) create mode 160000 Pro rename {Core => Tool}/Sources/ChatTab/ChatTab.swift (100%) diff --git a/.gitmodules b/.gitmodules index e69de29b..a091985c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Pro"] + path = Pro + url = git@github.com:intitni/CopilotForXcodePro.git diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 4430a70a..2f1964c2 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -162,6 +162,7 @@ C861E61F2994F6390056CB02 /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; }; C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommand.swift; sourceTree = ""; }; C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorCommand.swift; sourceTree = ""; }; + C87903302A5D2E6400FE6F42 /* Pro */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pro; sourceTree = ""; }; C87B03A3293B24AB00C77EAE /* Copilot-for-Xcode-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Copilot-for-Xcode-Info.plist"; sourceTree = SOURCE_ROOT; }; C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptSuggestionCommand.swift; sourceTree = ""; }; C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectSuggestionCommand.swift; sourceTree = ""; }; @@ -257,6 +258,7 @@ C83E3F3E2A38C66D0071506D /* Python */, C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, + C87903302A5D2E6400FE6F42 /* Pro */, C8189B182938972F00C9DCDA /* Copilot for Xcode */, C81458922939EFDC00135263 /* EditorExtension */, C8216B71298036EC00AD38C7 /* Helper */, diff --git a/Core/Package.swift b/Core/Package.swift index 2595833c..194a7b45 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -56,7 +56,7 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), - ], + ].pro, targets: [ // MARK: - Main @@ -88,7 +88,8 @@ let package = Package( "PromptToCodeService", "ServiceUpdateMigration", "UserDefaultsObserver", - "ChatTab", + "ChatGPTChatTab", + .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -223,12 +224,13 @@ let package = Package( ), .target( - name: "ChatTab", + name: "ChatGPTChatTab", dependencies: [ "SharedUIComponents", "ChatService", .product(name: "OpenAIService", package: "Tool"), .product(name: "Logger", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), ] ), @@ -248,13 +250,14 @@ let package = Package( .target( name: "SuggestionWidget", dependencies: [ - "ChatTab", + "ChatGPTChatTab", "ActiveApplicationMonitor", "AXNotificationStream", "Environment", "UserDefaultsObserver", "XcodeInspector", "SharedUIComponents", + .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), @@ -391,3 +394,34 @@ let package = Package( ] ) +// MARK: - Pro + +extension [Target.Dependency] { + func pro(_ targetNames: [String]) -> [Target.Dependency] { + if isProIncluded() { + return self + targetNames.map { Target.Dependency.product(name: $0, package: "Pro") } + } + return self + } +} + +extension [Package.Dependency] { + var pro: [Package.Dependency] { + if isProIncluded() { + return self + [.package(path: "../Pro")] + } + return self + } +} + +import Foundation + +func isProIncluded(file: StaticString = #file) -> Bool { + let filePath = "\(file)" + let url = URL(fileURLWithPath: filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Pro/Package.swift") + return FileManager.default.fileExists(atPath: url.path) +} + diff --git a/Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift similarity index 99% rename from Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift rename to Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 6609fcd3..0040e834 100644 --- a/Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -1,4 +1,5 @@ import ChatService +import ChatTab import Combine import Foundation import SwiftUI @@ -17,7 +18,7 @@ public class ChatGPTChatTab: ChatTab { self.service = service provider = .init(service: service) super.init(id: "Chat-" + provider.id.uuidString, title: "Chat") - + provider.$history.sink { [weak self] _ in if let title = self?.provider.title { self?.title = title diff --git a/Core/Sources/ChatTab/ChatGPT/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift similarity index 100% rename from Core/Sources/ChatTab/ChatGPT/ChatPanel.swift rename to Core/Sources/ChatGPTChatTab/ChatPanel.swift diff --git a/Core/Sources/ChatTab/ChatGPT/ChatProvider.swift b/Core/Sources/ChatGPTChatTab/ChatProvider.swift similarity index 100% rename from Core/Sources/ChatTab/ChatGPT/ChatProvider.swift rename to Core/Sources/ChatGPTChatTab/ChatProvider.swift diff --git a/Core/Sources/ChatTab/ChatGPT/Styles.swift b/Core/Sources/ChatGPTChatTab/Styles.swift similarity index 100% rename from Core/Sources/ChatTab/ChatGPT/Styles.swift rename to Core/Sources/ChatGPTChatTab/Styles.swift diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift index f7fc7196..1eb6258d 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift @@ -1,4 +1,5 @@ import AppKit +import ChatGPTChatTab import ChatTab import ComposableArchitecture import Environment diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 5f63b9ae..d8ed98df 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -1,6 +1,5 @@ import ActiveApplicationMonitor import ChatService -import ChatTab import ComposableArchitecture import Foundation import GitHubCopilotService diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2afa2d23..65366092 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -1,5 +1,6 @@ import ActiveApplicationMonitor import AppKit +import ChatGPTChatTab import ChatTab import ComposableArchitecture import SwiftUI @@ -31,9 +32,9 @@ struct ChatWindowView: View { .fill(.tertiary) .frame(width: 120, height: 4) .frame(height: 16) - + Divider() - + ChatTabBar(store: store) .frame(height: 26) @@ -50,7 +51,7 @@ struct ChatWindowView: View { } .opacity(0) .keyboardShortcut("M", modifiers: [.command]) - + Button(action: { viewStore.send(.closeActiveTabClicked) }) { diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 830feabc..95960df1 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -1,7 +1,6 @@ import ActiveApplicationMonitor import AsyncAlgorithms import AXNotificationStream -import ChatTab import ComposableArchitecture import Environment import Foundation @@ -250,7 +249,7 @@ public struct WidgetFeature: ReducerProtocol { else { continue } guard await windows.fullscreenDetector.isOnActiveSpace else { continue } let app = AXUIElementCreateApplication(activeXcode.processIdentifier) - if let window = app.focusedWindow { + if let _ = app.focusedWindow { await windows.orderFront() } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index 150ab938..387dec23 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -1,4 +1,3 @@ -import ChatTab import Foundation public protocol SuggestionWidgetDataSource { diff --git a/Pro b/Pro new file mode 160000 index 00000000..18369522 --- /dev/null +++ b/Pro @@ -0,0 +1 @@ +Subproject commit 18369522f6c861c877ed6fb33a8bdf406816ac0e diff --git a/Tool/Package.swift b/Tool/Package.swift index 2de22106..22cd1591 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -13,6 +13,7 @@ let package = Package( .library(name: "Preferences", targets: ["Preferences", "Configs"]), .library(name: "Logger", targets: ["Logger"]), .library(name: "OpenAIService", targets: ["OpenAIService"]), + .library(name: "ChatTab", targets: ["ChatTab"]), ], dependencies: [ // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. @@ -22,6 +23,10 @@ let package = Package( .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), .package(url: "https://github.com/unum-cloud/usearch", from: "0.19.1"), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture", + from: "0.55.0" + ), ], targets: [ // MARK: - Helpers @@ -87,6 +92,16 @@ let package = Package( dependencies: ["OpenAIService"] ), + // MARK: - UI + + .target( + name: "ChatTab", + dependencies: [.product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + )] + ), + // MARK: - Tests .testTarget( diff --git a/Core/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift similarity index 100% rename from Core/Sources/ChatTab/ChatTab.swift rename to Tool/Sources/ChatTab/ChatTab.swift From f8f7e965c4653aefb5195fd9bc383e6842667528 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 11 Jul 2023 19:13:58 +0800 Subject: [PATCH 02/94] Move chat context menu to tab bar --- .../ChatGPTChatTab/ChatContextMenu.swift | 69 +++++++++++++++++++ .../ChatGPTChatTab/ChatGPTChatTab.swift | 4 ++ Core/Sources/ChatGPTChatTab/ChatPanel.swift | 69 ------------------- .../SuggestionWidget/ChatWindowView.swift | 15 ++++ Tool/Sources/ChatTab/ChatTab.swift | 15 +++- 5 files changed, 101 insertions(+), 71 deletions(-) create mode 100644 Core/Sources/ChatGPTChatTab/ChatContextMenu.swift diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift new file mode 100644 index 00000000..ba115bef --- /dev/null +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -0,0 +1,69 @@ +import AppKit +import SharedUIComponents +import SwiftUI + +struct ChatContextMenu: View { + let chat: ChatProvider + @AppStorage(\.customCommands) var customCommands + + var body: some View { + Group { + currentSystemPrompt + currentExtraSystemPrompt + resetPrompt + + Divider() + + customCommandMenu + } + } + + @ViewBuilder + var currentSystemPrompt: some View { + Text("System Prompt:") + Text({ + var text = chat.systemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } + + @ViewBuilder + var currentExtraSystemPrompt: some View { + Text("Extra Prompt:") + Text({ + var text = chat.extraSystemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } + + var resetPrompt: some View { + Button("Reset System Prompt") { + chat.resetPrompt() + } + } + + var customCommandMenu: some View { + Menu("Custom Commands") { + ForEach( + customCommands.filter { + switch $0.feature { + case .chatWithSelection, .customChat: return true + case .promptToCode: return false + case .singleRoundDialog: return false + } + }, + id: \.name + ) { command in + Button(action: { + chat.triggerCustomCommand(command) + }) { + Text(command.name) + } + } + } + } +} diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 0040e834..19b3f7b6 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -13,6 +13,10 @@ public class ChatGPTChatTab: ChatTab { public func buildView() -> any View { ChatPanel(chat: provider) } + + public func buildMenu() -> any View { + ChatContextMenu(chat: provider) + } public init(service: ChatService = .init()) { self.service = service diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index a14354d7..ddb105b5 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -302,9 +302,6 @@ struct ChatPanelInputArea: View { } .padding(8) .background(.ultraThickMaterial) - .contextMenu { - ChatContextMenu(chat: chat) - } } var clearButton: some View { @@ -412,72 +409,6 @@ struct ChatPanelInputArea: View { } } -struct ChatContextMenu: View { - let chat: ChatProvider - @AppStorage(\.customCommands) var customCommands - - var body: some View { - Group { - currentSystemPrompt - currentExtraSystemPrompt - resetPrompt - - Divider() - - customCommandMenu - } - } - - @ViewBuilder - var currentSystemPrompt: some View { - Text("System Prompt:") - Text({ - var text = chat.systemPrompt - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } - - @ViewBuilder - var currentExtraSystemPrompt: some View { - Text("Extra Prompt:") - Text({ - var text = chat.extraSystemPrompt - if text.isEmpty { text = "N/A" } - if text.count > 30 { text = String(text.prefix(30)) + "..." } - return text - }() as String) - } - - var resetPrompt: some View { - Button("Reset System Prompt") { - chat.resetPrompt() - } - } - - var customCommandMenu: some View { - Menu("Custom Commands") { - ForEach( - customCommands.filter { - switch $0.feature { - case .chatWithSelection, .customChat: return true - case .promptToCode: return false - case .singleRoundDialog: return false - } - }, - id: \.name - ) { command in - Button(action: { - chat.triggerCustomCommand(command) - }) { - Text(command.name) - } - } - } - } -} - struct RoundedCorners: Shape { var tl: CGFloat = 0.0 var tr: CGFloat = 0.0 diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 65366092..50c524b0 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -73,6 +73,7 @@ struct ChatTabBar: View { let store: StoreOf struct TabBarState: Equatable { + var tabs: [BaseChatTab] var tabInfo: [ChatTabInfo] var selectedTabId: String } @@ -81,6 +82,7 @@ struct ChatTabBar: View { WithViewStore( store, observe: { TabBarState( + tabs: $0.chatTapGroup.tabs, tabInfo: $0.chatTapGroup.tabInfo, selectedTabId: $0.chatTapGroup.selectedTabId ?? $0.chatTapGroup.tabInfo.first?.id ?? "" @@ -95,6 +97,13 @@ struct ChatTabBar: View { info: info, isSelected: info.id == viewStore.state.selectedTabId ) + .contextMenu { + if let tab = viewStore.state.tabs + .first(where: { $0.id == info.id }) + { + tab.menu + } + } } } } @@ -194,6 +203,12 @@ struct ChatTabContainer: View { struct ChatWindowView_Previews: PreviewProvider { class FakeChatTab: ChatTab { + func buildMenu() -> any View { + Text("Menu Item") + Text("Menu Item") + Text("Menu Item") + } + func buildView() -> any View { ChatPanel( chat: .init( diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index b41c4319..b575fe95 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -62,7 +62,7 @@ open class BaseChatTab: Equatable { @ViewBuilder public var body: some View { - let id = "BaseChatTab\(info.id)" + let id = "ChatTabBody\(info.id)" if let tab = self as? ChatTabType { ContentView(info: info, buildView: tab.buildView).id(id) } else { @@ -72,7 +72,12 @@ open class BaseChatTab: Equatable { @ViewBuilder public var menu: some View { - EmptyView() + let id = "ChatTabMenu\(info.id)" + if let tab = self as? ChatTabType { + ContentView(info: info, buildView: tab.buildMenu).id(id) + } else { + EmptyView().id(id) + } } public static func == (lhs: BaseChatTab, rhs: BaseChatTab) -> Bool { @@ -83,6 +88,8 @@ open class BaseChatTab: Equatable { public protocol ChatTabType { @ViewBuilder func buildView() -> any View + @ViewBuilder + func buildMenu() -> any View } public class EmptyChatTab: ChatTab { @@ -92,6 +99,10 @@ public class EmptyChatTab: ChatTab { } .background(Color.blue) } + + public func buildMenu() -> any View { + EmptyView() + } public init(id: String = UUID().uuidString) { super.init(id: id, title: "Empty") From 6f0c727279bdb7fc4e53cc1ef7ac676d472059ae Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 01:03:18 +0800 Subject: [PATCH 03/94] Support creating different types of chats --- Core/Package.swift | 90 +++------- .../ChatGPTChatTab/ChatGPTChatTab.swift | 32 +++- ...aphicalUserInterfaceController.swift.swift | 92 +++++++++-- .../SuggestionWidget/ChatWindowView.swift | 156 ++++++++++++++---- .../FeatureReducers/ChatPanelFeature.swift | 38 ++++- .../SuggestionWidget/ModuleDependency.swift | 11 ++ Pro | 2 +- Tool/Package.swift | 54 ++++++ .../Sources/AXExtension/AXUIElement.swift | 0 .../AXNotificationStream.swift | 0 .../ActiveApplicationMonitor.swift | 0 Tool/Sources/ChatTab/ChatTab.swift | 51 +++++- .../Sources/Environment/Environment.swift | 0 .../SuggestionModel/CodeSuggestion.swift | 0 .../SuggestionModel/ExportedFromLSP.swift | 0 .../LanguageIdentifierFromFilePath.swift | 0 .../SuggestionModel/Modification.swift | 0 .../Sources/XcodeInspector/Helpers.swift | 0 .../Sources/XcodeInspector/SourceEditor.swift | 0 .../XcodeInspector/XcodeInspector.swift | 0 .../XcodeInspector/XcodeWindowInspector.swift | 0 .../ModificationTests.swift | 0 22 files changed, 405 insertions(+), 121 deletions(-) rename {Core => Tool}/Sources/AXExtension/AXUIElement.swift (100%) rename {Core => Tool}/Sources/AXNotificationStream/AXNotificationStream.swift (100%) rename {Core => Tool}/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift (100%) rename {Core => Tool}/Sources/Environment/Environment.swift (100%) rename {Core => Tool}/Sources/SuggestionModel/CodeSuggestion.swift (100%) rename {Core => Tool}/Sources/SuggestionModel/ExportedFromLSP.swift (100%) rename {Core => Tool}/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift (100%) rename {Core => Tool}/Sources/SuggestionModel/Modification.swift (100%) rename {Core => Tool}/Sources/XcodeInspector/Helpers.swift (100%) rename {Core => Tool}/Sources/XcodeInspector/SourceEditor.swift (100%) rename {Core => Tool}/Sources/XcodeInspector/XcodeInspector.swift (100%) rename {Core => Tool}/Sources/XcodeInspector/XcodeWindowInspector.swift (100%) rename {Core => Tool}/Tests/SuggestionModelTests/ModificationTests.swift (100%) diff --git a/Core/Package.swift b/Core/Package.swift index 194a7b45..37f50a5b 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -16,13 +16,11 @@ let package = Package( "LaunchAgentManager", "UpdateChecker", "UserDefaultsObserver", - "XcodeInspector", ] ), .library( name: "Client", targets: [ - "SuggestionModel", "Client", "XPCShared", ] @@ -31,7 +29,6 @@ let package = Package( name: "HostApp", targets: [ "HostApp", - "SuggestionModel", "GitHubCopilotService", "Client", "XPCShared", @@ -63,9 +60,9 @@ let package = Package( .target( name: "Client", dependencies: [ - "SuggestionModel", "XPCShared", "GitHubCopilotService", + .product(name: "SuggestionModel", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), ] @@ -73,29 +70,29 @@ let package = Package( .target( name: "Service", dependencies: [ - "SuggestionModel", "SuggestionService", "GitHubCopilotService", "XPCShared", "CGEventObserver", "DisplayLink", - "ActiveApplicationMonitor", - "AXNotificationStream", - "Environment", "SuggestionWidget", - "AXExtension", "ChatService", "PromptToCodeService", "ServiceUpdateMigration", "UserDefaultsObserver", "ChatGPTChatTab", + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Environment", package: "Tool"), + .product(name: "SuggestionModel", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] + ].pro([ + "ProChatTabs", + ]) ), .testTarget( name: "ServiceTests", @@ -105,16 +102,8 @@ let package = Package( "GitHubCopilotService", "SuggestionInjector", "XPCShared", - "Environment", - "SuggestionModel", - .product(name: "Preferences", package: "Tool"), - ] - ), - .target( - name: "Environment", - dependencies: [ - "ActiveApplicationMonitor", - "AXExtension", + .product(name: "SuggestionModel", package: "Tool"), + .product(name: "Environment", package: "Tool"), .product(name: "Preferences", package: "Tool"), ] ), @@ -127,8 +116,8 @@ let package = Package( "Client", "GitHubCopilotService", "CodeiumService", - "SuggestionModel", "LaunchAgentManager", + .product(name: "SuggestionModel", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -140,22 +129,14 @@ let package = Package( .target( name: "XPCShared", - dependencies: ["SuggestionModel"] + dependencies: [.product(name: "SuggestionModel", package: "Tool"),] ), // MARK: - Suggestion Service - .target( - name: "SuggestionModel", - dependencies: ["LanguageClient"] - ), - .testTarget( - name: "SuggestionModelTests", - dependencies: ["SuggestionModel"] - ), .target( name: "SuggestionInjector", - dependencies: ["SuggestionModel"] + dependencies: [.product(name: "SuggestionModel", package: "Tool"),] ), .testTarget( name: "SuggestionInjectorTests", @@ -172,9 +153,9 @@ let package = Package( .target( name: "PromptToCodeService", dependencies: [ - "Environment", "GitHubCopilotService", - "SuggestionModel", + .product(name: "SuggestionModel", package: "Tool"), + .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), ] ), @@ -187,8 +168,6 @@ let package = Package( dependencies: [ "ChatPlugin", "ChatContextCollector", - "Environment", - "XcodeInspector", // plugins "MathChatPlugin", @@ -198,6 +177,8 @@ let package = Package( // context collectors "WebChatContextCollector", + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Environment", package: "Tool"), .product(name: "Parsing", package: "swift-parsing"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -207,7 +188,7 @@ let package = Package( .target( name: "ChatPlugin", dependencies: [ - "Environment", + .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Terminal", package: "Tool"), ] @@ -215,9 +196,9 @@ let package = Package( .target( name: "ChatContextCollector", dependencies: [ - "Environment", - "SuggestionModel", - "XcodeInspector", + .product(name: "SuggestionModel", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), ] @@ -251,12 +232,10 @@ let package = Package( name: "SuggestionWidget", dependencies: [ "ChatGPTChatTab", - "ActiveApplicationMonitor", - "AXNotificationStream", - "Environment", "UserDefaultsObserver", - "XcodeInspector", "SharedUIComponents", + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Environment", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), @@ -277,13 +256,6 @@ let package = Package( .target(name: "FileChangeChecker"), .target(name: "LaunchAgentManager"), .target(name: "DisplayLink"), - .target(name: "ActiveApplicationMonitor"), - .target( - name: "AXNotificationStream", - dependencies: [ - .product(name: "Logger", package: "Tool"), - ] - ), .target( name: "UpdateChecker", dependencies: [ @@ -291,7 +263,6 @@ let package = Package( .product(name: "Logger", package: "Tool"), ] ), - .target(name: "AXExtension"), .target( name: "ServiceUpdateMigration", dependencies: [ @@ -300,17 +271,6 @@ let package = Package( ] ), .target(name: "UserDefaultsObserver"), - .target( - name: "XcodeInspector", - dependencies: [ - "AXExtension", - "SuggestionModel", - "Environment", - "AXNotificationStream", - .product(name: "Logger", package: "Tool"), - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - ] - ), // MARK: - GitHub Copilot @@ -318,8 +278,8 @@ let package = Package( name: "GitHubCopilotService", dependencies: [ "LanguageClient", - "SuggestionModel", "XPCShared", + .product(name: "SuggestionModel", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Terminal", package: "Tool"), @@ -337,9 +297,9 @@ let package = Package( name: "CodeiumService", dependencies: [ "LanguageClient", - "SuggestionModel", "KeychainAccess", - "XcodeInspector", + .product(name: "SuggestionModel", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Terminal", package: "Tool"), ] diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 19b3f7b6..9136d21a 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -2,22 +2,52 @@ import ChatService import ChatTab import Combine import Foundation +import Preferences import SwiftUI /// A chat tab that provides a context aware chat bot, powered by ChatGPT. public class ChatGPTChatTab: ChatTab { + public static var name: String { "Chat" } + public let service: ChatService public let provider: ChatProvider private var cancellable = Set() + struct Builder: ChatTabBuilder { + var title: String + var customCommand: CustomCommand? + + func build() -> any ChatTab { + let tab = ChatGPTChatTab() + Task { + if let customCommand { + try await tab.service.handleCustomCommand(customCommand) + } + } + return tab + } + } + public func buildView() -> any View { ChatPanel(chat: provider) } - + public func buildMenu() -> any View { ChatContextMenu(chat: provider) } + public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { + let customCommands = UserDefaults.shared.value(for: \.customCommands).compactMap { + command in + if case .customChat = command.feature { + return Builder(title: command.name, customCommand: command) + } + return nil + } + + return [Builder(title: "ChatGPT", customCommand: nil)] + customCommands + } + public init(service: ChatService = .init()) { self.service = service provider = .init(service: service) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift index 1eb6258d..a6831183 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift @@ -4,7 +4,9 @@ import ChatTab import ComposableArchitecture import Environment import Preferences +import SuggestionModel import SuggestionWidget +import XcodeInspector struct GUI: ReducerProtocol { struct State: Equatable { @@ -35,9 +37,8 @@ struct GUI: ReducerProtocol { ) { Reduce { _, action in switch action { - case let .createNewTapButtonClicked(type): - _ = type // always ChatGPTChatTab at the moment. - let chatTap = ChatGPTChatTab() + case let .createNewTapButtonClicked(kind): + let chatTap = kind?.builder.build() ?? ChatGPTChatTab() return .run { send in await send(.appendAndSelectTab(chatTap)) } @@ -114,24 +115,31 @@ public final class GraphicalUserInterfaceController { private init() { let suggestionDependency = SuggestionWidgetControllerDependency() + let setupDependency: (inout DependencyValues) -> Void = { dependencies in + dependencies.suggestionWidgetControllerDependency = suggestionDependency + dependencies.suggestionWidgetUserDefaultsObservers = .init() + dependencies.chatTabBuilderCollection = { + ChatTabFactory.chatTabBuilderCollection + } + } let store = StoreOf( initialState: .init(), - reducer: GUI() - ) { dependencies in - dependencies.suggestionWidgetControllerDependency = suggestionDependency - dependencies.suggestionWidgetUserDefaultsObservers = .init() - } + reducer: GUI(), + prepareDependencies: setupDependency + ) self.store = store viewStore = ViewStore(store) widgetDataSource = .init() - widgetController = SuggestionWidgetController( - store: store.scope( - state: \.suggestionWidgetState, - action: GUI.Action.suggestionWidget - ), - dependency: suggestionDependency - ) + widgetController = withDependencies(setupDependency) { + SuggestionWidgetController( + store: store.scope( + state: \.suggestionWidgetState, + action: GUI.Action.suggestionWidget + ), + dependency: suggestionDependency + ) + } suggestionDependency.suggestionWidgetDataSource = widgetDataSource suggestionDependency.onOpenChatClicked = { [weak self] in @@ -156,3 +164,57 @@ public final class GraphicalUserInterfaceController { } } +#if canImport(ProChatTabs) +import ProChatTabs + +enum ChatTabFactory { + static var chatTabBuilderCollection: [ChatTabBuilderCollection] { + func folderIfNeeded( + _ builders: [any ChatTabBuilder], + title: String + ) -> ChatTabBuilderCollection? { + if builders.count > 1 { + return .folder(title: title, kinds: builders.map(ChatTabKind.init)) + } + if let first = builders.first { return .kind(ChatTabKind(first)) } + return nil + } + + let collection = [ + folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), + folderIfNeeded(BrowserChatTab.chatBuilders(externalDependency: .init(getEditorContent: { + guard let editor = XcodeInspector.shared.focusedEditor else { + return .init(selectedText: "", language: "", fileContent: "") + } + let content = editor.content + return .init( + selectedText: content.selectedContent, + language: languageIdentifierFromFileURL(XcodeInspector.shared.activeDocumentURL) + .rawValue, + fileContent: content.content + ) + })), title: BrowserChatTab.name), + ].compactMap { $0 } + + return collection + } +} + +#else + +enum ChatTabFactory { + static var chatTabBuilderCollection: [ChatTabBuilderCollection] { + func folderIfNeeded(_ builders: [any ChatTabBuilder]) -> ChatTabBuilderCollection? { + if builders.count > 1 { return .folder(builders) } + if let first = builders.first { return .type(first) } + return nil + } + + return [ + folderIfNeeded(ChatGPTChatTab.chatBuilders()), + ].compactMap { $0 } + } +} + +#endif + diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 50c524b0..df030c42 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -78,6 +78,9 @@ struct ChatTabBar: View { var selectedTabId: String } + @State var isHoveringCreateButton = false + @State var isHoveringMenuButton = false + var body: some View { WithViewStore( store, @@ -110,13 +113,87 @@ struct ChatTabBar: View { Divider() - Button(action: { - store.send(.createNewTapButtonClicked(type: "")) - }) { - Image(systemName: "plus") - .foregroundColor(.secondary) - .padding(8) - }.buttonStyle(.plain) + createButton + } + } + } + + @ViewBuilder + var createButton: some View { + HStack(spacing: 0) { + Button(action: { + store.send(.createNewTapButtonClicked(kind: nil)) + }) { + Image(systemName: "plus") + .foregroundColor(.secondary) + .padding(.leading, 8) + .padding(.trailing, 4) + .frame(maxHeight: .infinity) + } + .buttonStyle(.plain) + .background { + if isHoveringCreateButton { + RoundedRectangle(cornerRadius: 2) + .fill(Color(nsColor: .controlTextColor).opacity(0.1)) + } + } + .onHover { isHoveringCreateButton = $0 } + + Menu { + WithViewStore(store, observe: { $0.chatTapGroup.tabCollection }) { viewStore in + ForEach(0.. any View { - Text("Menu Item") - Text("Menu Item") - Text("Menu Item") - } - - func buildView() -> any View { - ChatPanel( - chat: .init( - history: [ - .init(id: "1", role: .assistant, text: "Hello World"), - ], - isReceivingMessage: false - ), - typedMessage: "Hello World!" - ) - } +struct CreateOtherChatTabMenuStyle: MenuStyle { + func makeBody(configuration: Configuration) -> some View { + Image(systemName: "chevron.down") + .resizable() + .frame(width: 7, height: 4) + .frame(maxHeight: .infinity) + .padding(.leading, 4) + .padding(.trailing, 8) + .foregroundColor(.secondary) + } +} - override init(id: String, title: String) { - super.init(id: id, title: title) +class FakeChatTab: ChatTab { + static var name: String { "Fake" } + static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { [Builder()] } + + struct Builder: ChatTabBuilder { + var title: String = "Title" + + func build() -> any ChatTab { + return FakeChatTab(id: "id", title: "Title") } } + func buildMenu() -> any View { + Text("Menu Item") + Text("Menu Item") + Text("Menu Item") + } + + func buildView() -> any View { + ChatPanel( + chat: .init( + history: [ + .init(id: "1", role: .assistant, text: "Hello World"), + ], + isReceivingMessage: false + ), + typedMessage: "Hello World!" + ) + } + + override init(id: String, title: String) { + super.init(id: id, title: title) + } +} + +struct ChatWindowView_Previews: PreviewProvider { static var previews: some View { ChatWindowView( store: .init( diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 8570067c..3e9d13be 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -4,22 +4,40 @@ import ChatTab import ComposableArchitecture import SwiftUI +public enum ChatTabBuilderCollection: Equatable { + case folder(title: String, kinds: [ChatTabKind]) + case kind(ChatTabKind) +} + +public struct ChatTabKind: Equatable { + public var builder: any ChatTabBuilder + var title: String { builder.title } + + public init(_ builder: any ChatTabBuilder) { + self.builder = builder + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + } +} + public struct ChatPanelFeature: ReducerProtocol { public struct ChatTabGroup: Equatable { public var tabs: [BaseChatTab] - public var tabTypes: [String] public var tabInfo: [ChatTabInfo] + public var tabCollection: [ChatTabBuilderCollection] public var selectedTabId: String? init( tabs: [BaseChatTab] = [], - tabTypes: [String] = [], tabInfo: [ChatTabInfo] = [], + tabCollection: [ChatTabBuilderCollection] = [], selectedTabId: String? = nil ) { self.tabs = tabs - self.tabTypes = tabTypes self.tabInfo = tabInfo + self.tabCollection = tabCollection self.selectedTabId = selectedTabId } @@ -48,8 +66,9 @@ public struct ChatPanelFeature: ReducerProtocol { // Tabs case updateChatTabInfo([ChatTabInfo]) + case createNewTapButtonHovered case closeTabButtonClicked(id: String) - case createNewTapButtonClicked(type: String) + case createNewTapButtonClicked(kind: ChatTabKind?) case tabClicked(id: String) case appendAndSelectTab(BaseChatTab) } @@ -58,6 +77,7 @@ public struct ChatPanelFeature: ReducerProtocol { @Dependency(\.xcodeInspector) var xcodeInspector @Dependency(\.activatePreviouslyActiveXcode) var activatePreviouslyActiveXcode @Dependency(\.activateExtensionService) var activateExtensionService + @Dependency(\.chatTabBuilderCollection) var chatTabBuilderCollection public var body: some ReducerProtocol { Reduce { state, action in @@ -68,17 +88,17 @@ public struct ChatPanelFeature: ReducerProtocol { return .run { _ in await activatePreviouslyActiveXcode() } - + case .closeActiveTabClicked: if let id = state.chatTapGroup.selectedTabId { return .run { send in await send(.closeTabButtonClicked(id: id)) } } - + state.isPanelDisplayed = false return .none - + case .toggleChatPanelDetachedButtonClicked: state.chatPanelInASeparateWindow.toggle() return .none @@ -126,6 +146,10 @@ public struct ChatPanelFeature: ReducerProtocol { state.isPanelDisplayed = false } return .none + + case .createNewTapButtonHovered: + state.chatTapGroup.tabCollection = chatTabBuilderCollection() + return .none case .createNewTapButtonClicked: return .none // handled elsewhere diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index de2320cc..b7a9c16f 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -72,6 +72,12 @@ struct ActiveApplicationMonitorKey: DependencyKey { static let liveValue = ActiveApplicationMonitor.self } +struct ChatTabBuilderCollectionKey: DependencyKey { + static let liveValue: () -> [ChatTabBuilderCollection] = { + [.folder(title: "A", kinds: FakeChatTab.chatBuilders().map(ChatTabKind.init))] + } +} + struct ActivatePreviouslyActiveXcodeKey: DependencyKey { static let liveValue = { @MainActor in @Dependency(\.activeApplicationMonitor) var activeApplicationMonitor @@ -99,6 +105,11 @@ public extension DependencyValues { get { self[UserDefaultsDependencyKey.self] } set { self[UserDefaultsDependencyKey.self] = newValue } } + + var chatTabBuilderCollection: () -> [ChatTabBuilderCollection] { + get { self[ChatTabBuilderCollectionKey.self] } + set { self[ChatTabBuilderCollectionKey.self] = newValue } + } } extension DependencyValues { diff --git a/Pro b/Pro index 18369522..e717b47b 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 18369522f6c861c877ed6fb33a8bdf406816ac0e +Subproject commit e717b47b7125d66655e72e7886facf5ebe435f99 diff --git a/Tool/Package.swift b/Tool/Package.swift index 22cd1591..dc5468d4 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -14,10 +14,22 @@ let package = Package( .library(name: "Logger", targets: ["Logger"]), .library(name: "OpenAIService", targets: ["OpenAIService"]), .library(name: "ChatTab", targets: ["ChatTab"]), + .library(name: "Environment", targets: ["Environment"]), + .library(name: "SuggestionModel", targets: ["SuggestionModel"]), + .library( + name: "AppMonitoring", + targets: [ + "XcodeInspector", + "ActiveApplicationMonitor", + "AXExtension", + "AXNotificationStream", + ] + ), ], dependencies: [ // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. .package(url: "https://github.com/intitni/Tiktoken", branch: "main"), + .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), @@ -41,6 +53,17 @@ let package = Package( .target(name: "ObjectiveCExceptionHandling"), + .target( + name: "Environment", + dependencies: [ + "ActiveApplicationMonitor", + "AXExtension", + "Preferences", + ] + ), + + .target(name: "ActiveApplicationMonitor"), + .target(name: "USearchIndex", dependencies: [ "ObjectiveCExceptionHandling", .product(name: "USearch", package: "usearch"), @@ -60,6 +83,37 @@ let package = Package( dependencies: ["TokenEncoder"] ), + .target( + name: "SuggestionModel", + dependencies: ["LanguageClient"] + ), + + .testTarget( + name: "SuggestionModelTests", + dependencies: ["SuggestionModel"] + ), + + .target(name: "AXExtension"), + + .target( + name: "AXNotificationStream", + dependencies: [ + "Logger", + ] + ), + + .target( + name: "XcodeInspector", + dependencies: [ + "AXExtension", + "SuggestionModel", + "Environment", + "AXNotificationStream", + "Logger", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ] + ), + // MARK: - Services .target( diff --git a/Core/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift similarity index 100% rename from Core/Sources/AXExtension/AXUIElement.swift rename to Tool/Sources/AXExtension/AXUIElement.swift diff --git a/Core/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift similarity index 100% rename from Core/Sources/AXNotificationStream/AXNotificationStream.swift rename to Tool/Sources/AXNotificationStream/AXNotificationStream.swift diff --git a/Core/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift similarity index 100% rename from Core/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift rename to Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index b575fe95..9c15a1d5 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -22,7 +22,9 @@ public struct ChatTabInfoPreferenceKey: PreferenceKey { /// Every chat tab should conform to this type. public typealias ChatTab = BaseChatTab & ChatTabType +/// The base class for all chat tabs. open class BaseChatTab: Equatable { + /// To support dynamic update of title in view. final class InfoObservable: ObservableObject { @Published var id: String @Published var title: String @@ -32,6 +34,7 @@ open class BaseChatTab: Equatable { } } + /// A wrapper to support dynamic update of title in view. struct ContentView: View { @ObservedObject var info: InfoObservable var buildView: () -> any View @@ -60,20 +63,22 @@ open class BaseChatTab: Equatable { info = InfoObservable(id: id, title: title) } + /// The view for this chat tab. @ViewBuilder public var body: some View { let id = "ChatTabBody\(info.id)" - if let tab = self as? ChatTabType { + if let tab = self as? (any ChatTabType) { ContentView(info: info, buildView: tab.buildView).id(id) } else { EmptyView().id(id) } } - + + /// The menu for this chat tab. @ViewBuilder public var menu: some View { let id = "ChatTabMenu\(info.id)" - if let tab = self as? ChatTabType { + if let tab = self as? (any ChatTabType) { ContentView(info: info, buildView: tab.buildMenu).id(id) } else { EmptyView().id(id) @@ -85,21 +90,59 @@ open class BaseChatTab: Equatable { } } +/// A factory of a chat tab. +public protocol ChatTabBuilder { + /// A visible title for user. + var title: String { get } + /// Build the chat tab. + func build() -> any ChatTab +} + public protocol ChatTabType { + /// The type of the external dependency required by this chat tab. + associatedtype ExternalDependency + /// Build the view for this chat tab. @ViewBuilder func buildView() -> any View + /// Build the menu for this chat tab. @ViewBuilder func buildMenu() -> any View + /// The name of this chat tab. + static var name: String { get } + /// Available builders for this chat tab. + /// It's used to generate a list of tab types for user to create. + static func chatBuilders(externalDependency: ExternalDependency) -> [ChatTabBuilder] +} + +public extension ChatTabType where ExternalDependency == Void { + /// Available builders for this chat tab. + /// It's used to generate a list of tab types for user to create. + static func chatBuilders() -> [ChatTabBuilder] { + chatBuilders(externalDependency: ()) + } } public class EmptyChatTab: ChatTab { + public static var name: String { "Empty" } + + struct Builder: ChatTabBuilder { + let title: String + func build() -> any ChatTab { + EmptyChatTab() + } + } + + public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { + [Builder(title: "Empty")] + } + public func buildView() -> any View { VStack { Text("Empty-\(id)") } .background(Color.blue) } - + public func buildMenu() -> any View { EmptyView() } diff --git a/Core/Sources/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift similarity index 100% rename from Core/Sources/Environment/Environment.swift rename to Tool/Sources/Environment/Environment.swift diff --git a/Core/Sources/SuggestionModel/CodeSuggestion.swift b/Tool/Sources/SuggestionModel/CodeSuggestion.swift similarity index 100% rename from Core/Sources/SuggestionModel/CodeSuggestion.swift rename to Tool/Sources/SuggestionModel/CodeSuggestion.swift diff --git a/Core/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift similarity index 100% rename from Core/Sources/SuggestionModel/ExportedFromLSP.swift rename to Tool/Sources/SuggestionModel/ExportedFromLSP.swift diff --git a/Core/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift b/Tool/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift similarity index 100% rename from Core/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift rename to Tool/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift diff --git a/Core/Sources/SuggestionModel/Modification.swift b/Tool/Sources/SuggestionModel/Modification.swift similarity index 100% rename from Core/Sources/SuggestionModel/Modification.swift rename to Tool/Sources/SuggestionModel/Modification.swift diff --git a/Core/Sources/XcodeInspector/Helpers.swift b/Tool/Sources/XcodeInspector/Helpers.swift similarity index 100% rename from Core/Sources/XcodeInspector/Helpers.swift rename to Tool/Sources/XcodeInspector/Helpers.swift diff --git a/Core/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift similarity index 100% rename from Core/Sources/XcodeInspector/SourceEditor.swift rename to Tool/Sources/XcodeInspector/SourceEditor.swift diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift similarity index 100% rename from Core/Sources/XcodeInspector/XcodeInspector.swift rename to Tool/Sources/XcodeInspector/XcodeInspector.swift diff --git a/Core/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift similarity index 100% rename from Core/Sources/XcodeInspector/XcodeWindowInspector.swift rename to Tool/Sources/XcodeInspector/XcodeWindowInspector.swift diff --git a/Core/Tests/SuggestionModelTests/ModificationTests.swift b/Tool/Tests/SuggestionModelTests/ModificationTests.swift similarity index 100% rename from Core/Tests/SuggestionModelTests/ModificationTests.swift rename to Tool/Tests/SuggestionModelTests/ModificationTests.swift From 75eb5a19a247a48e72e084eee68c554a877d50cf Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 01:07:07 +0800 Subject: [PATCH 04/94] Update create button to only use Menu --- .../SuggestionWidget/ChatWindowView.swift | 97 ++++++------------- 1 file changed, 31 insertions(+), 66 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index df030c42..74794e17 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -78,9 +78,6 @@ struct ChatTabBar: View { var selectedTabId: String } - @State var isHoveringCreateButton = false - @State var isHoveringMenuButton = false - var body: some View { WithViewStore( store, @@ -120,77 +117,45 @@ struct ChatTabBar: View { @ViewBuilder var createButton: some View { - HStack(spacing: 0) { - Button(action: { - store.send(.createNewTapButtonClicked(kind: nil)) - }) { - Image(systemName: "plus") - .foregroundColor(.secondary) - .padding(.leading, 8) - .padding(.trailing, 4) - .frame(maxHeight: .infinity) - } - .buttonStyle(.plain) - .background { - if isHoveringCreateButton { - RoundedRectangle(cornerRadius: 2) - .fill(Color(nsColor: .controlTextColor).opacity(0.1)) - } - } - .onHover { isHoveringCreateButton = $0 } - - Menu { - WithViewStore(store, observe: { $0.chatTapGroup.tabCollection }) { viewStore in - ForEach(0.. Date: Wed, 12 Jul 2023 01:41:18 +0800 Subject: [PATCH 05/94] Adjust tab name --- Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift | 2 +- Tool/Sources/Preferences/Keys.swift | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 9136d21a..1dc905fd 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -45,7 +45,7 @@ public class ChatGPTChatTab: ChatTab { return nil } - return [Builder(title: "ChatGPT", customCommand: nil)] + customCommands + return [Builder(title: "New Chat", customCommand: nil)] + customCommands } public init(service: ChatService = .init()) { diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 367a5fbe..e48f6928 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -9,11 +9,21 @@ public protocol UserDefaultPreferenceKey { public struct PreferenceKey: UserDefaultPreferenceKey { public let defaultValue: T public let key: String + + public init(defaultValue: T, key: String) { + self.defaultValue = defaultValue + self.key = key + } } public struct FeatureFlag: UserDefaultPreferenceKey { public let defaultValue: Bool public let key: String + + public init(defaultValue: Bool, key: String) { + self.defaultValue = defaultValue + self.key = key + } } public struct UserDefaultPreferenceKeys { From bcb4a82dc61059bbbf41b679e153bc9bd49a8bfc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 01:41:26 +0800 Subject: [PATCH 06/94] Support bookmarking --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index e717b47b..3ea15e2c 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit e717b47b7125d66655e72e7886facf5ebe435f99 +Subproject commit 3ea15e2cd1c4a73368f408fb481c765c09a0730e From f5ae418795ca8ce594685120f1b9f57d6daff655 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 01:43:42 +0800 Subject: [PATCH 07/94] Adjust button order --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 3ea15e2c..0f51c17b 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 3ea15e2cd1c4a73368f408fb481c765c09a0730e +Subproject commit 0f51c17bcda3181bd4cfc40a9dcd1a5dd7a1442a From 6a45443dcd9d4664c7bff3a2b82a191abcdbee0c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 11:20:09 +0800 Subject: [PATCH 08/94] Move BrowserChatTab to it's own target --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 0f51c17b..c363df15 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 0f51c17bcda3181bd4cfc40a9dcd1a5dd7a1442a +Subproject commit c363df15c85f543ddd1cdf83c78379dc95fda36b From fe4df09a5eca114689390299d03087324bcea8fb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 11:23:51 +0800 Subject: [PATCH 09/94] Fix --- .../GraphicalUserInterfaceController.swift.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift index a6831183..4e14d9dd 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift @@ -204,14 +204,19 @@ enum ChatTabFactory { enum ChatTabFactory { static var chatTabBuilderCollection: [ChatTabBuilderCollection] { - func folderIfNeeded(_ builders: [any ChatTabBuilder]) -> ChatTabBuilderCollection? { - if builders.count > 1 { return .folder(builders) } - if let first = builders.first { return .type(first) } + func folderIfNeeded( + _ builders: [any ChatTabBuilder], + title: String + ) -> ChatTabBuilderCollection? { + if builders.count > 1 { + return .folder(title: title, kinds: builders.map(ChatTabKind.init)) + } + if let first = builders.first { return .kind(ChatTabKind(first)) } return nil } return [ - folderIfNeeded(ChatGPTChatTab.chatBuilders()), + folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), ].compactMap { $0 } } } From 2d92790f18c29a6b4c6948e7a57fa3375abf1c44 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 11:38:26 +0800 Subject: [PATCH 10/94] Update OpenAIChat to support memory and functionProvider --- Tool/Sources/LangChain/ChatModel/OpenAIChat.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index 51bb023d..aa286470 100644 --- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -3,13 +3,19 @@ import OpenAIService public struct OpenAIChat: ChatModel { public var configuration: ChatGPTConfiguration + public var memory: ChatGPTMemory + public var functionProvider: ChatGPTFunctionProvider public var stream: Bool public init( - configuration: ChatGPTConfiguration, + configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + memory: ChatGPTMemory = ConversationChatGPTMemory(systemPrompt: ""), + functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider(), stream: Bool ) { self.configuration = configuration + self.memory = memory + self.functionProvider = functionProvider self.stream = stream } @@ -18,12 +24,11 @@ public struct OpenAIChat: ChatModel { stops: [String], callbackManagers: [CallbackManager] ) async throws -> String { - let memory = AutoManagedChatGPTMemory( - systemPrompt: "", + let service = ChatGPTService( + memory: memory, configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: functionProvider ) - let service = ChatGPTService(memory: memory, configuration: configuration) for message in prompt { let role: OpenAIService.ChatMessage.Role = { switch message.role { From da55011026eeaa4a14126ea01f7bc6a514cdf7a0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 15:32:57 +0800 Subject: [PATCH 11/94] WIP --- .../QueryWebsiteFunction.swift | 16 +--- Tool/Sources/LangChain/Chains/LLMChain.swift | 10 ++- .../LangChain/Chains/RetrievalQA.swift | 79 ++++++++++++++++--- .../LangChain/ChatModel/ChatModel.swift | 21 +---- .../LangChain/ChatModel/OpenAIChat.swift | 19 ++--- .../Memory/EmptyChatGPTMemory.swift | 13 +++ 6 files changed, 102 insertions(+), 56 deletions(-) create mode 100644 Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift index 611cd1c0..3aadef8d 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -64,13 +64,7 @@ struct QueryWebsiteFunction: ChatGPTFunction { if let database = await TemporaryUSearch.view(identifier: urlString) { await reportProgress("Generating answers..") - let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) { - OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(temperature: 0)), - stream: true - ) - } + let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) return try await qa.call(.init(arguments.query)).answer } let loader = WebLoader(urls: [url]) @@ -89,13 +83,7 @@ struct QueryWebsiteFunction: ChatGPTFunction { try await database.set(embeddedDocuments) // 4. generate answer await reportProgress("Generating answers..") - let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) { - OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(temperature: 0)), - stream: true - ) - } + let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) let result = try await qa.call(.init(arguments.query)) return result.answer } diff --git a/Tool/Sources/LangChain/Chains/LLMChain.swift b/Tool/Sources/LangChain/Chains/LLMChain.swift index c7152325..1fc546c2 100644 --- a/Tool/Sources/LangChain/Chains/LLMChain.swift +++ b/Tool/Sources/LangChain/Chains/LLMChain.swift @@ -1,7 +1,7 @@ import Foundation public class ChatModelChain: Chain { - public typealias Output = String + public typealias Output = ChatMessage var chatModel: ChatModel var promptTemplate: (Input) -> [ChatMessage] @@ -31,7 +31,13 @@ public class ChatModelChain: Chain { } public func parseOutput(_ output: Output) -> String { - output + if let content = output.content { + return content + } else if let functionCall = output.functionCall { + return "\(functionCall.name): \(functionCall.arguments)" + } + + return "" } } diff --git a/Tool/Sources/LangChain/Chains/RetrievalQA.swift b/Tool/Sources/LangChain/Chains/RetrievalQA.swift index 023681a0..2faf09cd 100644 --- a/Tool/Sources/LangChain/Chains/RetrievalQA.swift +++ b/Tool/Sources/LangChain/Chains/RetrievalQA.swift @@ -1,9 +1,9 @@ import Foundation +import OpenAIService public final class RetrievalQAChain: Chain { let vectorStore: VectorStore let embedding: Embeddings - let chatModelFactory: () -> ChatModel public struct Output { public var answer: String @@ -12,12 +12,10 @@ public final class RetrievalQAChain: Chain { public init( vectorStore: VectorStore, - embedding: Embeddings, - chatModelFactory: @escaping () -> ChatModel + embedding: Embeddings ) { self.vectorStore = vectorStore self.embedding = embedding - self.chatModelFactory = chatModelFactory } public func callLogic( @@ -29,7 +27,7 @@ public final class RetrievalQAChain: Chain { embeddings: embeddedQuestion, count: 5 ) - let refinementChain = RefineDocumentChain(chatModelFactory: chatModelFactory) + let refinementChain = RefineDocumentChain() let answer = try await refinementChain.run( .init(question: input, documents: documents), callbackManagers: callbackManagers @@ -68,12 +66,68 @@ public final class RefineDocumentChain: Chain { var distance: Float } + class FunctionProvider: ChatGPTFunctionProvider { + var functions: [any ChatGPTFunction] = [] + } + + struct RespondFunction: ChatGPTFunction { + struct Arguments: Codable { + var answer: String + var score: Double + var more: Bool + } + + struct Result: ChatGPTFunctionResult { + var botReadableContent: String { "" } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String = "respond" + var description: String = "Respond with the refined answer" + var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: [ + "answer": [ + .type: "string", + .description: "The answer", + ], + "score": [ + .type: "number", + .description: "The score of the answer, the higher the better", + ], + "more": [ + .type: "boolean", + .description: "Whether more information is needed to complete the answer", + ], + ], + ] + } + + func prepare() async {} + + func call(arguments: Arguments) async throws -> Result { + return Result() + } + } + let initialChatModel: ChatModelChain let refinementChatModel: ChatModelChain + let initialChatMemory: ChatGPTMemory + let refinementChatMemory: ChatGPTMemory + + public init() { + initialChatMemory = ConversationChatGPTMemory(systemPrompt: "") + refinementChatMemory = ConversationChatGPTMemory(systemPrompt: "") - public init(chatModelFactory: () -> ChatModel) { initialChatModel = .init( - chatModel: chatModelFactory(), + chatModel: OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: 0)), + memory: initialChatMemory, + stream: false + ), promptTemplate: { input in [ .init(role: .system, content: """ The user will send you a question, you must answer it at your best. @@ -85,7 +139,12 @@ public final class RefineDocumentChain: Chain { ] } ) refinementChatModel = .init( - chatModel: chatModelFactory(), + chatModel: OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: 0)), + memory: refinementChatMemory, + stream: false + ), promptTemplate: { input in [ .init(role: .system, content: """ The user will send you a question, you must refine your previous answer to it at your best. @@ -117,7 +176,9 @@ public final class RefineDocumentChain: Chain { ), callbackManagers: callbackManagers ) - callbackManagers.send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: output)) + guard var content = output.content else { return "" } + callbackManagers + .send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: content)) for document in input.documents.dropFirst(1) { output = try await refinementChatModel.call( .init( diff --git a/Tool/Sources/LangChain/ChatModel/ChatModel.swift b/Tool/Sources/LangChain/ChatModel/ChatModel.swift index b58bafc5..8d85b6ee 100644 --- a/Tool/Sources/LangChain/ChatModel/ChatModel.swift +++ b/Tool/Sources/LangChain/ChatModel/ChatModel.swift @@ -1,29 +1,16 @@ import Foundation +import OpenAIService public protocol ChatModel { func generate( prompt: [ChatMessage], stops: [String], callbackManagers: [CallbackManager] - ) async throws -> String -} - -public struct ChatMessage { - public enum Role { - case system - case user - case assistant - } - - public var role: Role - public var content: String - - public init(role: Role, content: String) { - self.role = role - self.content = content - } + ) async throws -> ChatMessage } +public typealias ChatMessage = OpenAIService.ChatMessage + public extension CallbackEvents { struct LLMDidProduceNewToken: CallbackEvent { public let info: String diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index aa286470..062e111b 100644 --- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -23,24 +23,14 @@ public struct OpenAIChat: ChatModel { prompt: [ChatMessage], stops: [String], callbackManagers: [CallbackManager] - ) async throws -> String { + ) async throws -> ChatMessage { let service = ChatGPTService( memory: memory, configuration: configuration, functionProvider: functionProvider ) for message in prompt { - let role: OpenAIService.ChatMessage.Role = { - switch message.role { - case .system: - return .system - case .user: - return .user - case .assistant: - return .assistant - } - }() - await memory.appendMessage(.init(role: role, content: message.content)) + await memory.appendMessage(message) } if stream { @@ -51,9 +41,10 @@ public struct OpenAIChat: ChatModel { callbackManagers .forEach { $0.send(CallbackEvents.LLMDidProduceNewToken(info: trunk)) } } - return message + return await memory.messages.last ?? .init(role: .assistant, content: "") } else { - return try await service.sendAndWait(content: "") ?? "" + let _ = try await service.sendAndWait(content: "") + return await memory.messages.last ?? .init(role: .assistant, content: "") } } } diff --git a/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift new file mode 100644 index 00000000..477cce6b --- /dev/null +++ b/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift @@ -0,0 +1,13 @@ +import Foundation + +public actor EmptyChatGPTMemory: ChatGPTMemory { + public var messages: [ChatMessage] = [] + public var remainingTokens: Int? { nil } + + public init() {} + + public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { + update(&messages) + } +} + From 697468262cd987762e976393b43b5aaeef0de0c5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 15:46:55 +0800 Subject: [PATCH 12/94] Update ChatModel to return ChatMessage --- Tool/Sources/LangChain/Agent.swift | 7 ++++--- Tool/Sources/LangChain/Chains/RetrievalQA.swift | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/LangChain/Agent.swift b/Tool/Sources/LangChain/Agent.swift index b5da456a..e469b33d 100644 --- a/Tool/Sources/LangChain/Agent.swift +++ b/Tool/Sources/LangChain/Agent.swift @@ -104,7 +104,7 @@ public extension Agent { ) async throws -> AgentNextStep { let input = getFullInputs(input: input, intermediateSteps: intermediateSteps) let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) - return parseOutput(output) + return parseOutput(output.content ?? "") } func returnStoppedResponse( @@ -128,12 +128,13 @@ public extension Agent { """ let input = AgentInput(input: input, thoughts: .text(thoughts)) let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) - let nextAction = parseOutput(output) + let reply = output.content ?? "" + let nextAction = parseOutput(reply) switch nextAction { case let .finish(finish): return finish case .actions: - return AgentFinish(returnValue: output, log: output) + return AgentFinish(returnValue: reply, log: reply) } } } diff --git a/Tool/Sources/LangChain/Chains/RetrievalQA.swift b/Tool/Sources/LangChain/Chains/RetrievalQA.swift index 2faf09cd..b9145648 100644 --- a/Tool/Sources/LangChain/Chains/RetrievalQA.swift +++ b/Tool/Sources/LangChain/Chains/RetrievalQA.swift @@ -183,16 +183,17 @@ public final class RefineDocumentChain: Chain { output = try await refinementChatModel.call( .init( question: input.question, - previousAnswer: output, + previousAnswer: content, document: document.document.pageContent, distance: document.distance ), callbackManagers: callbackManagers ) + content = output.content ?? "" callbackManagers - .send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: output)) + .send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: content)) } - return output + return content } public func parseOutput(_ output: String) -> String { From 174abe58524ad0a93b5a50c5599c766762f617b4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 17:09:49 +0800 Subject: [PATCH 13/94] Fix that non-stream api was not correctly handling function call results --- .../Sources/OpenAIService/CompletionAPI.swift | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/OpenAIService/CompletionAPI.swift b/Tool/Sources/OpenAIService/CompletionAPI.swift index 22c2c379..d298e91e 100644 --- a/Tool/Sources/OpenAIService/CompletionAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionAPI.swift @@ -10,7 +10,25 @@ protocol CompletionAPI { /// https://platform.openai.com/docs/api-reference/chat/create struct CompletionResponseBody: Codable, Equatable { - typealias Message = CompletionRequestBody.Message + struct Message: Codable, Equatable { + /// The role of the message. + var role: ChatMessage.Role + /// The content of the message. + var content: String? + /// When we want to reply to a function call with the result, we have to provide the + /// name of the function call, and include the result in `content`. + /// + /// - important: It's required when the role is `function`. + var name: String? + /// When the bot wants to call a function, it will reply with a function call in format: + /// ```json + /// { + /// "name": "weather", + /// "arguments": "{ \"location\": \"earth\" }" + /// } + /// ``` + var function_call: CompletionRequestBody.MessageFunctionCall? + } struct Choice: Codable, Equatable { var message: Message @@ -89,7 +107,12 @@ struct OpenAICompletionAPI: CompletionAPI { .otherError(String(data: result, encoding: .utf8) ?? "Unknown Error") } - return try JSONDecoder().decode(CompletionResponseBody.self, from: result) + do { + return try JSONDecoder().decode(CompletionResponseBody.self, from: result) + } catch { + dump(error) + fatalError() + } } } From 5f602f62f7c79eb6d8c68223467a3265d70d6700 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 17:10:29 +0800 Subject: [PATCH 14/94] Update RetrievalQA to support early return when the answer is good enough --- .../ChatService/ChatFunctionProvider.swift | 6 +- .../Contents.swift | 18 +-- .../timeline.xctimeline | 2 + .../LangChain/Chains/RetrievalQA.swift | 105 +++++++++++++----- .../LangChain/ChatModel/OpenAIChat.swift | 3 +- .../OpenAIService/ChatGPTService.swift | 5 +- .../OpenAIService/CompletionStreamAPI.swift | 52 ++++----- .../FucntionCall/ChatGPTFuntionProvider.swift | 2 + 8 files changed, 125 insertions(+), 68 deletions(-) diff --git a/Core/Sources/ChatService/ChatFunctionProvider.swift b/Core/Sources/ChatService/ChatFunctionProvider.swift index 8370a612..dffab8f2 100644 --- a/Core/Sources/ChatService/ChatFunctionProvider.swift +++ b/Core/Sources/ChatService/ChatFunctionProvider.swift @@ -15,5 +15,9 @@ final class ChatFunctionProvider { } } -extension ChatFunctionProvider: ChatGPTFunctionProvider {} +extension ChatFunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { + nil + } +} diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift index 18fe2fb5..e28e4332 100644 --- a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift @@ -5,7 +5,7 @@ import PlaygroundSupport import SwiftUI struct QAForm: View { - @State var intermediateAnswers = [String]() + @State var intermediateAnswers = [RefineDocumentChain.IntermediateAnswer]() @State var answer: String = "" @State var question: String = "What is Swift macros?" @State var isProcessing: Bool = false @@ -31,9 +31,14 @@ struct QAForm: View { Text(answer) } Section(header: Text("Intermediate Answers")) { - ForEach(intermediateAnswers, id: \.self) { answer in - Text(answer) - Divider() + ForEach(0.. + + let refinementChatModel: ChatModelChain - let initialChatMemory: ChatGPTMemory - let refinementChatMemory: ChatGPTMemory public init() { - initialChatMemory = ConversationChatGPTMemory(systemPrompt: "") - refinementChatMemory = ConversationChatGPTMemory(systemPrompt: "") - initialChatModel = .init( chatModel: OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(temperature: 0)), - memory: initialChatMemory, + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.temperature = 0 + $0.runFunctionsAutomatically = false + }, + memory: EmptyChatGPTMemory(), + functionProvider: FunctionProvider(), stream: false ), promptTemplate: { input in [ @@ -140,9 +161,12 @@ public final class RefineDocumentChain: Chain { ) refinementChatModel = .init( chatModel: OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(temperature: 0)), - memory: refinementChatMemory, + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.temperature = 0 + $0.runFunctionsAutomatically = false + }, + memory: EmptyChatGPTMemory(), + functionProvider: FunctionProvider(), stream: false ), promptTemplate: { input in [ @@ -168,6 +192,26 @@ public final class RefineDocumentChain: Chain { guard let firstDocument = input.documents.first else { return "" } + + func extractAnswer(_ chatMessage: ChatMessage) -> IntermediateAnswer { + if let functionCall = chatMessage.functionCall { + do { + let intermediateAnswer = try JSONDecoder().decode( + IntermediateAnswer.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + return intermediateAnswer + } catch { + let intermediateAnswer = IntermediateAnswer( + answer: functionCall.arguments, + score: 0, + more: true + ) + return intermediateAnswer + } + } + return .init(answer: chatMessage.content ?? "", score: 0, more: true) + } var output = try await initialChatModel.call( .init( question: input.question, @@ -176,24 +220,27 @@ public final class RefineDocumentChain: Chain { ), callbackManagers: callbackManagers ) - guard var content = output.content else { return "" } - callbackManagers - .send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: content)) - for document in input.documents.dropFirst(1) { + var intermediateAnswer = extractAnswer(output) + callbackManagers.send( + CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: intermediateAnswer) + ) + + for document in input.documents.dropFirst(1) where intermediateAnswer.more { output = try await refinementChatModel.call( .init( question: input.question, - previousAnswer: content, + previousAnswer: intermediateAnswer.answer, document: document.document.pageContent, distance: document.distance ), callbackManagers: callbackManagers ) - content = output.content ?? "" - callbackManagers - .send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: content)) + intermediateAnswer = extractAnswer(output) + callbackManagers.send( + CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: intermediateAnswer) + ) } - return content + return intermediateAnswer.answer } public func parseOutput(_ output: String) -> String { diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index 062e111b..bb9c7752 100644 --- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -38,8 +38,7 @@ public struct OpenAIChat: ChatModel { var message = "" for try await trunk in stream { message.append(trunk) - callbackManagers - .forEach { $0.send(CallbackEvents.LLMDidProduceNewToken(info: trunk)) } + callbackManagers.send(CallbackEvents.LLMDidProduceNewToken(info: trunk)) } return await memory.messages.last ?? .init(role: .assistant, content: "") } else { diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index ffdd05a4..953f4c72 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -205,7 +205,7 @@ extension ChatGPTService { model: configuration.model, remainingTokens: remainingTokens ), - function_call: nil, + function_call: functionProvider.functionCallStrategy, functions: functionProvider.functions.map { ChatGPTFunctionSchema( name: $0.name, @@ -302,7 +302,7 @@ extension ChatGPTService { model: configuration.model, remainingTokens: remainingTokens ), - function_call: nil, + function_call: functionProvider.functionCallStrategy, functions: functionProvider.functions.map { ChatGPTFunctionSchema( name: $0.name, @@ -318,6 +318,7 @@ extension ChatGPTService { url, requestBody ) + let response = try await api() guard let choice = response.choices.first else { return nil } diff --git a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift index f9c06acc..047b92a1 100644 --- a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift @@ -12,6 +12,31 @@ protocol CompletionStreamAPI { ) } +public enum FunctionCallStrategy: Encodable, Equatable { + /// Forbid the bot to call any function. + case none + /// Let the bot choose what function to call. + case auto + /// Force the bot to call a function with the given name. + case name(String) + + struct CallFunctionNamed: Codable { + var name: String + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .none: + try container.encode("none") + case .auto: + try container.encode("auto") + case let .name(name): + try container.encode(CallFunctionNamed(name: name)) + } + } +} + /// https://platform.openai.com/docs/api-reference/chat/create struct CompletionRequestBody: Encodable, Equatable { struct Message: Codable, Equatable { @@ -31,7 +56,7 @@ struct CompletionRequestBody: Encodable, Equatable { /// "arguments": "{ \"location\": \"earth\" }" /// } /// ``` - var function_call: MessageFunctionCall? + var function_call: CompletionRequestBody.MessageFunctionCall? } struct MessageFunctionCall: Codable, Equatable { @@ -41,31 +66,6 @@ struct CompletionRequestBody: Encodable, Equatable { var arguments: String? } - enum FunctionCallStrategy: Encodable, Equatable { - /// Forbid the bot to call any function. - case none - /// Let the bot choose what function to call. - case auto - /// Force the bot to call a function with the given name. - case name(String) - - struct CallFunctionNamed: Codable { - var name: String - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .none: - try container.encode("none") - case .auto: - try container.encode("auto") - case let .name(name): - try container.encode(CallFunctionNamed(name: name)) - } - } - } - struct Function: Codable { var name: String var description: String diff --git a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift index d53cdf49..c3a60341 100644 --- a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift +++ b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift @@ -2,6 +2,7 @@ import Foundation public protocol ChatGPTFunctionProvider { var functions: [any ChatGPTFunction] { get } + var functionCallStrategy: FunctionCallStrategy? { get } } extension ChatGPTFunctionProvider { @@ -11,6 +12,7 @@ extension ChatGPTFunctionProvider { } public struct NoChatGPTFunctionProvider: ChatGPTFunctionProvider { + public var functionCallStrategy: FunctionCallStrategy? public var functions: [any ChatGPTFunction] { [] } public init() {} } From eb6821fd3709af4a55a008179d8db553c73ec903 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 23:09:24 +0800 Subject: [PATCH 15/94] Tweak auto stop in QA chain --- .../Contents.swift | 93 ++++++--- .../timeline.xctimeline | 14 -- .../LangChain/Chains/RetrievalQA.swift | 177 +++++++++--------- .../VectorStore/TemporaryUSearch.swift | 2 +- 4 files changed, 153 insertions(+), 133 deletions(-) delete mode 100644 Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/timeline.xctimeline diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift index e28e4332..7ebeb9b5 100644 --- a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift @@ -1,4 +1,5 @@ import AppKit +import Foundation import LangChain import OpenAIService import PlaygroundSupport @@ -6,47 +7,85 @@ import SwiftUI struct QAForm: View { @State var intermediateAnswers = [RefineDocumentChain.IntermediateAnswer]() + @State var relevantDocuments = [(document: Document, distance: Float)]() + @State var duration: TimeInterval = 0 @State var answer: String = "" @State var question: String = "What is Swift macros?" @State var isProcessing: Bool = false @State var url: String = "https://developer.apple.com/documentation/swift/applying-macros" var body: some View { - Form { - Section(header: Text("Input")) { - TextField("URL", text: $url) - TextField("Question", text: $question) - Button("Ask") { - Task { - do { - try await ask() - } catch { - answer = error.localizedDescription + HStack(spacing: 0) { + ScrollView { + Form { + Section(header: Text("Input")) { + TextField("URL", text: $url) + TextField("Question", text: $question) + HStack { + Button("Ask") { + Task { + do { + try await ask() + } catch { + answer = error.localizedDescription + } + } + } + .disabled(isProcessing) + + Text("\(duration) seconds") + } + } + Section(header: Text("Answer")) { + Text(answer) + } + Section(header: Text("Intermediate Answers")) { + ForEach(0.. - - - - - - - - - - diff --git a/Tool/Sources/LangChain/Chains/RetrievalQA.swift b/Tool/Sources/LangChain/Chains/RetrievalQA.swift index c9f4f186..5ef1af16 100644 --- a/Tool/Sources/LangChain/Chains/RetrievalQA.swift +++ b/Tool/Sources/LangChain/Chains/RetrievalQA.swift @@ -27,6 +27,9 @@ public final class RetrievalQAChain: Chain { embeddings: embeddedQuestion, count: 5 ) + + callbackManagers.send(CallbackEvents.RetrievalQADidExtractRelevantContent(info: documents)) + let refinementChain = RefineDocumentChain() let answer = try await refinementChain.run( .init(question: input, documents: documents), @@ -45,6 +48,10 @@ public extension CallbackEvents { struct RetrievalQADidGenerateIntermediateAnswer: CallbackEvent { public let info: RefineDocumentChain.IntermediateAnswer } + + struct RetrievalQADidExtractRelevantContent: CallbackEvent { + public let info: [(document: Document, distance: Float)] + } } public final class RefineDocumentChain: Chain { @@ -53,41 +60,37 @@ public final class RefineDocumentChain: Chain { var documents: [(document: Document, distance: Float)] } - struct InitialInput { - var question: String - var document: String - var distance: Float - } - struct RefinementInput { + var index: Int + var totalCount: Int var question: String - var previousAnswer: String + var previousAnswer: String? var document: String var distance: Float } public struct IntermediateAnswer: Decodable { public var answer: String - public var score: Double + public var usefulness: Double public var more: Bool public enum CodingKeys: String, CodingKey { case answer - case score + case usefulness case more } - init(answer: String, score: Double, more: Bool) { + init(answer: String, usefulness: Double, more: Bool) { self.answer = answer - self.score = score + self.usefulness = usefulness self.more = more } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) answer = try container.decode(String.self, forKey: .answer) - score = (try? container.decode(Double.self, forKey: .score)) ?? 0 - more = (try? container.decode(Bool.self, forKey: .more)) ?? (score < 6) + usefulness = (try? container.decode(Double.self, forKey: .usefulness)) ?? 0 + more = (try? container.decode(Bool.self, forKey: .more)) ?? true } } @@ -115,16 +118,16 @@ public final class RefineDocumentChain: Chain { .type: "string", .description: "The refined answer", ], - "score": [ + "usefulness": [ .type: "number", - .description: "The score of the answer, the higher the better. 0 to 10.", + .description: "How useful the page of document is in generating the answer, the higher the better. 0 to 10", ], "more": [ .type: "boolean", - .description: "Whether more information is needed to complete the answer", + .description: "Whether you want to read the next page. The next page maybe less relevant to the question", ], ], - .required: ["answer", "score", "more"], + .required: ["answer", "more", "usefulness"], ] } @@ -135,11 +138,8 @@ public final class RefineDocumentChain: Chain { } } - let initialChatModel: ChatModelChain - let refinementChatModel: ChatModelChain - - public init() { - initialChatModel = .init( + func buildChatModel() -> ChatModelChain { + .init( chatModel: OpenAIChat( configuration: UserPreferenceChatGPTConfiguration().overriding { $0.temperature = 0 @@ -150,101 +150,92 @@ public final class RefineDocumentChain: Chain { stream: false ), promptTemplate: { input in [ - .init(role: .system, content: """ - The user will send you a question, you must answer it at your best. - You can use the following document as a reference:### - \(input.document) - ### - """), - .init(role: .user, content: input.question), - ] } - ) - refinementChatModel = .init( - chatModel: OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration().overriding { - $0.temperature = 0 - $0.runFunctionsAutomatically = false - }, - memory: EmptyChatGPTMemory(), - functionProvider: FunctionProvider(), - stream: false - ), - promptTemplate: { input in [ - .init(role: .system, content: """ - The user will send you a question, you must refine your previous answer to it at your best. - You should focus on answering the question, there is no need to add extra details in other topics. - Previous answer:### - \(input.previousAnswer) - ### - You can use the following document as a reference:### - \(input.document) - ### - """), + .init( + role: .system, + content: { + if let previousAnswer = input.previousAnswer { + return """ + The user will send you a question about a document, you must refine your previous answer to it only according to the document. + Previous answer:### + \(previousAnswer) + ### + Page \(input.index) of \(input.totalCount) of the document:### + \(input.document) + ### + """ + } else { + return """ + The user will send you a question about a document, you must answer it only according to the document. + Page \(input.index) of \(input.totalCount) of the document:### + \(input.document) + ### + """ + } + }() + + ), .init(role: .user, content: input.question), ] } ) } + public init() {} + public func callLogic( _ input: Input, callbackManagers: [CallbackManager] ) async throws -> String { - guard let firstDocument = input.documents.first else { - return "" - } + var intermediateAnswer: IntermediateAnswer? - func extractAnswer(_ chatMessage: ChatMessage) -> IntermediateAnswer { - if let functionCall = chatMessage.functionCall { - do { - let intermediateAnswer = try JSONDecoder().decode( - IntermediateAnswer.self, - from: functionCall.arguments.data(using: .utf8) ?? Data() - ) - return intermediateAnswer - } catch { - let intermediateAnswer = IntermediateAnswer( - answer: functionCall.arguments, - score: 0, - more: true - ) - return intermediateAnswer - } - } - return .init(answer: chatMessage.content ?? "", score: 0, more: true) - } - var output = try await initialChatModel.call( - .init( - question: input.question, - document: firstDocument.document.pageContent, - distance: firstDocument.distance - ), - callbackManagers: callbackManagers - ) - var intermediateAnswer = extractAnswer(output) - callbackManagers.send( - CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: intermediateAnswer) - ) + for (index, document) in input.documents.enumerated() { + if let intermediateAnswer, !intermediateAnswer.more { break } - for document in input.documents.dropFirst(1) where intermediateAnswer.more { - output = try await refinementChatModel.call( + let output = try await buildChatModel().call( .init( + index: index, + totalCount: input.documents.count, question: input.question, - previousAnswer: intermediateAnswer.answer, + previousAnswer: intermediateAnswer?.answer, document: document.document.pageContent, distance: document.distance ), callbackManagers: callbackManagers ) intermediateAnswer = extractAnswer(output) - callbackManagers.send( - CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: intermediateAnswer) - ) + + if let intermediateAnswer { + callbackManagers.send( + CallbackEvents + .RetrievalQADidGenerateIntermediateAnswer(info: intermediateAnswer) + ) + } } - return intermediateAnswer.answer + + return intermediateAnswer?.answer ?? "None" } public func parseOutput(_ output: String) -> String { return output } + + func extractAnswer(_ chatMessage: ChatMessage) -> IntermediateAnswer { + if let functionCall = chatMessage.functionCall { + do { + let intermediateAnswer = try JSONDecoder().decode( + IntermediateAnswer.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + return intermediateAnswer + } catch { + let intermediateAnswer = IntermediateAnswer( + answer: functionCall.arguments, + usefulness: 0, + more: true + ) + return intermediateAnswer + } + } + return .init(answer: chatMessage.content ?? "", usefulness: 0, more: true) + } } diff --git a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift index b5f6ac61..f5a3cb3c 100644 --- a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift +++ b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift @@ -21,7 +21,7 @@ public actor TemporaryUSearch: VectorStore { public init(identifier: String) { self.identifier = calculateMD5Hash(identifier) index = .init( - metric: .cos, + metric: .IP, dimensions: 1536, // text-embedding-ada-002 connectivity: 16, quantization: .F32 From f17589679a3b8780b786e5ddf54aff11b1253812 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 12 Jul 2023 23:37:13 +0800 Subject: [PATCH 16/94] Remove print --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index c363df15..fd23e0fd 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit c363df15c85f543ddd1cdf83c78379dc95fda36b +Subproject commit fd23e0fdfc4c2acd71b44df2bef2ce6ce1f5ba24 From 0575d41395b3c5fb7972185818e9ad0f52c74124 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 13 Jul 2023 00:18:09 +0800 Subject: [PATCH 17/94] Fix chat tab clickable area --- Core/Sources/SuggestionWidget/ChatWindowView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 74794e17..eb5febc0 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -179,9 +179,10 @@ struct ChatTabBarButton: View { .font(.callout) .lineLimit(1) .frame(maxWidth: 120) + .padding(.horizontal, 32) + .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) - .padding(.horizontal, 32) .overlay(alignment: .leading) { Button(action: { From d29a08b4ab0bc66696f1ef4a80cf2413a35daeb0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 02:06:30 +0800 Subject: [PATCH 18/94] Support switching chat tab with command+shift+[] --- .../SuggestionWidget/ChatWindowView.swift | 8 +++++ .../FeatureReducers/ChatPanelFeature.swift | 30 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index eb5febc0..523c66cf 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -113,6 +113,14 @@ struct ChatTabBar: View { createButton } } + .background { + Button(action: { store.send(.switchToNextTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("]", modifiers: [.command, .shift]) + Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() } + .opacity(0) + .keyboardShortcut("[", modifiers: [.command, .shift]) + } } @ViewBuilder diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 3e9d13be..1267b902 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -71,6 +71,8 @@ public struct ChatPanelFeature: ReducerProtocol { case createNewTapButtonClicked(kind: ChatTabKind?) case tabClicked(id: String) case appendAndSelectTab(BaseChatTab) + case switchToNextTab + case switchToPreviousTab } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -146,7 +148,7 @@ public struct ChatPanelFeature: ReducerProtocol { state.isPanelDisplayed = false } return .none - + case .createNewTapButtonHovered: state.chatTapGroup.tabCollection = chatTabBuilderCollection() return .none @@ -168,6 +170,32 @@ public struct ChatPanelFeature: ReducerProtocol { state.chatTapGroup.tabs.append(tab) state.chatTapGroup.selectedTabId = tab.id return .none + + case .switchToNextTab: + let selectedId = state.chatTapGroup.selectedTabId + guard let index = state.chatTapGroup.tabInfo + .firstIndex(where: { $0.id == selectedId }) + else { return .none } + let nextIndex = index + 1 + if nextIndex >= state.chatTapGroup.tabInfo.endIndex { + return .none + } + let targetId = state.chatTapGroup.tabInfo[nextIndex].id + state.chatTapGroup.selectedTabId = targetId + return .none + + case .switchToPreviousTab: + let selectedId = state.chatTapGroup.selectedTabId + guard let index = state.chatTapGroup.tabInfo + .firstIndex(where: { $0.id == selectedId }) + else { return .none } + let previousIndex = index - 1 + if previousIndex < 0 || previousIndex >= state.chatTapGroup.tabInfo.endIndex { + return .none + } + let targetId = state.chatTapGroup.tabInfo[previousIndex].id + state.chatTapGroup.selectedTabId = targetId + return .none } } } From 81d0a58033749ccaf71ce8832d030e3456bc67a5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 14:25:07 +0800 Subject: [PATCH 19/94] Add refresh and activate address bar shortcuts --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index fd23e0fd..dfbf605d 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit fd23e0fdfc4c2acd71b44df2bef2ce6ce1f5ba24 +Subproject commit dfbf605dadaa4f27438a5c80992923b02cdbf7ce From f118cf79ebcbe81c0822729c69c0cffc9b35f032 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 14:34:43 +0800 Subject: [PATCH 20/94] Hide chat tab bar scroll indicator --- .../SuggestionWidget/ChatWindowView.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 523c66cf..9a7f3ad2 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -69,6 +69,16 @@ struct ChatWindowView: View { } } +private extension View { + func hideScrollIndicator() -> some View { + if #available(macOS 13.0, *) { + return scrollIndicators(.hidden) + } else { + return self + } + } +} + struct ChatTabBar: View { let store: StoreOf @@ -107,6 +117,7 @@ struct ChatTabBar: View { } } } + .hideScrollIndicator() Divider() @@ -308,6 +319,11 @@ struct ChatWindowView_Previews: PreviewProvider { tabs: [ FakeChatTab(id: "1", title: "Hello I am a chatbot"), EmptyChatTab(id: "2"), + EmptyChatTab(id: "3"), + EmptyChatTab(id: "4"), + EmptyChatTab(id: "5"), + EmptyChatTab(id: "6"), + EmptyChatTab(id: "7"), ], selectedTabId: "1" ), From e3895069bfa44169db8a7a3ab67587ce978aa1b7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 14:42:23 +0800 Subject: [PATCH 21/94] Update tab bar to scroll to the active tab --- .../SuggestionWidget/ChatWindowView.swift | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 9a7f3ad2..a1a1c27d 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -99,25 +99,33 @@ struct ChatTabBar: View { ) } ) { viewStore in HStack(spacing: 0) { - ScrollView(.horizontal) { - HStack(spacing: 0) { - ForEach(viewStore.state.tabInfo, id: \.id) { info in - ChatTabBarButton( - store: store, - info: info, - isSelected: info.id == viewStore.state.selectedTabId - ) - .contextMenu { - if let tab = viewStore.state.tabs - .first(where: { $0.id == info.id }) - { - tab.menu + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(viewStore.state.tabInfo, id: \.id) { info in + ChatTabBarButton( + store: store, + info: info, + isSelected: info.id == viewStore.state.selectedTabId + ) + .id(info.id) + .contextMenu { + if let tab = viewStore.state.tabs + .first(where: { $0.id == info.id }) + { + tab.menu + } } } } } + .hideScrollIndicator() + .onChange(of: viewStore.selectedTabId) { id in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(id) + } + } } - .hideScrollIndicator() Divider() From edd9f91031a61108ba1d76b74df70775133102e4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 14:44:15 +0800 Subject: [PATCH 22/94] Support scope shorthand --- .../ActiveDocumentChatContextCollector.swift | 4 ++-- .../WebChatContextCollector/WebChatContextCollector.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift index f8c33dda..a83f932f 100644 --- a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -17,7 +17,7 @@ public struct ActiveDocumentChatContextCollector: ChatContextCollector { .replacingOccurrences(of: content.projectURL.path, with: "") let selectionRange = content.editorContent?.selections.first ?? .outOfScope let editorContent = { - if scopes.contains("file") { + if scopes.contains("file") || scopes.contains("f") { return """ File Content:```\(content.language.rawValue) \(content.editorContent?.content ?? "") @@ -57,7 +57,7 @@ public struct ActiveDocumentChatContextCollector: ChatContextCollector { """ } - if scopes.contains("selection") { + if scopes.contains("selection") || scopes.contains("s") { return """ Selected Code \ (start from line \(selectionRange.start.line)):```\(content.language.rawValue) diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift index a9a1313f..4035d44b 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -12,7 +12,7 @@ public final class WebChatContextCollector: ChatContextCollector { scopes: Set, content: String ) -> ChatContext? { - guard scopes.contains("web") else { return nil } + guard scopes.contains("web") || scopes.contains("w") else { return nil } let links = Self.detectLinks(from: history) + Self.detectLinks(from: content) let functions: [(any ChatGPTFunction)?] = [ SearchFunction(), From beb08894e71df711d7b2d5f4686de783c0bd6e46 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 15:08:58 +0800 Subject: [PATCH 23/94] Add minimize and attach button to title bar of chat panel --- .../SuggestionWidget/ChatWindowView.swift | 104 ++++++++++++++---- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index a1a1c27d..a4875a85 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -28,10 +28,7 @@ struct ChatWindowView: View { } ) { viewStore in VStack(spacing: 0) { - RoundedRectangle(cornerRadius: 2) - .fill(.tertiary) - .frame(width: 120, height: 4) - .frame(height: 16) + ChatTitleBar(store: store) Divider() @@ -43,23 +40,6 @@ struct ChatWindowView: View { ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } - .background { - Button(action: { - viewStore.send(.hideButtonClicked) - }) { - EmptyView() - } - .opacity(0) - .keyboardShortcut("M", modifiers: [.command]) - - Button(action: { - viewStore.send(.closeActiveTabClicked) - }) { - EmptyView() - } - .opacity(0) - .keyboardShortcut("W", modifiers: [.command]) - } .background(.regularMaterial) .xcodeStyleFrame() .opacity(viewStore.state.isPanelDisplayed ? 1 : 0) @@ -69,6 +49,88 @@ struct ChatWindowView: View { } } +struct ChatTitleBar: View { + let store: StoreOf + @State var isHovering = false + + var body: some View { + HStack(spacing: 4) { + Button(action: { + store.send(.hideButtonClicked) + }) { + Circle() + .fill(Color(nsColor: .systemRed)) + .frame(width: 10, height: 10) + .overlay { + Circle().strokeBorder(.secondary.opacity(0.3), lineWidth: 1) + } + .overlay { + if isHovering { + Image(systemName: "xmark") + .resizable() + .foregroundStyle(.secondary) + .font(Font.title.weight(.heavy)) + .frame(width: 5, height: 5) + } + } + } + + WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in + if viewStore.state { + Button(action: { + store.send(.attachChatPanel) + }) { + Circle() + .fill(.indigo) + .frame(width: 10, height: 10) + .overlay { + Circle().strokeBorder(.secondary.opacity(0.3), lineWidth: 1) + } + .overlay { + if isHovering { + Image(systemName: "pin") + .resizable() + .foregroundStyle(.secondary) + .font(Font.title.weight(.heavy)) + .frame(width: 5, height: 5) + } + } + } + } + } + + Button(action: { + store.send(.closeActiveTabClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("W", modifiers: [.command]) + + Spacer() + } + .buttonStyle(.plain) + .overlay { + RoundedRectangle(cornerRadius: 2) + .fill(.tertiary) + .frame(width: 120, height: 4) + .background { + if isHovering { + RoundedRectangle(cornerRadius: 6) + .fill(.tertiary.opacity(0.3)) + .frame(width: 128, height: 12) + } + } + } + .padding(.horizontal, 6) + .frame(maxWidth: .infinity) + .frame(height: 16) + .onHover(perform: { hovering in + isHovering = hovering + }) + } +} + private extension View { func hideScrollIndicator() -> some View { if #available(macOS 13.0, *) { From e55c74d4e4fad72ee7040731c8aed2f8fd01710a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 15:49:43 +0800 Subject: [PATCH 24/94] Remove CopilotPromptToCodeAPI --- Core/Package.swift | 1 - .../CopilotPromptToCodeAPI.swift | 103 ------------------ 2 files changed, 104 deletions(-) delete mode 100644 Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift diff --git a/Core/Package.swift b/Core/Package.swift index 37f50a5b..2a958c76 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -153,7 +153,6 @@ let package = Package( .target( name: "PromptToCodeService", dependencies: [ - "GitHubCopilotService", .product(name: "SuggestionModel", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), diff --git a/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift deleted file mode 100644 index 3806082e..00000000 --- a/Core/Sources/PromptToCodeService/CopilotPromptToCodeAPI.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation -import GitHubCopilotService -import OpenAIService -import SuggestionModel - -final class CopilotPromptToCodeAPI: PromptToCodeAPI { - var task: Task? - - func stopResponding() { - task?.cancel() - } - - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { - let copilotService = try GitHubCopilotSuggestionService(projectRootURL: projectRootURL) - let _ = { - let filePath = fileURL.path - let rootPath = projectRootURL.path - if let range = filePath.range(of: rootPath), - range.lowerBound == filePath.startIndex - { - let relativePath = filePath.replacingCharacters( - in: filePath.startIndex.. String { - s.split(separator: "\n").map { "// \($0)" }.joined(separator: "\n") - } - - let comment = """ - // A file to refactor the following code - // - // Code: - // ``` - \(convertToComment(code)) - // ``` - // - // Requirements: - \(convertToComment((extraSystemPrompt ?? "\n") + requirement)) - // - - - - // end of file - """ - let lineCount = comment.breakLines().count - - return .init { continuation in - self.task = Task { - do { - let result = try await copilotService.getCompletions( - fileURL: fileURL, - content: comment, - cursorPosition: .init(line: lineCount - 3, character: 0), - tabSize: indentSize, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - ignoreSpaceOnlySuggestions: true, - ignoreTrailingNewLinesAndSpaces: false - ) - try Task.checkCancellation() - guard let first = result.first else { throw CancellationError() } - continuation.yield((first.text, "")) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } -} - -extension String { - /// Break a string into lines. - func breakLines() -> [String] { - let lines = split(separator: "\n", omittingEmptySubsequences: false) - var all = [String]() - for (index, line) in lines.enumerated() { - if index == lines.endIndex - 1 { - all.append(String(line)) - } else { - all.append(String(line) + "\n") - } - } - return all - } -} - From 1c5a9a8342374ec0c9b58745543e3ef618b152f1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 17:08:37 +0800 Subject: [PATCH 25/94] Update --- Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift | 1 - Core/Sources/PromptToCodeService/PromptToCodeService.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift index 5b1a68d2..bcd54a28 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift @@ -1,5 +1,4 @@ import Foundation -import GitHubCopilotService import OpenAIService import Preferences import SuggestionModel diff --git a/Core/Sources/PromptToCodeService/PromptToCodeService.swift b/Core/Sources/PromptToCodeService/PromptToCodeService.swift index 9a492f71..a293629f 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeService.swift @@ -1,5 +1,4 @@ import SuggestionModel -import GitHubCopilotService import Foundation import OpenAIService From f03279f23ab7fd7ea6993b5aac39649435176678 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 22:51:41 +0800 Subject: [PATCH 26/94] Support rewrite with custom command --- Core/Sources/ChatService/ChatService.swift | 15 ++++ ...aphicalUserInterfaceController.swift.swift | 87 +++++++++++++++---- Pro | 2 +- 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 4512fc20..2720d64f 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -231,5 +231,20 @@ public final class ChatService: ObservableObject { } return try await sendAndWait(content: templateProcessor.process(prompt)) } + + public func processMessage( + systemPrompt: String?, + extraSystemPrompt: String?, + prompt: String + ) async throws -> String { + let templateProcessor = CustomCommandTemplateProcessor() + if let systemPrompt { + mutateSystemPrompt(templateProcessor.process(systemPrompt)) + } + if let extraSystemPrompt { + mutateExtraSystemPrompt(templateProcessor.process(extraSystemPrompt)) + } + return try await sendAndWait(content: templateProcessor.process(prompt)) + } } diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift index 4e14d9dd..cd14e2a9 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift @@ -1,9 +1,11 @@ import AppKit import ChatGPTChatTab +import ChatService import ChatTab import ComposableArchitecture import Environment import Preferences +import PromptToCodeService import SuggestionModel import SuggestionWidget import XcodeInspector @@ -116,11 +118,11 @@ public final class GraphicalUserInterfaceController { private init() { let suggestionDependency = SuggestionWidgetControllerDependency() let setupDependency: (inout DependencyValues) -> Void = { dependencies in - dependencies.suggestionWidgetControllerDependency = suggestionDependency - dependencies.suggestionWidgetUserDefaultsObservers = .init() - dependencies.chatTabBuilderCollection = { - ChatTabFactory.chatTabBuilderCollection - } + dependencies.suggestionWidgetControllerDependency = suggestionDependency + dependencies.suggestionWidgetUserDefaultsObservers = .init() + dependencies.chatTabBuilderCollection = { + ChatTabFactory.chatTabBuilderCollection + } } let store = StoreOf( initialState: .init(), @@ -182,20 +184,71 @@ enum ChatTabFactory { let collection = [ folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), - folderIfNeeded(BrowserChatTab.chatBuilders(externalDependency: .init(getEditorContent: { - guard let editor = XcodeInspector.shared.focusedEditor else { - return .init(selectedText: "", language: "", fileContent: "") - } - let content = editor.content - return .init( - selectedText: content.selectedContent, - language: languageIdentifierFromFileURL(XcodeInspector.shared.activeDocumentURL) + folderIfNeeded(BrowserChatTab.chatBuilders(externalDependency: .init( + getEditorContent: { + guard let editor = XcodeInspector.shared.focusedEditor else { + return .init(selectedText: "", language: "", fileContent: "") + } + let content = editor.content + return .init( + selectedText: content.selectedContent, + language: languageIdentifierFromFileURL( + XcodeInspector.shared + .activeDocumentURL + ) .rawValue, - fileContent: content.content - ) - })), title: BrowserChatTab.name), + fileContent: content.content + ) + }, + handleCustomCommand: { command, prompt in + switch command.feature { + case let .chatWithSelection(extraSystemPrompt, _, useExtraSystemPrompt): + let service = ChatService() + return try await service.processMessage( + systemPrompt: nil, + extraSystemPrompt: (useExtraSystemPrompt ?? false) ? extraSystemPrompt : + nil, + prompt: prompt + ) + case let .customChat(systemPrompt, _): + let service = ChatService() + return try await service.processMessage( + systemPrompt: systemPrompt, + extraSystemPrompt: nil, + prompt: prompt + ) + case let .singleRoundDialog( + systemPrompt, + overwriteSystemPrompt, + _, + _ + ): + let service = ChatService() + return try await service.handleSingleRoundDialogCommand( + systemPrompt: systemPrompt, + overwriteSystemPrompt: overwriteSystemPrompt ?? false, + prompt: prompt + ) + case let .promptToCode(extraSystemPrompt, instruction, _, _): + let service = PromptToCodeService( + code: prompt, + selectionRange: .outOfScope, + language: .plaintext, + identSize: 4, + usesTabsForIndentation: true, + projectRootURL: .init(fileURLWithPath: "/"), + fileURL: .init(fileURLWithPath: "/"), + allCode: prompt, + extraSystemPrompt: extraSystemPrompt, + generateDescriptionRequirement: false + ) + try await service.modifyCode(prompt: instruction ?? "Modify content.") + return service.code + } + } + )), title: BrowserChatTab.name), ].compactMap { $0 } - + return collection } } diff --git a/Pro b/Pro index dfbf605d..da49861d 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit dfbf605dadaa4f27438a5c80992923b02cdbf7ce +Subproject commit da49861d2332282e2ecbeffbf6afbbc24f7c440d From a35400a197a19322e1e9584e5c3ad8f13c0f6436 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 23:20:25 +0800 Subject: [PATCH 27/94] Displaying toast when processing custom commands --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index da49861d..881d90d9 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit da49861d2332282e2ecbeffbf6afbbc24f7c440d +Subproject commit 881d90d91b0382faf8be8155870d0def71e43d5e From c9acb43c1b43623f84d20f62d23c856346080367 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 23:26:32 +0800 Subject: [PATCH 28/94] Support inserting file content --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 881d90d9..0fe48a9f 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 881d90d91b0382faf8be8155870d0def71e43d5e +Subproject commit 0fe48a9f3eca3e0d8ed96ef660322aa45dc4f00b From f8c565a637096d2747a689ee62bded90f4941693 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Jul 2023 00:12:08 +0800 Subject: [PATCH 29/94] Adjust buttons --- .../SuggestionWidget/ChatWindowView.swift | 58 +++++++++++-------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index a4875a85..2bbe9320 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -52,6 +52,7 @@ struct ChatWindowView: View { struct ChatTitleBar: View { let store: StoreOf @State var isHovering = false + @Environment(\.controlActiveState) var controlActiveState var body: some View { HStack(spacing: 4) { @@ -59,46 +60,53 @@ struct ChatTitleBar: View { store.send(.hideButtonClicked) }) { Circle() - .fill(Color(nsColor: .systemRed)) + .fill( + controlActiveState == .key + ? Color(nsColor: .systemOrange) + : Color(nsColor: .disabledControlTextColor) + ) .frame(width: 10, height: 10) .overlay { - Circle().strokeBorder(.secondary.opacity(0.3), lineWidth: 1) + Circle().strokeBorder(.secondary.opacity(0.2), lineWidth: 1) } .overlay { if isHovering { - Image(systemName: "xmark") + Image(systemName: "minus") .resizable() .foregroundStyle(.secondary) .font(Font.title.weight(.heavy)) - .frame(width: 5, height: 5) + .frame(width: 5, height: 1) } } } - + WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in - if viewStore.state { - Button(action: { - store.send(.attachChatPanel) - }) { - Circle() - .fill(.indigo) - .frame(width: 10, height: 10) - .overlay { - Circle().strokeBorder(.secondary.opacity(0.3), lineWidth: 1) - } - .overlay { - if isHovering { - Image(systemName: "pin") - .resizable() - .foregroundStyle(.secondary) - .font(Font.title.weight(.heavy)) - .frame(width: 5, height: 5) - } + Button(action: { + store.send(.toggleChatPanelDetachedButtonClicked) + }) { + Circle() + .fill( + controlActiveState == .key && viewStore.state + ? Color(nsColor: .systemIndigo) + : Color(nsColor: .disabledControlTextColor) + ) + .frame(width: 10, height: 10) + .overlay { + Circle().strokeBorder(.secondary.opacity(0.2), lineWidth: 1) + } + .disabled(!viewStore.state) + .overlay { + if isHovering { + Image(systemName: "pin") + .resizable() + .foregroundStyle(.secondary) + .font(Font.title.weight(.heavy)) + .frame(width: 5, height: 5) } - } + } } } - + Button(action: { store.send(.closeActiveTabClicked) }) { From 555a2e66ec06a8c40a18bf02c07deb9fe020de85 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 30 Jul 2023 00:48:18 +0800 Subject: [PATCH 30/94] Adjust host app to use store --- .../CustomCommandSettings/CustomCommand.swift | 5 +- .../CustomCommandView.swift | 17 +- .../EditCustomCommand.swift | 12 +- .../EditCustomCommandView.swift | 3 +- Core/Sources/HostApp/General.swift | 84 ++++++ Core/Sources/HostApp/GeneralView.swift | 59 ++--- Core/Sources/HostApp/HostApp.swift | 28 ++ Core/Sources/HostApp/TabContainer.swift | 250 +++++++++--------- Core/Sources/HostApp/Toast.swift | 49 ++++ 9 files changed, 329 insertions(+), 178 deletions(-) create mode 100644 Core/Sources/HostApp/General.swift create mode 100644 Core/Sources/HostApp/HostApp.swift create mode 100644 Core/Sources/HostApp/Toast.swift diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index c6409ea8..3c60dd0e 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -9,7 +9,6 @@ struct CustomCommandFeature: ReducerProtocol { } let settings: CustomCommandView.Settings - let toast: (Text, ToastType) -> Void enum Action: Equatable { case createNewCommand @@ -18,6 +17,8 @@ struct CustomCommandFeature: ReducerProtocol { case deleteCommand(CustomCommand) } + @Dependency(\.toastController) var toastController + var body: some ReducerProtocol { Reduce { state, action in switch action { @@ -43,7 +44,7 @@ struct CustomCommandFeature: ReducerProtocol { } }.ifLet(\.editCustomCommand, action: /Action.editCustomCommand) { - EditCustomCommand(settings: settings, toast: toast) + EditCustomCommand(settings: settings) } } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 4722c505..11ec0acb 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -17,12 +17,7 @@ extension List { let customCommandStore = StoreOf( initialState: .init(), reducer: CustomCommandFeature( - settings: .init(), - toast: { content, type in - Task { @MainActor in - globalToastController.toast(content: content, type: type) - } - } + settings: .init() ) ) @@ -247,10 +242,7 @@ struct CustomCommandView_Preview: PreviewProvider { ) ))) ), - reducer: CustomCommandFeature( - settings: settings, - toast: { _, _ in } - ) + reducer: CustomCommandFeature(settings: settings) ), settings: settings ) @@ -286,10 +278,7 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider { initialState: .init( editCustomCommand: nil ), - reducer: CustomCommandFeature( - settings: settings, - toast: { _, _ in } - ) + reducer: CustomCommandFeature(settings: settings) ), settings: settings ) diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift index 36b42e19..85a2f049 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift @@ -84,7 +84,8 @@ struct EditCustomCommand: ReducerProtocol { } let settings: CustomCommandView.Settings - let toast: (Text, ToastType) -> Void + + @Dependency(\.toastController) var toastController var body: some ReducerProtocol { Scope(state: \.sendMessage, action: /Action.sendMessage) { @@ -109,7 +110,10 @@ struct EditCustomCommand: ReducerProtocol { switch action { case .saveCommand: guard !state.name.isEmpty else { - toast(Text("Command name cannot be empty."), .error) + toastController.toast( + content: Text("Command name cannot be empty."), + type: .error + ) return .none } @@ -154,7 +158,7 @@ struct EditCustomCommand: ReducerProtocol { if state.isNewCommand { settings.customCommands.append(newCommand) state.isNewCommand = false - toast(Text("The command is created."), .info) + toastController.toast(content: Text("The command is created."), type: .info) } else { if let index = settings.customCommands.firstIndex(where: { $0.id == newCommand.id @@ -163,7 +167,7 @@ struct EditCustomCommand: ReducerProtocol { } else { settings.customCommands.append(newCommand) } - toast(Text("The command is updated."), .info) + toastController.toast(content: Text("The command is updated."), type: .info) } return .none diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index 70754213..74b61585 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -243,8 +243,7 @@ struct EditCustomCommandView_Preview: PreviewProvider { settings: .init(customCommands: .init( wrappedValue: [], "CustomCommandView_Preview" - )), - toast: { _, _ in } + )) ) ) ) diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift new file mode 100644 index 00000000..98807011 --- /dev/null +++ b/Core/Sources/HostApp/General.swift @@ -0,0 +1,84 @@ +import Client +import ComposableArchitecture +import Foundation +import LaunchAgentManager +import SwiftUI + +struct General: ReducerProtocol { + struct State: Equatable { + var xpcServiceVersion: String? + var isAccessibilityPermissionGranted: Bool? + var isReloading = false + } + + enum Action: Equatable { + case appear + case setupLaunchAgentIfNeeded + case reloadStatus + case finishReloading(xpcServiceVersion: String, permissionGranted: Bool) + case failedReloading + } + + @Dependency(\.toastController) var toastController + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.setupLaunchAgentIfNeeded) + } + case .setupLaunchAgentIfNeeded: + return .run { send in + #if DEBUG + // do not auto install on debug build + #else + Task { + do { + try await LaunchAgentManager() + .setupLaunchAgentForTheFirstTimeIfNeeded() + } catch { + toastController.toast( + content: Text(error.localizedDescription), + type: .error + ) + } + } + #endif + await send(.reloadStatus) + } + case .reloadStatus: + state.isReloading = true + return .run { send in + let service = try getService() + do { + let xpcServiceVersion = try await service.getXPCServiceVersion().version + let isAccessibilityPermissionGranted = try await service + .getXPCServiceAccessibilityPermission() + await send(.finishReloading( + xpcServiceVersion: xpcServiceVersion, + permissionGranted: isAccessibilityPermissionGranted + )) + } catch { + toastController.toast( + content: Text(error.localizedDescription), + type: .error + ) + await send(.failedReloading) + } + } + + case let .finishReloading(version, granted): + state.xpcServiceVersion = version + state.isAccessibilityPermissionGranted = granted + state.isReloading = false + return .none + + case .failedReloading: + state.isReloading = false + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index a0cf366b..17078c14 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -1,21 +1,27 @@ import Client +import ComposableArchitecture import LaunchAgentManager import Preferences import SwiftUI struct GeneralView: View { + let store: StoreOf + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { AppInfoView() Divider() - ExtensionServiceView() + ExtensionServiceView(store: store) Divider() LaunchAgentView() Divider() GeneralSettingsView() } } + .onAppear { + store.send(.appear) + } } } @@ -74,24 +80,28 @@ struct AppInfoView: View { } struct ExtensionServiceView: View { - @Environment(\.toast) var toast - @State var xpcServiceVersion: String? - @State var isAccessibilityPermissionGranted: Bool? - @State var isRunningAction = false + let store: StoreOf var body: some View { VStack(alignment: .leading) { - Text("Extension Service Version: \(xpcServiceVersion ?? "Loading..")") - let grantedStatus: String = { - guard let isAccessibilityPermissionGranted else { return "Loading.." } - return isAccessibilityPermissionGranted ? "Granted" : "Not Granted" - }() - Text("Accessibility Permission: \(grantedStatus)") + WithViewStore(store, observe: { $0.xpcServiceVersion }) { viewStore in + Text("Extension Service Version: \(viewStore.state ?? "Loading..")") + } + + WithViewStore(store, observe: { $0.isAccessibilityPermissionGranted }) { viewStore in + let grantedStatus: String = { + guard let granted = viewStore.state else { return "Loading.." } + return granted ? "Granted" : "Not Granted" + }() + Text("Accessibility Permission: \(grantedStatus)") + } HStack { - Button(action: { checkStatus() }) { - Text("Refresh") - }.disabled(isRunningAction) + WithViewStore(store, observe: { $0.isReloading }) { viewStore in + Button(action: { viewStore.send(.reloadStatus) }) { + Text("Refresh") + }.disabled(viewStore.state) + } Button(action: { Task { @@ -126,25 +136,6 @@ struct ExtensionServiceView: View { } } .padding() - .onAppear { - checkStatus() - } - } - - func checkStatus() { - Task { - try await Task.sleep(nanoseconds: 2_000_000_000) - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getService() - xpcServiceVersion = try await service.getXPCServiceVersion().version - isAccessibilityPermissionGranted = try await service - .getXPCServiceAccessibilityPermission() - } catch { - toast(Text(error.localizedDescription), .error) - } - } } } @@ -298,7 +289,7 @@ struct GeneralSettingsView: View { struct GeneralView_Previews: PreviewProvider { static var previews: some View { - GeneralView() + GeneralView(store: .init(initialState: .init(), reducer: General())) } } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift new file mode 100644 index 00000000..bfb8337c --- /dev/null +++ b/Core/Sources/HostApp/HostApp.swift @@ -0,0 +1,28 @@ +import Foundation +import ComposableArchitecture + +struct HostApp: ReducerProtocol { + struct State: Equatable { + var general = General.State() + } + + enum Action: Equatable { + case appear + case general(General.Action) + } + + var body: some ReducerProtocol { + Scope(state: \.general, action: /Action.general) { + General() + } + + Reduce { _, action in + switch action { + case .appear: + return .none + case .general: + return .none + } + } + } +} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 24db9ee4..3b4d5c86 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -1,52 +1,76 @@ +import ComposableArchitecture +import Dependencies import Foundation import LaunchAgentManager import SwiftUI import UpdateChecker -enum Tab: Int, CaseIterable, Equatable { - case general - case service - case feature - case customCommand - case debug +struct ToastControllerDependencyKey: DependencyKey { + static let liveValue = ToastController(messages: []) +} + +extension DependencyValues { + var toastController: ToastController { + get { self[ToastControllerDependencyKey.self] } + set { self[ToastControllerDependencyKey.self] = newValue } + } } @MainActor -let globalToastController = ToastController(messages: []) +let hostAppStore: StoreOf = .init(initialState: .init(), reducer: HostApp()) public struct TabContainer: View { + let store: StoreOf @ObservedObject var toastController: ToastController - @State var tab = Tab.general + @State private var tabBarItems = [TabBarItem]() + @State var tag: Int = 0 public init() { - self.toastController = globalToastController + toastController = ToastControllerDependencyKey.liveValue + store = hostAppStore } - init(toastController: ToastController) { + init(store: StoreOf, toastController: ToastController) { + self.store = store self.toastController = toastController } public var body: some View { VStack(spacing: 0) { - TabBar(tab: $tab) + TabBar(tag: $tag, tabBarItems: tabBarItems) .padding(.bottom, 8) Divider() - Group { - switch tab { - case .general: - GeneralView() - case .service: - ServiceView() - case .feature: - FeatureSettingsView() - case .customCommand: - CustomCommandView(store: customCommandStore) - case .debug: - DebugSettingsView() - } + ZStack(alignment: .center) { + GeneralView(store: store.scope(state: \.general, action: HostApp.Action.general)) + .tabBarItem( + tag: 0, + title: "General", + image: "app.gift" + ) + ServiceView().tabBarItem( + tag: 1, + title: "Service", + image: "globe" + ) + FeatureSettingsView().tabBarItem( + tag: 2, + title: "Feature", + image: "star.square" + ) + CustomCommandView(store: customCommandStore).tabBarItem( + tag: 3, + title: "Custom Command", + image: "command.square" + ) + DebugSettingsView().tabBarItem( + tag: 4, + title: "Advanced", + image: "gearshape.2" + ) } + .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) .overlay(alignment: .bottom) { VStack(spacing: 4) { @@ -73,76 +97,43 @@ public struct TabContainer: View { .environment(\.toast) { [toastController] content, type in toastController.toast(content: content, type: type) } + .onPreferenceChange(TabBarItemPreferenceKey.self) { items in + tabBarItems = items + } .onAppear { - #if DEBUG - // do not auto install on debug build - #else - Task { - do { - try await LaunchAgentManager() - .setupLaunchAgentForTheFirstTimeIfNeeded() - } catch { - toastController.toast(content: Text(error.localizedDescription), type: .error) - } - } - #endif + store.send(.appear) } } } struct TabBar: View { - @Binding var tab: Tab + @Binding var tag: Int + fileprivate var tabBarItems: [TabBarItem] var body: some View { HStack { - ForEach(Tab.allCases, id: \.self) { tab in - switch tab { - case .general: - TabBarButton( - currentTab: $tab, - title: "General", - image: "app.gift", - tab: tab - ) - case .service: - TabBarButton(currentTab: $tab, title: "Service", image: "globe", tab: tab) - case .feature: - TabBarButton( - currentTab: $tab, - title: "Feature", - image: "star.square", - tab: tab - ) - case .customCommand: - TabBarButton( - currentTab: $tab, - title: "Custom Command", - image: "command.square", - tab: tab - ) - case .debug: - TabBarButton( - currentTab: $tab, - title: "Advanced", - image: "gearshape.2", - tab: tab - ) - } + ForEach(tabBarItems) { tab in + TabBarButton( + currentTag: $tag, + tag: tab.tag, + title: tab.title, + image: tab.image + ) } } } } struct TabBarButton: View { - @Binding var currentTab: Tab + @Binding var currentTag: Int @State var isHovered = false + var tag: Int var title: String var image: String - var tab: Tab var body: some View { Button(action: { - self.currentTab = tab + self.currentTag = tag }) { VStack(spacing: 2) { Image(systemName: image) @@ -156,7 +147,7 @@ struct TabBarButton: View { .padding(.vertical, 4) .padding(.top, 4) .background( - tab == currentTab + tag == currentTag ? Color(nsColor: .textColor).opacity(0.1) : Color.clear, in: RoundedRectangle(cornerRadius: 8) @@ -175,64 +166,76 @@ struct TabBarButton: View { } } -// MARK: - Environment Keys - -struct UpdateCheckerKey: EnvironmentKey { - static var defaultValue: UpdateChecker = .init(hostBundle: nil) -} +private struct TabBarTabViewWrapper: View { + @Environment(\.tabBarTabTag) var tabBarTabTag + var tag: Int + var title: String + var image: String + var content: () -> Content -public extension EnvironmentValues { - var updateChecker: UpdateChecker { - get { self[UpdateCheckerKey.self] } - set { self[UpdateCheckerKey.self] = newValue } + var body: some View { + Group { + if tag == tabBarTabTag { + content() + } else { + Color.clear + } + } + .preference( + key: TabBarItemPreferenceKey.self, + value: [.init(tag: tag, title: title, image: image)] + ) } } -enum ToastType { - case info - case warning - case error +private extension View { + func tabBarItem( + tag: Int, + title: String, + image: String + ) -> some View { + TabBarTabViewWrapper( + tag: tag, + title: title, + image: image, + content: { self } + ) + } } -struct ToastKey: EnvironmentKey { - static var defaultValue: (Text, ToastType) -> Void = { _, _ in } +private struct TabBarItem: Identifiable, Equatable { + var id: Int { tag } + var tag: Int + var title: String + var image: String } -extension EnvironmentValues { - var toast: (Text, ToastType) -> Void { - get { self[ToastKey.self] } - set { self[ToastKey.self] = newValue } +private struct TabBarItemPreferenceKey: PreferenceKey { + static var defaultValue: [TabBarItem] = [] + static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) { + value.append(contentsOf: nextValue()) } } -@MainActor -class ToastController: ObservableObject { - struct Message: Identifiable { - var id: UUID - var type: ToastType - var content: Text - } - - @Published var messages: [Message] = [] +private struct TabBarTabTagKey: EnvironmentKey { + static var defaultValue: Int = 0 +} - init(messages: [Message]) { - self.messages = messages +private extension EnvironmentValues { + var tabBarTabTag: Int { + get { self[TabBarTabTagKey.self] } + set { self[TabBarTabTagKey.self] = newValue } } +} - func toast(content: Text, type: ToastType) { - let id = UUID() - let message = Message(id: id, type: type, content: content) +struct UpdateCheckerKey: EnvironmentKey { + static var defaultValue: UpdateChecker = .init(hostBundle: nil) +} - Task { @MainActor in - withAnimation(.easeInOut(duration: 0.2)) { - messages.append(message) - messages = messages.suffix(3) - } - try await Task.sleep(nanoseconds: 4_000_000_000) - withAnimation(.easeInOut(duration: 0.2)) { - messages.removeAll { $0.id == id } - } - } +public extension EnvironmentValues { + var updateChecker: UpdateChecker { + get { self[UpdateCheckerKey.self] } + set { self[UpdateCheckerKey.self] = newValue } } } @@ -247,11 +250,14 @@ struct TabContainer_Previews: PreviewProvider { struct TabContainer_Toasts_Previews: PreviewProvider { static var previews: some View { - TabContainer(toastController: .init(messages: [ - .init(id: UUID(), type: .info, content: Text("info")), - .init(id: UUID(), type: .error, content: Text("error")), - .init(id: UUID(), type: .warning, content: Text("warning")), - ])) + TabContainer( + store: .init(initialState: .init(), reducer: HostApp()), + toastController: .init(messages: [ + .init(id: UUID(), type: .info, content: Text("info")), + .init(id: UUID(), type: .error, content: Text("error")), + .init(id: UUID(), type: .warning, content: Text("warning")), + ]) + ) .frame(width: 800) } } diff --git a/Core/Sources/HostApp/Toast.swift b/Core/Sources/HostApp/Toast.swift new file mode 100644 index 00000000..efd150e9 --- /dev/null +++ b/Core/Sources/HostApp/Toast.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftUI + +enum ToastType { + case info + case warning + case error +} + +struct ToastKey: EnvironmentKey { + static var defaultValue: (Text, ToastType) -> Void = { _, _ in } +} + +extension EnvironmentValues { + var toast: (Text, ToastType) -> Void { + get { self[ToastKey.self] } + set { self[ToastKey.self] = newValue } + } +} + +class ToastController: ObservableObject { + struct Message: Identifiable { + var id: UUID + var type: ToastType + var content: Text + } + + @Published var messages: [Message] = [] + + init(messages: [Message]) { + self.messages = messages + } + + func toast(content: Text, type: ToastType) { + let id = UUID() + let message = Message(id: id, type: type, content: content) + + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.2)) { + messages.append(message) + messages = messages.suffix(3) + } + try await Task.sleep(nanoseconds: 4_000_000_000) + withAnimation(.easeInOut(duration: 0.2)) { + messages.removeAll { $0.id == id } + } + } + } +} From f5f7343f282e2654025793a3c1f871328a0294fb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 00:24:24 +0800 Subject: [PATCH 31/94] Add license management view --- Copilot for Xcode/App.swift | 6 ++ Core/Package.swift | 9 ++- .../HostApp/AccountSettings/AzureView.swift | 4 +- .../HostApp/AccountSettings/CodeiumView.swift | 16 ++--- .../HostApp/AccountSettings/CopilotView.swift | 31 ++++---- .../HostApp/AccountSettings/OpenAIView.swift | 4 +- .../CustomCommandSettings/CustomCommand.swift | 11 +-- .../EditCustomCommand.swift | 11 ++- Core/Sources/HostApp/General.swift | 7 +- Core/Sources/HostApp/GeneralView.swift | 6 +- Core/Sources/HostApp/TabContainer.swift | 23 +++--- Core/Sources/HostApp/Toast.swift | 49 ------------- .../SuggestionWidget/ChatWindowView.swift | 8 ++- .../SuggestionWidgetController.swift | 10 +-- Pro | 2 +- TestPlan.xctestplan | 21 ++++-- Tool/Package.swift | 19 +++-- Tool/Sources/Toast/Toast.swift | 71 +++++++++++++++++++ .../Tests/LangChainTests/ChatAgentTests.swift | 4 +- .../ChatGPTStreamTests.swift | 2 + 20 files changed, 181 insertions(+), 133 deletions(-) delete mode 100644 Core/Sources/HostApp/Toast.swift create mode 100644 Tool/Sources/Toast/Toast.swift diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index c499fb4a..094e32d5 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -5,12 +5,18 @@ import SwiftUI import UpdateChecker import XPCShared +struct VisualEffect: NSViewRepresentable { + func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() } + func updateNSView(_ nsView: NSView, context: Context) { } +} + @main struct CopilotForXcodeApp: App { var body: some Scene { WindowGroup { TabContainer() .frame(minWidth: 800, minHeight: 600) + .background(VisualEffect().ignoresSafeArea()) .onAppear { UserDefaults.setupDefaultSettings() } diff --git a/Core/Package.swift b/Core/Package.swift index 2a958c76..d79e6fb1 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -117,26 +117,29 @@ let package = Package( "GitHubCopilotService", "CodeiumService", "LaunchAgentManager", + .product(name: "Toast", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] + ].pro([ + "ProHostApp", + ]) ), // MARK: - XPC Related .target( name: "XPCShared", - dependencies: [.product(name: "SuggestionModel", package: "Tool"),] + dependencies: [.product(name: "SuggestionModel", package: "Tool")] ), // MARK: - Suggestion Service .target( name: "SuggestionInjector", - dependencies: [.product(name: "SuggestionModel", package: "Tool"),] + dependencies: [.product(name: "SuggestionModel", package: "Tool")] ), .testTarget( name: "SuggestionInjectorTests", diff --git a/Core/Sources/HostApp/AccountSettings/AzureView.swift b/Core/Sources/HostApp/AccountSettings/AzureView.swift index cb2b59b6..cfe34903 100644 --- a/Core/Sources/HostApp/AccountSettings/AzureView.swift +++ b/Core/Sources/HostApp/AccountSettings/AzureView.swift @@ -51,9 +51,9 @@ struct AzureView: View { .overriding(.init(featureProvider: .azureOpenAI)) ) .sendAndWait(content: "Hello", summary: nil) - toast(Text("ChatGPT replied: \(reply ?? "N/A")"), .info) + toast("ChatGPT replied: \(reply ?? "N/A")", .info) } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift index a6885bb4..53e1f76e 100644 --- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift +++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift @@ -89,7 +89,7 @@ struct CodeiumView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -104,7 +104,7 @@ struct CodeiumView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -158,7 +158,7 @@ struct CodeiumView: View { do { try await viewModel.signOut() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -187,13 +187,13 @@ struct CodeiumView: View { if let step = newValue { switch step { case .downloading: - toast(Text("Downloading.."), .info) + toast("Downloading..", .info) case .uninstalling: - toast(Text("Uninstalling old version.."), .info) + toast("Uninstalling old version..", .info) case .decompressing: - toast(Text("Decompressing.."), .info) + toast("Decompressing..", .info) case .done: - toast(Text("Done!"), .info) + toast("Done!", .info) } } } @@ -249,7 +249,7 @@ struct CodeiumSignInView: View { isPresented = false } catch { isGeneratingKey = false - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { diff --git a/Core/Sources/HostApp/AccountSettings/CopilotView.swift b/Core/Sources/HostApp/AccountSettings/CopilotView.swift index 2f9968c1..41ff2003 100644 --- a/Core/Sources/HostApp/AccountSettings/CopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/CopilotView.swift @@ -105,7 +105,7 @@ struct CopilotView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -120,7 +120,7 @@ struct CopilotView: View { do { try await viewModel.install() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -270,13 +270,13 @@ struct CopilotView: View { if let step = newValue { switch step { case .downloading: - toast(Text("Downloading.."), .info) + toast("Downloading..", .info) case .uninstalling: - toast(Text("Uninstalling old version.."), .info) + toast("Uninstalling old version..", .info) case .decompressing: - toast(Text("Decompressing.."), .info) + toast("Decompressing..", .info) case .done: - toast(Text("Done!"), .info) + toast("Done!", .info) checkStatus() } } @@ -295,14 +295,13 @@ struct CopilotView: View { if status != .ok, status != .notSignedIn { toast( - Text( - "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription." - ), + "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", + .error ) } } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } @@ -316,17 +315,17 @@ struct CopilotView: View { let (uri, userCode) = try await service.signInInitiate() self.userCode = userCode guard let url = URL(string: uri) else { - toast(Text("Verification URI is incorrect."), .error) + toast("Verification URI is incorrect.", .error) return } let pasteboard = NSPasteboard.general pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) pasteboard.setString(userCode, forType: NSPasteboard.PasteboardType.string) - toast(Text("Usercode \(userCode) already copied!"), .info) + toast("Usercode \(userCode) already copied!", .info) openURL(url) isUserCodeCopiedAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } @@ -338,14 +337,14 @@ struct CopilotView: View { do { let service = try getGitHubCopilotAuthService() guard let userCode else { - toast(Text("Usercode is empty."), .error) + toast("Usercode is empty.", .error) return } let (username, status) = try await service.signInConfirm(userCode: userCode) self.settings.username = username self.status = status } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } @@ -358,7 +357,7 @@ struct CopilotView: View { let service = try getGitHubCopilotAuthService() status = try await service.signOut() } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } } diff --git a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift index ccf037f9..ddb21afd 100644 --- a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift +++ b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift @@ -56,9 +56,9 @@ struct OpenAIView: View { .overriding(.init(featureProvider: .openAI)) ) .sendAndWait(content: "Hello", summary: nil) - toast(Text("ChatGPT replied: \(reply ?? "N/A")"), .info) + toast("ChatGPT replied: \(reply ?? "N/A")", .info) } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }.disabled(isTesting) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index 3c60dd0e..39d98a28 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -2,23 +2,24 @@ import ComposableArchitecture import Foundation import Preferences import SwiftUI +import Toast struct CustomCommandFeature: ReducerProtocol { struct State: Equatable { var editCustomCommand: EditCustomCommand.State? } - + let settings: CustomCommandView.Settings - + enum Action: Equatable { case createNewCommand case editCommand(CustomCommand) case editCustomCommand(EditCustomCommand.Action) case deleteCommand(CustomCommand) } - + @Dependency(\.toastController) var toastController - + var body: some ReducerProtocol { Reduce { state, action in switch action { @@ -41,10 +42,10 @@ struct CustomCommandFeature: ReducerProtocol { return .none case .editCustomCommand: return .none - } }.ifLet(\.editCustomCommand, action: /Action.editCustomCommand) { EditCustomCommand(settings: settings) } } } + diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift index 85a2f049..03d8ddf9 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift @@ -85,7 +85,7 @@ struct EditCustomCommand: ReducerProtocol { let settings: CustomCommandView.Settings - @Dependency(\.toastController) var toastController + @Dependency(\.toast) var toast var body: some ReducerProtocol { Scope(state: \.sendMessage, action: /Action.sendMessage) { @@ -110,10 +110,7 @@ struct EditCustomCommand: ReducerProtocol { switch action { case .saveCommand: guard !state.name.isEmpty else { - toastController.toast( - content: Text("Command name cannot be empty."), - type: .error - ) + toast("Command name cannot be empty.", .error) return .none } @@ -158,7 +155,7 @@ struct EditCustomCommand: ReducerProtocol { if state.isNewCommand { settings.customCommands.append(newCommand) state.isNewCommand = false - toastController.toast(content: Text("The command is created."), type: .info) + toast("The command is created.", .info) } else { if let index = settings.customCommands.firstIndex(where: { $0.id == newCommand.id @@ -167,7 +164,7 @@ struct EditCustomCommand: ReducerProtocol { } else { settings.customCommands.append(newCommand) } - toastController.toast(content: Text("The command is updated."), type: .info) + toast("The command is updated.", .info) } return .none diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 98807011..a2d294d8 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -60,10 +60,7 @@ struct General: ReducerProtocol { permissionGranted: isAccessibilityPermissionGranted )) } catch { - toastController.toast( - content: Text(error.localizedDescription), - type: .error - ) + toastController.toast(content: error.localizedDescription, type: .error) await send(.failedReloading) } } @@ -73,7 +70,7 @@ struct General: ReducerProtocol { state.isAccessibilityPermissionGranted = granted state.isReloading = false return .none - + case .failedReloading: state.isReloading = false return .none diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index 17078c14..dac8ca72 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -154,7 +154,7 @@ struct LaunchAgentView: View { try await LaunchAgentManager().setupLaunchAgent() isDidSetupLaunchAgentAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -176,7 +176,7 @@ struct LaunchAgentView: View { try await LaunchAgentManager().removeLaunchAgent() isDidRemoveLaunchAgentAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -195,7 +195,7 @@ struct LaunchAgentView: View { try await LaunchAgentManager().reloadLaunchAgent() isDidRestartLaunchAgentAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 3b4d5c86..0bb550a8 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -3,18 +3,12 @@ import Dependencies import Foundation import LaunchAgentManager import SwiftUI +import Toast import UpdateChecker -struct ToastControllerDependencyKey: DependencyKey { - static let liveValue = ToastController(messages: []) -} - -extension DependencyValues { - var toastController: ToastController { - get { self[ToastControllerDependencyKey.self] } - set { self[ToastControllerDependencyKey.self] = newValue } - } -} +#if canImport(ProHostApp) +import ProHostApp +#endif @MainActor let hostAppStore: StoreOf = .init(initialState: .init(), reducer: HostApp()) @@ -64,6 +58,13 @@ public struct TabContainer: View { title: "Custom Command", image: "command.square" ) + #if canImport(ProHostApp) + PlusView().tabBarItem( + tag: 5, + title: "Plus", + image: "plus.diamond" + ) + #endif DebugSettingsView().tabBarItem( tag: 4, title: "Advanced", @@ -94,6 +95,8 @@ public struct TabContainer: View { } .focusable(false) .padding(.top, 8) + .background(.ultraThinMaterial.opacity(0.01)) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.4)) .environment(\.toast) { [toastController] content, type in toastController.toast(content: content, type: type) } diff --git a/Core/Sources/HostApp/Toast.swift b/Core/Sources/HostApp/Toast.swift deleted file mode 100644 index efd150e9..00000000 --- a/Core/Sources/HostApp/Toast.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import SwiftUI - -enum ToastType { - case info - case warning - case error -} - -struct ToastKey: EnvironmentKey { - static var defaultValue: (Text, ToastType) -> Void = { _, _ in } -} - -extension EnvironmentValues { - var toast: (Text, ToastType) -> Void { - get { self[ToastKey.self] } - set { self[ToastKey.self] = newValue } - } -} - -class ToastController: ObservableObject { - struct Message: Identifiable { - var id: UUID - var type: ToastType - var content: Text - } - - @Published var messages: [Message] = [] - - init(messages: [Message]) { - self.messages = messages - } - - func toast(content: Text, type: ToastType) { - let id = UUID() - let message = Message(id: id, type: type, content: content) - - Task { @MainActor in - withAnimation(.easeInOut(duration: 0.2)) { - messages.append(message) - messages = messages.suffix(3) - } - try await Task.sleep(nanoseconds: 4_000_000_000) - withAnimation(.easeInOut(duration: 0.2)) { - messages.removeAll { $0.id == id } - } - } - } -} diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2bbe9320..6bca7998 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -67,7 +67,7 @@ struct ChatTitleBar: View { ) .frame(width: 10, height: 10) .overlay { - Circle().strokeBorder(.secondary.opacity(0.2), lineWidth: 1) + Circle().strokeBorder(.black.opacity(0.2), lineWidth: 1) } .overlay { if isHovering { @@ -92,7 +92,7 @@ struct ChatTitleBar: View { ) .frame(width: 10, height: 10) .overlay { - Circle().strokeBorder(.secondary.opacity(0.2), lineWidth: 1) + Circle().strokeBorder(.black.opacity(0.2), lineWidth: 1) } .disabled(!viewStore.state) .overlay { @@ -101,7 +101,7 @@ struct ChatTitleBar: View { .resizable() .foregroundStyle(.secondary) .font(Font.title.weight(.heavy)) - .frame(width: 5, height: 5) + .frame(width: 3, height: 5) } } } @@ -131,6 +131,7 @@ struct ChatTitleBar: View { } } .padding(.horizontal, 6) + .padding(.top, 1) .frame(maxWidth: .infinity) .frame(height: 16) .onHover(perform: { hovering in @@ -410,6 +411,7 @@ struct ChatWindowView_Previews: PreviewProvider { reducer: ChatPanelFeature() ) ) + .xcodeStyleFrame() .padding() } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index bdccbe87..18ecda60 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -41,7 +41,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.level = .init(19) + it.level = .floating it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( @@ -67,7 +67,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.level = .init(19) + it.level = .floating it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( @@ -91,7 +91,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 1) + it.level = .init(NSWindow.Level.floating.rawValue + 2) it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( @@ -127,7 +127,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 1) + it.level = .init(NSWindow.Level.floating.rawValue + 2) it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( @@ -156,7 +156,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.level = .floating + it.level = .init(NSWindow.Level.floating.rawValue + 1) it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( diff --git a/Pro b/Pro index 0fe48a9f..d2a1ffa9 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 0fe48a9f3eca3e0d8ed96ef660322aa45dc4f00b +Subproject commit d2a1ffa9eb887074838a694d053fb437a9c72f81 diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 6b9a6a3a..f79f736f 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -57,13 +57,6 @@ "name" : "GitHubCopilotServiceTests" } }, - { - "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionModelTests", - "name" : "SuggestionModelTests" - } - }, { "target" : { "containerPath" : "container:Tool", @@ -98,6 +91,20 @@ "identifier" : "TokenEncoderTests", "name" : "TokenEncoderTests" } + }, + { + "target" : { + "containerPath" : "container:Pro", + "identifier" : "LicenseManagementTests", + "name" : "LicenseManagementTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionModelTests", + "name" : "SuggestionModelTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index dc5468d4..17a1470f 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -16,6 +16,7 @@ let package = Package( .library(name: "ChatTab", targets: ["ChatTab"]), .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), + .library(name: "Toast", targets: ["Toast"]), .library( name: "AppMonitoring", targets: [ @@ -53,6 +54,14 @@ let package = Package( .target(name: "ObjectiveCExceptionHandling"), + .target( + name: "Toast", + dependencies: [.product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + )] + ), + .target( name: "Environment", dependencies: [ @@ -87,11 +96,11 @@ let package = Package( name: "SuggestionModel", dependencies: ["LanguageClient"] ), - - .testTarget( - name: "SuggestionModelTests", - dependencies: ["SuggestionModel"] - ), + + .testTarget( + name: "SuggestionModelTests", + dependencies: ["SuggestionModel"] + ), .target(name: "AXExtension"), diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift new file mode 100644 index 00000000..76c37eb0 --- /dev/null +++ b/Tool/Sources/Toast/Toast.swift @@ -0,0 +1,71 @@ +import Foundation +import SwiftUI +import Dependencies + +public enum ToastType { + case info + case warning + case error +} + +public struct ToastKey: EnvironmentKey { + public static var defaultValue: (String, ToastType) -> Void = { _, _ in } +} + +public extension EnvironmentValues { + var toast: (String, ToastType) -> Void { + get { self[ToastKey.self] } + set { self[ToastKey.self] = newValue } + } +} + +public struct ToastControllerDependencyKey: DependencyKey { + public static let liveValue = ToastController(messages: []) +} + +public extension DependencyValues { + var toastController: ToastController { + get { self[ToastControllerDependencyKey.self] } + set { self[ToastControllerDependencyKey.self] = newValue } + } + + var toast: (String, ToastType) -> Void { + get { toastController.toast } + } +} + +public class ToastController: ObservableObject { + public struct Message: Identifiable { + public var id: UUID + public var type: ToastType + public var content: Text + public init(id: UUID, type: ToastType, content: Text) { + self.id = id + self.type = type + self.content = content + } + } + + @Published public var messages: [Message] = [] + + public init(messages: [Message]) { + self.messages = messages + } + + public func toast(content: String, type: ToastType) { + let id = UUID() + let message = Message(id: id, type: type, content: Text(content)) + + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.2)) { + messages.append(message) + messages = messages.suffix(3) + } + try await Task.sleep(nanoseconds: 4_000_000_000) + withAnimation(.easeInOut(duration: 0.2)) { + messages.removeAll { $0.id == id } + } + } + } +} + diff --git a/Tool/Tests/LangChainTests/ChatAgentTests.swift b/Tool/Tests/LangChainTests/ChatAgentTests.swift index 9c4280de..24918f61 100644 --- a/Tool/Tests/LangChainTests/ChatAgentTests.swift +++ b/Tool/Tests/LangChainTests/ChatAgentTests.swift @@ -6,8 +6,8 @@ private struct FakeChatModel: ChatModel { prompt: [LangChain.ChatMessage], stops: [String], callbackManagers: [LangChain.CallbackManager] - ) async throws -> String { - return "New Message" + ) async throws -> LangChain.ChatMessage { + return .init(role: .assistant, content: "New Message") } } diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index 6f6284ab..59813dc6 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -360,6 +360,8 @@ extension ChatGPTStreamTests { } struct FunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil } + var functions: [any ChatGPTFunction] { [EmptyFunction()] } } } From 0a59dcdccd3521c90ed8f03303a5e85f06c4f2e9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 00:26:48 +0800 Subject: [PATCH 32/94] Update Azure OpenAI api version --- .../OpenAIService/Configuration/ChatGPTConfiguration.swift | 2 +- .../OpenAIService/Configuration/EmbeddingConfiguration.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index 226501f7..f446ea2d 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -23,7 +23,7 @@ public extension ChatGPTConfiguration { case .azureOpenAI: let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) let deployment = UserDefaults.shared.value(for: \.azureChatGPTDeployment) - let version = "2023-05-15" + let version = "2023-07-01-preview" if baseURL.isEmpty { return "" } return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" } diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index 62209033..5d61f581 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -19,7 +19,7 @@ public extension EmbeddingConfiguration { case .azureOpenAI: let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) let deployment = UserDefaults.shared.value(for: \.azureChatGPTDeployment) - let version = "2023-05-15" + let version = "2023-07-01-preview" if baseURL.isEmpty { return "" } return "\(baseURL)/openai/deployments/\(deployment)/embeddings?api-version=\(version)" } From d0712417befbb4dc83fb4bb891676df97ef0b218 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 00:33:09 +0800 Subject: [PATCH 33/94] Fix that embedding with Azure OpenAI was using the wrong deployment --- .../OpenAIService/Configuration/EmbeddingConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index 5d61f581..13c4ab21 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -18,7 +18,7 @@ public extension EmbeddingConfiguration { return "\(baseURL)/v1/embeddings" case .azureOpenAI: let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) - let deployment = UserDefaults.shared.value(for: \.azureChatGPTDeployment) + let deployment = UserDefaults.shared.value(for: \.azureEmbeddingDeployment) let version = "2023-07-01-preview" if baseURL.isEmpty { return "" } return "\(baseURL)/openai/deployments/\(deployment)/embeddings?api-version=\(version)" From d55eebe00032a04c9daf7259f94dd13ce5af99dd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 00:38:49 +0800 Subject: [PATCH 34/94] Bump Codeium version to 1.2.57 --- Core/Sources/CodeiumService/CodeiumInstallationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift index 7ac7cc7f..1101538e 100644 --- a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift +++ b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.2.40" + static let latestSupportedVersion = "1.2.57" public init() {} From db73cc1bf0250037eb86cf5f96024f967bc77fdb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 16:57:18 +0800 Subject: [PATCH 35/94] Move files to package Tool --- Core/Package.swift | 18 ++++-------------- ... => GraphicalUserInterfaceController.swift} | 17 ++++++++--------- Tool/Package.swift | 13 +++++++++++++ .../Sources/SharedUIComponents/CodeBlock.swift | 0 .../SharedUIComponents/CopyButton.swift | 0 .../SharedUIComponents/CustomScrollView.swift | 0 .../SharedUIComponents/CustomTextEditor.swift | 0 .../SyntaxHighlighting.swift | 0 .../ConvertToCodeLinesTests.swift | 0 9 files changed, 25 insertions(+), 23 deletions(-) rename Core/Sources/Service/GUI/{GraphicalUserInterfaceController.swift.swift => GraphicalUserInterfaceController.swift} (96%) rename {Core => Tool}/Sources/SharedUIComponents/CodeBlock.swift (100%) rename {Core => Tool}/Sources/SharedUIComponents/CopyButton.swift (100%) rename {Core => Tool}/Sources/SharedUIComponents/CustomScrollView.swift (100%) rename {Core => Tool}/Sources/SharedUIComponents/CustomTextEditor.swift (100%) rename {Core => Tool}/Sources/SharedUIComponents/SyntaxHighlighting.swift (100%) rename {Core => Tool}/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift (100%) diff --git a/Core/Package.swift b/Core/Package.swift index d79e6fb1..61efbf75 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -43,12 +43,11 @@ let package = Package( .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), - .package(url: "https://github.com/raspu/Highlightr", from: "2.1.0"), - .package(url: "https://github.com/JohnSundell/Splash", branch: "master"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.2"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.5.1"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" @@ -90,6 +89,7 @@ let package = Package( .product(name: "Preferences", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "Dependencies", package: "swift-dependencies"), ].pro([ "ProChatTabs", ]) @@ -209,8 +209,8 @@ let package = Package( .target( name: "ChatGPTChatTab", dependencies: [ - "SharedUIComponents", "ChatService", + .product(name: "SharedUIComponents", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "ChatTab", package: "Tool"), @@ -220,22 +220,12 @@ let package = Package( // MARK: - UI - .target( - name: "SharedUIComponents", - dependencies: [ - "Highlightr", - "Splash", - .product(name: "Preferences", package: "Tool"), - ] - ), - .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), - .target( name: "SuggestionWidget", dependencies: [ "ChatGPTChatTab", "UserDefaultsObserver", - "SharedUIComponents", + .product(name: "SharedUIComponents", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "ChatTab", package: "Tool"), diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift similarity index 96% rename from Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift rename to Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index cd14e2a9..a32971f6 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -3,6 +3,7 @@ import ChatGPTChatTab import ChatService import ChatTab import ComposableArchitecture +import Dependencies import Environment import Preferences import PromptToCodeService @@ -133,15 +134,13 @@ public final class GraphicalUserInterfaceController { viewStore = ViewStore(store) widgetDataSource = .init() - widgetController = withDependencies(setupDependency) { - SuggestionWidgetController( - store: store.scope( - state: \.suggestionWidgetState, - action: GUI.Action.suggestionWidget - ), - dependency: suggestionDependency - ) - } + widgetController = SuggestionWidgetController( + store: store.scope( + state: \.suggestionWidgetState, + action: GUI.Action.suggestionWidget + ), + dependency: suggestionDependency + ) suggestionDependency.suggestionWidgetDataSource = widgetDataSource suggestionDependency.onOpenChatClicked = { [weak self] in diff --git a/Tool/Package.swift b/Tool/Package.swift index 17a1470f..8002b3e5 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), .library(name: "Toast", targets: ["Toast"]), + .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), .library( name: "AppMonitoring", targets: [ @@ -36,6 +37,8 @@ let package = Package( .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), .package(url: "https://github.com/unum-cloud/usearch", from: "0.19.1"), + .package(url: "https://github.com/raspu/Highlightr", from: "2.1.0"), + .package(url: "https://github.com/JohnSundell/Splash", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" @@ -123,6 +126,16 @@ let package = Package( ] ), + .target( + name: "SharedUIComponents", + dependencies: [ + "Highlightr", + "Splash", + "Preferences", + ] + ), + .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), + // MARK: - Services .target( diff --git a/Core/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift similarity index 100% rename from Core/Sources/SharedUIComponents/CodeBlock.swift rename to Tool/Sources/SharedUIComponents/CodeBlock.swift diff --git a/Core/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift similarity index 100% rename from Core/Sources/SharedUIComponents/CopyButton.swift rename to Tool/Sources/SharedUIComponents/CopyButton.swift diff --git a/Core/Sources/SharedUIComponents/CustomScrollView.swift b/Tool/Sources/SharedUIComponents/CustomScrollView.swift similarity index 100% rename from Core/Sources/SharedUIComponents/CustomScrollView.swift rename to Tool/Sources/SharedUIComponents/CustomScrollView.swift diff --git a/Core/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift similarity index 100% rename from Core/Sources/SharedUIComponents/CustomTextEditor.swift rename to Tool/Sources/SharedUIComponents/CustomTextEditor.swift diff --git a/Core/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift similarity index 100% rename from Core/Sources/SharedUIComponents/SyntaxHighlighting.swift rename to Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift diff --git a/Core/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift b/Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift similarity index 100% rename from Core/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift rename to Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift From 4f1911cda2a69a2c246fc706fdf574dfa318ca7c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 16:58:00 +0800 Subject: [PATCH 36/94] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index d2a1ffa9..d52ada16 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit d2a1ffa9eb887074838a694d053fb437a9c72f81 +Subproject commit d52ada16d8d6cf4da047ee9d931f39120403ce40 From 67a86cd53d2c6d6fb3904a264e380d58d3f5d1e3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 17:29:53 +0800 Subject: [PATCH 37/94] Disable tabWindow --- .../SuggestionWidget/FeatureReducers/WidgetFeature.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 95960df1..9b117e23 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -496,7 +496,7 @@ public struct WidgetFeature: ReducerProtocol { windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = noFocus ? 0 : 1 - windows.tabWindow.alphaValue = noFocus ? 0 : 1 + windows.tabWindow.alphaValue = 0 if isChatPanelDetached { windows.chatPanelWindow.alphaValue = hasChat ? 1 : 0 @@ -520,7 +520,7 @@ public struct WidgetFeature: ReducerProtocol { windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 windows.widgetWindow.alphaValue = noFocus ? 0 : 1 - windows.tabWindow.alphaValue = noFocus ? 0 : 1 + windows.tabWindow.alphaValue = 0 if isChatPanelDetached { windows.chatPanelWindow.alphaValue = hasChat ? 1 : 0 } else { From 0b3284d1aa7020d3430d7223478841f54efe30c8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 17:47:07 +0800 Subject: [PATCH 38/94] Support hiding circular widget --- Core/Sources/HostApp/GeneralView.swift | 6 ++ .../WidgetPositionStrategy.swift | 63 ++++++++++++++----- Tool/Sources/Preferences/Keys.swift | 15 +++-- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index dac8ca72..ce2ccd92 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -222,6 +222,8 @@ struct GeneralSettingsView: View { var widgetColorScheme @AppStorage(\.preferWidgetToStayInsideEditorWhenWidthGreaterThan) var preferWidgetToStayInsideEditorWhenWidthGreaterThan + @AppStorage(\.hideCircularWidget) + var hideCircularWidget } @StateObject var settings = Settings() @@ -283,6 +285,10 @@ struct GeneralSettingsView: View { Text("pt") } + + Toggle(isOn: $settings.hideCircularWidget) { + Text("Hide circular widget") + } }.padding() } } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 4604ae95..eae75bbc 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -20,6 +20,7 @@ enum UpdateLocationStrategy { mainScreen: NSScreen, activeScreen: NSScreen, editor: AXUIElement, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), preferredInsideEditorMinWidth: Double = UserDefaults.shared .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan) ) -> WidgetLocation { @@ -33,7 +34,8 @@ enum UpdateLocationStrategy { return FixedToBottom().framesForWindows( editorFrame: editorFrame, mainScreen: mainScreen, - activeScreen: activeScreen + activeScreen: activeScreen, + hideCircularWidget: hideCircularWidget ) } var frame: CGRect = .zero @@ -42,7 +44,8 @@ enum UpdateLocationStrategy { return FixedToBottom().framesForWindows( editorFrame: editorFrame, mainScreen: mainScreen, - activeScreen: activeScreen + activeScreen: activeScreen, + hideCircularWidget: hideCircularWidget ) } return HorizontalMovable().framesForWindows( @@ -51,7 +54,8 @@ enum UpdateLocationStrategy { editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, - preferredInsideEditorMinWidth: preferredInsideEditorMinWidth + preferredInsideEditorMinWidth: preferredInsideEditorMinWidth, + hideCircularWidget: hideCircularWidget ) } } @@ -61,6 +65,7 @@ enum UpdateLocationStrategy { editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), preferredInsideEditorMinWidth: Double = UserDefaults.shared .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan) ) -> WidgetLocation { @@ -70,7 +75,8 @@ enum UpdateLocationStrategy { editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, - preferredInsideEditorMinWidth: preferredInsideEditorMinWidth + preferredInsideEditorMinWidth: preferredInsideEditorMinWidth, + hideCircularWidget: hideCircularWidget ) } } @@ -82,7 +88,8 @@ enum UpdateLocationStrategy { editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, - preferredInsideEditorMinWidth: Double + preferredInsideEditorMinWidth: Double, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget) ) -> WidgetLocation { let maxY = max( y, @@ -96,12 +103,23 @@ enum UpdateLocationStrategy { .widgetPadding ) - let proposedAnchorFrameOnTheRightSide = CGRect( - x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, - y: y, - width: Style.widgetWidth, - height: Style.widgetHeight - ) + let proposedAnchorFrameOnTheRightSide = { + if hideCircularWidget { + return CGRect( + x: editorFrame.maxX, + y: y, + width: 0, + height: 0 + ) + } else { + return CGRect( + x: editorFrame.maxX - Style.widgetPadding - Style.widgetWidth, + y: y, + width: Style.widgetWidth, + height: Style.widgetHeight + ) + } + }() let proposedPanelX = proposedAnchorFrameOnTheRightSide.maxX + Style .widgetPadding * 2 @@ -139,12 +157,23 @@ enum UpdateLocationStrategy { suggestionPanelLocation: nil ) } else { - let proposedAnchorFrameOnTheLeftSide = CGRect( - x: editorFrame.minX + Style.widgetPadding, - y: proposedAnchorFrameOnTheRightSide.origin.y, - width: Style.widgetWidth, - height: Style.widgetHeight - ) + let proposedAnchorFrameOnTheLeftSide = { + if hideCircularWidget { + return CGRect( + x: editorFrame.minX, + y: proposedAnchorFrameOnTheRightSide.origin.y, + width: 0, + height: 0 + ) + } else { + return CGRect( + x: editorFrame.minX + Style.widgetPadding, + y: proposedAnchorFrameOnTheRightSide.origin.y, + width: Style.widgetWidth, + height: Style.widgetHeight + ) + } + }() let proposedPanelX = proposedAnchorFrameOnTheLeftSide.minX - Style .widgetPadding * 2 - Style.panelWidth let putAnchorToTheLeft = { diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index e48f6928..a69f81e5 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -9,7 +9,7 @@ public protocol UserDefaultPreferenceKey { public struct PreferenceKey: UserDefaultPreferenceKey { public let defaultValue: T public let key: String - + public init(defaultValue: T, key: String) { self.defaultValue = defaultValue self.key = key @@ -19,7 +19,7 @@ public struct PreferenceKey: UserDefaultPreferenceKey { public struct FeatureFlag: UserDefaultPreferenceKey { public let defaultValue: Bool public let key: String - + public init(defaultValue: Bool, key: String) { self.defaultValue = defaultValue self.key = key @@ -70,6 +70,13 @@ public struct UserDefaultPreferenceKeys { defaultValue: 1400 as Double, key: "PreferWidgetToStayInsideEditorWhenWidthGreaterThan" ) + + // MARK: Hide Circular Widget + + public let hideCircularWidget = PreferenceKey( + defaultValue: false, + key: "HideCircularWidget" + ) } // MARK: - OpenAI Account Settings @@ -170,7 +177,7 @@ public extension UserDefaultPreferenceKeys { var runNodeWith: PreferenceKey { .init(defaultValue: .env, key: "RunNodeWith") } - + var gitHubCopilotIgnoreTrailingNewLines: PreferenceKey { .init(defaultValue: false, key: "GitHubCopilotIgnoreTrailingNewLines") } @@ -246,7 +253,7 @@ public extension UserDefaultPreferenceKeys { var chatFeatureProvider: PreferenceKey { .init(defaultValue: .openAI, key: "ChatFeatureProvider") } - + var embeddingFeatureProvider: PreferenceKey { .init(defaultValue: .openAI, key: "EmbeddingFeatureProvider") } From 19e7bd0e2aa5138e73cb382aa0b937fc87baeb53 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 18:18:25 +0800 Subject: [PATCH 39/94] Update --- Core/Sources/HostApp/GeneralView.swift | 73 ++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index ce2ccd92..7ce32d54 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -6,7 +6,7 @@ import SwiftUI struct GeneralView: View { let store: StoreOf - + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -87,7 +87,7 @@ struct ExtensionServiceView: View { WithViewStore(store, observe: { $0.xpcServiceVersion }) { viewStore in Text("Extension Service Version: \(viewStore.state ?? "Loading..")") } - + WithViewStore(store, observe: { $0.isAccessibilityPermissionGranted }) { viewStore in let grantedStatus: String = { guard let granted = viewStore.state else { return "Loading.." } @@ -285,7 +285,7 @@ struct GeneralSettingsView: View { Text("pt") } - + Toggle(isOn: $settings.hideCircularWidget) { Text("Hide circular widget") } @@ -293,9 +293,76 @@ struct GeneralSettingsView: View { } } +struct WidgetPositionIcon: View { + var position: SuggestionWidgetPositionMode + var isSelected: Bool + + var body: some View { + ZStack { + Rectangle() + .fill(Color(nsColor: .textBackgroundColor)) + Rectangle() + .fill(Color.accentColor.opacity(0.2)) + .frame(width: 120, height: 20) + } + .frame(width: 120, height: 80) + } +} + +struct LargeIconPicker< + Data: RandomAccessCollection, + ID: Hashable, + Content: View, + Label: View +>: View { + @Binding var selection: Data.Element + var data: Data + var id: KeyPath + var builder: (Data.Element, _ isSelected: Bool) -> Content + var label: () -> Label + + @ViewBuilder + var content: some View { + HStack { + ForEach(data, id: id) { item in + let isSelected = selection[keyPath: id] == item[keyPath: id] + Button(action: { + selection = item + }) { + builder(item, isSelected) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.1), + style: .init(lineWidth: 2) + ) + } + }.buttonStyle(.plain) + } + } + } + + var body: some View { + if #available(macOS 13.0, *) { + LabeledContent { + content + } label: { + label() + } + } else { + VStack { + label() + content + } + } + } +} + struct GeneralView_Previews: PreviewProvider { static var previews: some View { GeneralView(store: .init(initialState: .init(), reducer: General())) + .frame(height: 800) } } From fd1e1cfd209f2ab2868defc3a067f39e75da499c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 18:25:35 +0800 Subject: [PATCH 40/94] Update --- Core/Sources/HostApp/General.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index a2d294d8..6e1aa17e 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -19,7 +19,7 @@ struct General: ReducerProtocol { case failedReloading } - @Dependency(\.toastController) var toastController + @Dependency(\.toast) var toast var body: some ReducerProtocol { Reduce { state, action in @@ -38,10 +38,7 @@ struct General: ReducerProtocol { try await LaunchAgentManager() .setupLaunchAgentForTheFirstTimeIfNeeded() } catch { - toastController.toast( - content: Text(error.localizedDescription), - type: .error - ) + toast(error.localizedDescription, .error) } } #endif @@ -60,7 +57,7 @@ struct General: ReducerProtocol { permissionGranted: isAccessibilityPermissionGranted )) } catch { - toastController.toast(content: error.localizedDescription, type: .error) + toast(error.localizedDescription, .error) await send(.failedReloading) } } From bf811c937accb38d1fc6b3ed4bb4410f8948c100 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 23:22:28 +0800 Subject: [PATCH 41/94] Support post notification to extension service --- Core/Sources/Client/AsyncXPCService.swift | 18 ++++++++++++++++++ Core/Sources/Service/XPCService.swift | 9 +++++++++ .../Sources/XPCShared/XPCServiceProtocol.swift | 2 ++ 3 files changed, 29 insertions(+) diff --git a/Core/Sources/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift index 79d9ea91..8398e464 100644 --- a/Core/Sources/Client/AsyncXPCService.swift +++ b/Core/Sources/Client/AsyncXPCService.swift @@ -134,6 +134,24 @@ public struct AsyncXPCService { { service in { service.customCommand(id: id, editorContent: $0, withReply: $1) } } ) } + + public func postNotification(name: String) async throws { + try await withXPCServiceConnected(connection: connection) { + service, continuation in + service.postNotification(name: name) { + continuation.resume(()) + } + } + } + + public func performAction(name: String, arguments: String) async throws -> String { + try await withXPCServiceConnected(connection: connection) { + service, continuation in + service.performAction(name: name, arguments: arguments) { + continuation.resume($0) + } + } + } } struct AutoFinishContinuation { diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 892498eb..13ab09cc 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -184,5 +184,14 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(nil) } } + + public func postNotification(name: String, withReply reply: @escaping () -> Void) { + reply() + NSWorkspace.shared.notificationCenter.post(name: .init(name), object: nil) + } + + public func performAction(name: String, arguments: String, withReply reply: @escaping (String) -> Void) { + reply("None") + } } diff --git a/Core/Sources/XPCShared/XPCServiceProtocol.swift b/Core/Sources/XPCShared/XPCServiceProtocol.swift index cd63347e..1c567f3b 100644 --- a/Core/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Core/Sources/XPCShared/XPCServiceProtocol.swift @@ -50,4 +50,6 @@ public protocol XPCServiceProtocol { func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) + func postNotification(name: String, withReply reply: @escaping () -> Void) + func performAction(name: String, arguments: String, withReply reply: @escaping (String) -> Void) } From edd1af25253572dbe664b4aa84c4a318cb5c7e28 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 23:23:24 +0800 Subject: [PATCH 42/94] Support plus feature flag --- Core/Package.swift | 6 ++++ .../ChatGPTChatTab/ChatGPTChatTab.swift | 1 + Core/Sources/HostApp/HostApp.swift | 29 ++++++++++++++++--- Core/Sources/HostApp/TabContainer.swift | 4 ++- .../PlusFeatureFlag/PlusFeatureFlag.swift | 27 +++++++++++++++++ .../GraphicalUserInterfaceController.swift | 3 +- .../SuggestionWidget/ChatWindowView.swift | 3 +- Pro | 2 +- Tool/Sources/ChatTab/ChatTab.swift | 15 ++++++++++ 9 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift diff --git a/Core/Package.swift b/Core/Package.swift index 61efbf75..21b5f13b 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -263,6 +263,12 @@ let package = Package( ] ), .target(name: "UserDefaultsObserver"), + .target( + name: "PlusFeatureFlag", + dependencies: [ + .product(name: "LicenseManagement", package: "Tool"), + ] + ), // MARK: - GitHub Copilot diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 1dc905fd..a13d2b6f 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -15,6 +15,7 @@ public class ChatGPTChatTab: ChatTab { struct Builder: ChatTabBuilder { var title: String + var buildable: Bool { true } var customCommand: CustomCommand? func build() -> any ChatTab { diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index bfb8337c..2ab3980d 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -1,28 +1,49 @@ -import Foundation +import Client import ComposableArchitecture +import Foundation + +#if canImport(LicenseManagement) +import LicenseManagement +#endif struct HostApp: ReducerProtocol { struct State: Equatable { var general = General.State() } - + enum Action: Equatable { case appear + case informExtensionServiceAboutLicenseKeyChange case general(General.Action) } - + + @Dependency(\.toast) var toast + var body: some ReducerProtocol { Scope(state: \.general, action: /Action.general) { General() } - + Reduce { _, action in switch action { case .appear: return .none + case .informExtensionServiceAboutLicenseKeyChange: + return .run { _ in + #if canImport(LicenseManagement) + let service = try getService() + do { + try await service + .postNotification(name: Notification.Name.licenseKeyChanged.rawValue) + } catch { + toast(error.localizedDescription, .error) + } + #endif + } case .general: return .none } } } } + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 0bb550a8..77c62a2c 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -59,7 +59,9 @@ public struct TabContainer: View { image: "command.square" ) #if canImport(ProHostApp) - PlusView().tabBarItem( + PlusView(onLicenseKeyChanged: { + store.send(.informExtensionServiceAboutLicenseKeyChange) + }).tabBarItem( tag: 5, title: "Plus", image: "plus.diamond" diff --git a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift new file mode 100644 index 00000000..10adf21e --- /dev/null +++ b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift @@ -0,0 +1,27 @@ +import Dependencies +import Foundation +import SwiftUI + +#if canImport(LicenseManagement) + +import LicenseManagement + +public func withFeatureEnabled( + _ flag: KeyPath, + then: () throws -> Void +) rethrows { + try LicenseManagement.withFeatureEnabled(flag, then: then) +} + +public func withFeatureEnabled( + _ flag: KeyPath, + then: () async throws -> Void +) async rethrows { + try await LicenseManagement.withFeatureEnabled(flag, then: then) +} + +public func isFeatureAvailable(_ flag: KeyPath) -> Bool { + LicenseManagement.isFeatureAvailable(flag) +} + +#endif diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index a32971f6..8e83ce5f 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -41,7 +41,8 @@ struct GUI: ReducerProtocol { Reduce { _, action in switch action { case let .createNewTapButtonClicked(kind): - let chatTap = kind?.builder.build() ?? ChatGPTChatTab() + guard let builder = kind?.builder, builder.buildable else { return .none } + let chatTap = builder.build() return .run { send in await send(.appendAndSelectTab(chatTap)) } diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 6bca7998..897cbbf4 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -224,7 +224,7 @@ struct ChatTabBar: View { store.send(.createNewTapButtonClicked(kind: kind)) }) { Text(kind.title) - } + }.disabled(!kind.builder.buildable) case let .folder(title, list): Menu { ForEach(0.. any ChatTab { return FakeChatTab(id: "id", title: "Title") diff --git a/Pro b/Pro index d52ada16..9b31802d 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit d52ada16d8d6cf4da047ee9d931f39120403ce40 +Subproject commit 9b31802d6bc150a2314d15dcfd1651b440ab6f0f diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 9c15a1d5..f44617ce 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -94,10 +94,24 @@ open class BaseChatTab: Equatable { public protocol ChatTabBuilder { /// A visible title for user. var title: String { get } + /// whether the chat tab is buildable. + var buildable: Bool { get } /// Build the chat tab. func build() -> any ChatTab } +public struct DisabledChatTabBuilder: ChatTabBuilder { + public var title: String + public var buildable: Bool { false } + public func build() -> any ChatTab { + EmptyChatTab(id: UUID().uuidString) + } + + public init(title: String) { + self.title = title + } +} + public protocol ChatTabType { /// The type of the external dependency required by this chat tab. associatedtype ExternalDependency @@ -127,6 +141,7 @@ public class EmptyChatTab: ChatTab { struct Builder: ChatTabBuilder { let title: String + var buildable: Bool { true } func build() -> any ChatTab { EmptyChatTab() } From 9e3a01308cb6183e7c2d8ea01f64b563f2aa06fb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 1 Aug 2023 23:39:17 +0800 Subject: [PATCH 43/94] Update --- Core/Package.swift | 6 +++-- .../CustomCommandSettings/CustomCommand.swift | 6 ++++- .../CustomCommandView.swift | 8 ++++++- Core/Sources/HostApp/HostApp.swift | 6 +++-- .../PlusFeatureFlag/PlusFeatureFlag.swift | 24 ++++++++++++++++--- TestPlan.xctestplan | 14 +++++------ 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index 21b5f13b..6d39fa3f 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -117,6 +117,7 @@ let package = Package( "GitHubCopilotService", "CodeiumService", "LaunchAgentManager", + "PlusFeatureFlag", .product(name: "Toast", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), @@ -266,8 +267,9 @@ let package = Package( .target( name: "PlusFeatureFlag", dependencies: [ - .product(name: "LicenseManagement", package: "Tool"), - ] + ].pro([ + "LicenseManagement" + ]) ), // MARK: - GitHub Copilot diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index 39d98a28..da5debd1 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -18,12 +18,16 @@ struct CustomCommandFeature: ReducerProtocol { case deleteCommand(CustomCommand) } - @Dependency(\.toastController) var toastController + @Dependency(\.toast) var toast var body: some ReducerProtocol { Reduce { state, action in switch action { case .createNewCommand: + if settings.customCommands.count >= 10 { + toast("Upgrade to Plus to add more commands", .info) + return .none + } state.editCustomCommand = EditCustomCommand.State(nil) return .none case let .editCommand(command): diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 11ec0acb..06de9c24 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import MarkdownUI +import PlusFeatureFlag import Preferences import SwiftUI @@ -72,7 +73,12 @@ struct CustomCommandView: View { Button(action: { store.send(.createNewCommand) }) { - Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") + if isFeatureAvailable(\.unlimitedCustomCommands) { + Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") + } else { + Text(Image(systemName: "plus.circle.fill")) + + Text(" New Command (\(settings.customCommands.count)/10)") + } } .buttonStyle(.plain) .padding() diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 2ab3980d..7b96456b 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -29,8 +29,8 @@ struct HostApp: ReducerProtocol { case .appear: return .none case .informExtensionServiceAboutLicenseKeyChange: + #if canImport(LicenseManagement) return .run { _ in - #if canImport(LicenseManagement) let service = try getService() do { try await service @@ -38,8 +38,10 @@ struct HostApp: ReducerProtocol { } catch { toast(error.localizedDescription, .error) } - #endif } + #else + return .none + #endif case .general: return .none } diff --git a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift index 10adf21e..dbcd579c 100644 --- a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift +++ b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift @@ -1,4 +1,3 @@ -import Dependencies import Foundation import SwiftUI @@ -6,22 +5,41 @@ import SwiftUI import LicenseManagement +#else + +public typealias PlusFeatureFlag = Int + +public struct PlusFeatureFlags { + public let browserTab = 1 + public let unlimitedCustomCommands = 1 + init() {} +} + +#endif + public func withFeatureEnabled( _ flag: KeyPath, then: () throws -> Void ) rethrows { + #if canImport(LicenseManagement) try LicenseManagement.withFeatureEnabled(flag, then: then) + #endif } public func withFeatureEnabled( _ flag: KeyPath, then: () async throws -> Void ) async rethrows { + #if canImport(LicenseManagement) try await LicenseManagement.withFeatureEnabled(flag, then: then) + #endif } public func isFeatureAvailable(_ flag: KeyPath) -> Bool { - LicenseManagement.isFeatureAvailable(flag) + #if canImport(LicenseManagement) + return LicenseManagement.isFeatureAvailable(flag) + #else + return false + #endif } -#endif diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index f79f736f..aa20be7d 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -78,13 +78,6 @@ "name" : "ChatServiceTests" } }, - { - "target" : { - "containerPath" : "container:Core", - "identifier" : "SharedUIComponentsTests", - "name" : "SharedUIComponentsTests" - } - }, { "target" : { "containerPath" : "container:Tool", @@ -105,6 +98,13 @@ "identifier" : "SuggestionModelTests", "name" : "SuggestionModelTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" + } } ], "version" : 1 From 821e28795fd753615b64b0a5dfe4c25cd3891425 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 2 Aug 2023 02:43:22 +0800 Subject: [PATCH 44/94] Make id optional, remove created. LocalAI tested. --- Tool/Sources/OpenAIService/ChatGPTService.swift | 7 ++++--- Tool/Sources/OpenAIService/CompletionAPI.swift | 3 +-- Tool/Sources/OpenAIService/CompletionStreamAPI.swift | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 953f4c72..7acc7a4b 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -227,6 +227,7 @@ extension ChatGPTService { do { let (trunks, cancel) = try await api() cancelTask = cancel + let proposedId = UUID().uuidString for try await trunk in trunks { guard let delta = trunk.choices.first?.delta else { continue } @@ -242,7 +243,7 @@ extension ChatGPTService { } await memory.streamMessage( - id: trunk.id, + id: trunk.id ?? proposedId, role: delta.role, content: delta.content, functionCall: functionCall @@ -323,7 +324,7 @@ extension ChatGPTService { guard let choice = response.choices.first else { return nil } let message = ChatMessage( - id: response.id, + id: response.id ?? UUID().uuidString, role: choice.message.role, content: choice.message.content, name: choice.message.name, @@ -425,5 +426,5 @@ func maxTokenForReply(model: String, remainingTokens: Int?) -> Int? { guard let remainingTokens else { return nil } guard let model = ChatGPTModel(rawValue: model) else { return remainingTokens } return min(model.maxToken / 2, remainingTokens) -} +} diff --git a/Tool/Sources/OpenAIService/CompletionAPI.swift b/Tool/Sources/OpenAIService/CompletionAPI.swift index d298e91e..e092411f 100644 --- a/Tool/Sources/OpenAIService/CompletionAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionAPI.swift @@ -42,9 +42,8 @@ struct CompletionResponseBody: Codable, Equatable { var total_tokens: Int } - var id: String + var id: String? var object: String - var created: Int var model: String var usage: Usage var choices: [Choice] diff --git a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift index 047b92a1..3be74435 100644 --- a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift @@ -123,9 +123,8 @@ struct CompletionRequestBody: Encodable, Equatable { } struct CompletionStreamDataTrunk: Codable { - var id: String + var id: String? var object: String - var created: Int var model: String var choices: [Choice] From 5a990575527b2baac138007387efbf19e915452a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 2 Aug 2023 14:41:56 +0800 Subject: [PATCH 45/94] Remove use shared conversation toggle --- Core/Sources/SuggestionWidget/WidgetView.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 7273eff9..aaea57e6 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -163,15 +163,6 @@ struct WidgetContextMenu: View { } } - Button(action: { - useGlobalChat.toggle() - }) { - Text("Use Shared Conversation") - if useGlobalChat { - Image(systemName: "checkmark") - } - } - Button(action: { realtimeSuggestionToggle.toggle() }) { From bfaca246bfaf51692f87a82c66877785e79205fe Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 2 Aug 2023 14:44:11 +0800 Subject: [PATCH 46/94] Update --- Core/Sources/HostApp/AccountSettings/CopilotView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/AccountSettings/CopilotView.swift b/Core/Sources/HostApp/AccountSettings/CopilotView.swift index 41ff2003..dbfafea7 100644 --- a/Core/Sources/HostApp/AccountSettings/CopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/CopilotView.swift @@ -143,7 +143,7 @@ struct CopilotView: View { VStack(alignment: .leading, spacing: 8) { Form { TextField(text: $settings.nodePath, prompt: Text("node")) { - Text("Path to Node") + Text("Path to Node (v17+)") } Picker(selection: $settings.runNodeWith) { From 45007c839ad43e1de3b7dcdf35ba83ffc170ff77 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 2 Aug 2023 14:52:20 +0800 Subject: [PATCH 47/94] Make mock PlusFeatureFlags easier to maintain --- Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift index dbcd579c..ce45eaf8 100644 --- a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift +++ b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift @@ -9,9 +9,9 @@ import LicenseManagement public typealias PlusFeatureFlag = Int +@dynamicMemberLookup public struct PlusFeatureFlags { - public let browserTab = 1 - public let unlimitedCustomCommands = 1 + public subscript(dynamicMember dynamicMember: String) -> PlusFeatureFlag { return 0 } init() {} } From efc03c1d3ff6909df3ba85cf218caf357abd2d70 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 2 Aug 2023 15:56:06 +0800 Subject: [PATCH 48/94] Use SMAppService to setup launch agent in macOS 13 --- Copilot for Xcode.xcodeproj/project.pbxproj | 15 +++ .../LaunchAgentManager.swift | 112 +++++++++++------- launchAgent.plist | 15 +++ 3 files changed, 101 insertions(+), 41 deletions(-) create mode 100644 launchAgent.plist diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 8bd799b4..50fd7406 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */; }; C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */; }; + C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8F103292A7A365000D28F4F /* launchAgent.plist */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -121,6 +122,17 @@ name = "Embed Service"; runOnlyForDeploymentPostprocessing = 0; }; + C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 12; + dstPath = Contents/Library/LaunchAgents; + dstSubfolderSpec = 1; + files = ( + C8F1032B2A7A39D700D28F4F /* launchAgent.plist in Copy Launch Agent */, + ); + name = "Copy Launch Agent"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -171,6 +183,7 @@ C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; }; C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWithSelection.swift; sourceTree = ""; }; + C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -250,6 +263,7 @@ C8520308293D805800460097 /* README.md */, C82E38492A1F025F00D4EADF /* LICENSE */, C83E5DED2A38CD8C0071506D /* Makefile */, + C8F103292A7A365000D28F4F /* launchAgent.plist */, C81E867D296FE4420026E908 /* Version.xcconfig */, C81458AD293A009600135263 /* Config.xcconfig */, C81458AE293A009800135263 /* Config.debug.xcconfig */, @@ -354,6 +368,7 @@ C814589F2939EFDC00135263 /* Embed Foundation Extensions */, C8520306293CF0EF00460097 /* Embed XPCService */, C8C8B60829AFA32800034BEE /* Embed Service */, + C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */, ); buildRules = ( ); diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index 4addc6bb..b8a89fe2 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -1,6 +1,6 @@ import Foundation +import ServiceManagement -#warning("TODO: Migrate to SMAppService") public struct LaunchAgentManager { let lastLaunchAgentVersionKey = "LastLaunchAgentVersion" let serviceIdentifier: String @@ -23,67 +23,96 @@ public struct LaunchAgentManager { } public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { - if UserDefaults.standard.integer(forKey: lastLaunchAgentVersionKey) < 40 { + if #available(macOS 13, *) { + await removeObsoleteLaunchAgent() try await setupLaunchAgent() - return + } else { + if UserDefaults.standard.integer(forKey: lastLaunchAgentVersionKey) < 40 { + try await setupLaunchAgent() + return + } + guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } + try await setupLaunchAgent() + await removeObsoleteLaunchAgent() } - guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } - try await setupLaunchAgent() - await removeObsoleteLaunchAgent() } public func setupLaunchAgent() async throws { - let content = """ - - - - - Label - \(serviceIdentifier) - Program - \(executablePath) - MachServices + if #available(macOS 13, *) { + let launchAgent = SMAppService.agent(plistName: "launchAgent.plist") + try launchAgent.register() + } else { + let content = """ + + + - \(serviceIdentifier) - - - AssociatedBundleIdentifiers - - \(bundleIdentifier) + Label \(serviceIdentifier) - - - - """ - if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { - try FileManager.default.createDirectory( - at: launchAgentDirURL, - withIntermediateDirectories: false + Program + \(executablePath) + MachServices + + \(serviceIdentifier) + + + AssociatedBundleIdentifiers + + \(bundleIdentifier) + \(serviceIdentifier) + + + + """ + if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { + try FileManager.default.createDirectory( + at: launchAgentDirURL, + withIntermediateDirectories: false + ) + } + FileManager.default.createFile( + atPath: launchAgentPath, + contents: content.data(using: .utf8) ) + try await launchctl("load", launchAgentPath) } - FileManager.default.createFile( - atPath: launchAgentPath, - contents: content.data(using: .utf8) - ) + let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) .flatMap(Int.init) UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey) - try await launchctl("load", launchAgentPath) } public func removeLaunchAgent() async throws { - try await launchctl("unload", launchAgentPath) - try FileManager.default.removeItem(atPath: launchAgentPath) + if #available(macOS 13, *) { + let launchAgent = SMAppService.agent(plistName: "launchAgent.plist") + try await launchAgent.unregister() + } else { + try await launchctl("unload", launchAgentPath) + try FileManager.default.removeItem(atPath: launchAgentPath) + } } public func reloadLaunchAgent() async throws { - try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) + if #unavailable(macOS 13) { + try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) + } } public func removeObsoleteLaunchAgent() async { - let path = launchAgentPath.replacingOccurrences(of: "ExtensionService", with: "XPCService") - if FileManager.default.fileExists(atPath: path) { - try? FileManager.default.removeItem(atPath: path) + if #available(macOS 13, *) { + let path = launchAgentPath + if FileManager.default.fileExists(atPath: path) { + try? await launchctl("unload", path) + try? FileManager.default.removeItem(atPath: path) + } + } else { + let path = launchAgentPath.replacingOccurrences( + of: "ExtensionService", + with: "XPCService" + ) + if FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) + } } } } @@ -144,3 +173,4 @@ private func launchctl(_ args: String...) async throws { struct E: Error, LocalizedError { var errorDescription: String? } + diff --git a/launchAgent.plist b/launchAgent.plist new file mode 100644 index 00000000..7b248b50 --- /dev/null +++ b/launchAgent.plist @@ -0,0 +1,15 @@ + + + + + Label + com.intii.CopilotForXcode.ExtensionService + Program + /Applications/Copilot for Xcode.app/Contents/Applications/CopilotForXcodeExtensionService.app/Contents/MacOS/CopilotForXcodeExtensionService + MachServices + + com.intii.CopilotForXcode.ExtensionService + + + + From 41b178654472349e1cf4d1c0d1bc724234685fe4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 2 Aug 2023 16:00:33 +0800 Subject: [PATCH 49/94] Update DEVELOPMENT.md --- DEVELOPMENT.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e0bb7eb7..653c3511 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,9 +14,9 @@ As its name suggests, the editor extension. Its sole purpose is to forward edito The `ExtensionService` is a program that operates in the background and performs a wide range of tasks. It redirects requests from the `EditorExtension` to the `CopilotService` and returns the updated code back to the extension, or presents it in a GUI outside of Xcode. -### Core +### Core and Tool -Most of the logics are implemented inside the package `Core`. +Most of the logics are implemented inside the package `Core` and `Tool`. - The `CopilotService` is responsible for communicating with the GitHub Copilot LSP. - The `Service` is responsible for handling the requests from the `EditorExtension`, communicating with the `CopilotService`, update the code blocks and present the GUI. @@ -27,7 +27,8 @@ Most of the logics are implemented inside the package `Core`. ## Building and Archiving the App -1. Build or archive the Copilot for Xcode target. +1. Update the xcconfig files, launchAgent.plist, and Tool/Configs/Configurations.swift. +2. Build or archive the Copilot for Xcode target. ## Testing Extension From 384d44c0842b440ec98f762a721ae521e0064cc9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 2 Aug 2023 19:15:20 +0800 Subject: [PATCH 50/94] Fix creating default tab --- Core/Sources/Service/GUI/ChatTabFactory.swift | 119 +++++++++++++++++ .../GraphicalUserInterfaceController.swift | 122 +----------------- 2 files changed, 126 insertions(+), 115 deletions(-) create mode 100644 Core/Sources/Service/GUI/ChatTabFactory.swift diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift new file mode 100644 index 00000000..6804c389 --- /dev/null +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -0,0 +1,119 @@ +import ChatGPTChatTab +import ChatService +import ChatTab +import Foundation +import PromptToCodeService +import SuggestionModel +import SuggestionWidget +import XcodeInspector + +#if canImport(ProChatTabs) +import ProChatTabs + +enum ChatTabFactory { + static var chatTabBuilderCollection: [ChatTabBuilderCollection] { + func folderIfNeeded( + _ builders: [any ChatTabBuilder], + title: String + ) -> ChatTabBuilderCollection? { + if builders.count > 1 { + return .folder(title: title, kinds: builders.map(ChatTabKind.init)) + } + if let first = builders.first { return .kind(ChatTabKind(first)) } + return nil + } + + let collection = [ + folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), + folderIfNeeded(BrowserChatTab.chatBuilders(externalDependency: .init( + getEditorContent: { + guard let editor = XcodeInspector.shared.focusedEditor else { + return .init(selectedText: "", language: "", fileContent: "") + } + let content = editor.content + return .init( + selectedText: content.selectedContent, + language: languageIdentifierFromFileURL( + XcodeInspector.shared + .activeDocumentURL + ) + .rawValue, + fileContent: content.content + ) + }, + handleCustomCommand: { command, prompt in + switch command.feature { + case let .chatWithSelection(extraSystemPrompt, _, useExtraSystemPrompt): + let service = ChatService() + return try await service.processMessage( + systemPrompt: nil, + extraSystemPrompt: (useExtraSystemPrompt ?? false) ? extraSystemPrompt : + nil, + prompt: prompt + ) + case let .customChat(systemPrompt, _): + let service = ChatService() + return try await service.processMessage( + systemPrompt: systemPrompt, + extraSystemPrompt: nil, + prompt: prompt + ) + case let .singleRoundDialog( + systemPrompt, + overwriteSystemPrompt, + _, + _ + ): + let service = ChatService() + return try await service.handleSingleRoundDialogCommand( + systemPrompt: systemPrompt, + overwriteSystemPrompt: overwriteSystemPrompt ?? false, + prompt: prompt + ) + case let .promptToCode(extraSystemPrompt, instruction, _, _): + let service = PromptToCodeService( + code: prompt, + selectionRange: .outOfScope, + language: .plaintext, + identSize: 4, + usesTabsForIndentation: true, + projectRootURL: .init(fileURLWithPath: "/"), + fileURL: .init(fileURLWithPath: "/"), + allCode: prompt, + extraSystemPrompt: extraSystemPrompt, + generateDescriptionRequirement: false + ) + try await service.modifyCode(prompt: instruction ?? "Modify content.") + return service.code + } + } + )), title: BrowserChatTab.name), + ].compactMap { $0 } + + return collection + } +} + +#else + +enum ChatTabFactory { + static var chatTabBuilderCollection: [ChatTabBuilderCollection] { + func folderIfNeeded( + _ builders: [any ChatTabBuilder], + title: String + ) -> ChatTabBuilderCollection? { + if builders.count > 1 { + return .folder(title: title, kinds: builders.map(ChatTabKind.init)) + } + if let first = builders.first { return .kind(ChatTabKind(first)) } + return nil + } + + return [ + folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), + ].compactMap { $0 } + } +} + +#endif + diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 8e83ce5f..bd4ea831 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -1,15 +1,11 @@ import AppKit import ChatGPTChatTab -import ChatService import ChatTab import ComposableArchitecture import Dependencies import Environment import Preferences -import PromptToCodeService -import SuggestionModel import SuggestionWidget -import XcodeInspector struct GUI: ReducerProtocol { struct State: Equatable { @@ -41,7 +37,13 @@ struct GUI: ReducerProtocol { Reduce { _, action in switch action { case let .createNewTapButtonClicked(kind): - guard let builder = kind?.builder, builder.buildable else { return .none } + guard let builder = kind?.builder else { + let chatTap = ChatGPTChatTab() + return .run { send in + await send(.appendAndSelectTab(chatTap)) + } + } + guard builder.buildable else { return .none } let chatTap = builder.build() return .run { send in await send(.appendAndSelectTab(chatTap)) @@ -166,113 +168,3 @@ public final class GraphicalUserInterfaceController { } } -#if canImport(ProChatTabs) -import ProChatTabs - -enum ChatTabFactory { - static var chatTabBuilderCollection: [ChatTabBuilderCollection] { - func folderIfNeeded( - _ builders: [any ChatTabBuilder], - title: String - ) -> ChatTabBuilderCollection? { - if builders.count > 1 { - return .folder(title: title, kinds: builders.map(ChatTabKind.init)) - } - if let first = builders.first { return .kind(ChatTabKind(first)) } - return nil - } - - let collection = [ - folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), - folderIfNeeded(BrowserChatTab.chatBuilders(externalDependency: .init( - getEditorContent: { - guard let editor = XcodeInspector.shared.focusedEditor else { - return .init(selectedText: "", language: "", fileContent: "") - } - let content = editor.content - return .init( - selectedText: content.selectedContent, - language: languageIdentifierFromFileURL( - XcodeInspector.shared - .activeDocumentURL - ) - .rawValue, - fileContent: content.content - ) - }, - handleCustomCommand: { command, prompt in - switch command.feature { - case let .chatWithSelection(extraSystemPrompt, _, useExtraSystemPrompt): - let service = ChatService() - return try await service.processMessage( - systemPrompt: nil, - extraSystemPrompt: (useExtraSystemPrompt ?? false) ? extraSystemPrompt : - nil, - prompt: prompt - ) - case let .customChat(systemPrompt, _): - let service = ChatService() - return try await service.processMessage( - systemPrompt: systemPrompt, - extraSystemPrompt: nil, - prompt: prompt - ) - case let .singleRoundDialog( - systemPrompt, - overwriteSystemPrompt, - _, - _ - ): - let service = ChatService() - return try await service.handleSingleRoundDialogCommand( - systemPrompt: systemPrompt, - overwriteSystemPrompt: overwriteSystemPrompt ?? false, - prompt: prompt - ) - case let .promptToCode(extraSystemPrompt, instruction, _, _): - let service = PromptToCodeService( - code: prompt, - selectionRange: .outOfScope, - language: .plaintext, - identSize: 4, - usesTabsForIndentation: true, - projectRootURL: .init(fileURLWithPath: "/"), - fileURL: .init(fileURLWithPath: "/"), - allCode: prompt, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: false - ) - try await service.modifyCode(prompt: instruction ?? "Modify content.") - return service.code - } - } - )), title: BrowserChatTab.name), - ].compactMap { $0 } - - return collection - } -} - -#else - -enum ChatTabFactory { - static var chatTabBuilderCollection: [ChatTabBuilderCollection] { - func folderIfNeeded( - _ builders: [any ChatTabBuilder], - title: String - ) -> ChatTabBuilderCollection? { - if builders.count > 1 { - return .folder(title: title, kinds: builders.map(ChatTabKind.init)) - } - if let first = builders.first { return .kind(ChatTabKind(first)) } - return nil - } - - return [ - folderIfNeeded(ChatGPTChatTab.chatBuilders(), title: ChatGPTChatTab.name), - ].compactMap { $0 } - } -} - -#endif - From 4fba6f372984c7ca81402dc1b876ae809031ff38 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 3 Aug 2023 00:01:47 +0800 Subject: [PATCH 51/94] Use RelevantInformationExtractionChain to replace RefineDocumentChain --- .../Contents.swift | 33 ++- .../timeline.xctimeline | 6 + Tool/Sources/LangChain/Agent.swift | 12 ++ Tool/Sources/LangChain/Callback.swift | 34 ++- Tool/Sources/LangChain/Chain.swift | 2 +- .../Chains/RefineDocumentChain.swift | 200 ++++++++++++++++++ .../RelevantInformationExtractionChain.swift | 140 ++++++++++++ .../LangChain/Chains/RetrievalQA.swift | 196 +---------------- .../LangChain/ChatModel/ChatModel.swift | 4 + 9 files changed, 412 insertions(+), 215 deletions(-) create mode 100644 Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/timeline.xctimeline create mode 100644 Tool/Sources/LangChain/Chains/RefineDocumentChain.swift create mode 100644 Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift index 7ebeb9b5..482ae5eb 100644 --- a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift @@ -4,12 +4,14 @@ import LangChain import OpenAIService import PlaygroundSupport import SwiftUI +import TokenEncoder struct QAForm: View { - @State var intermediateAnswers = [RefineDocumentChain.IntermediateAnswer]() + @State var relevantInformation = [String]() @State var relevantDocuments = [(document: Document, distance: Float)]() @State var duration: TimeInterval = 0 @State var answer: String = "" + @State var tokenCount: Int = 0 @State var question: String = "What is Swift macros?" @State var isProcessing: Bool = false @State var url: String = "https://developer.apple.com/documentation/swift/applying-macros" @@ -36,23 +38,14 @@ struct QAForm: View { Text("\(duration) seconds") } } - Section(header: Text("Answer")) { + Section(header: Text("All Relevant Information (\(tokenCount) words)")) { Text(answer) } - Section(header: Text("Intermediate Answers")) { - ForEach(0.. + + + + diff --git a/Tool/Sources/LangChain/Agent.swift b/Tool/Sources/LangChain/Agent.swift index e469b33d..daf954a7 100644 --- a/Tool/Sources/LangChain/Agent.swift +++ b/Tool/Sources/LangChain/Agent.swift @@ -24,14 +24,26 @@ public extension CallbackEvents { struct AgentDidFinish: CallbackEvent { public let info: AgentFinish } + + var agentDidFinish: AgentDidFinish.Type { + AgentDidFinish.self + } struct AgentActionDidStart: CallbackEvent { public let info: AgentAction } + + var agentActionDidStart: AgentActionDidStart.Type { + AgentActionDidStart.self + } struct AgentActionDidEnd: CallbackEvent { public let info: AgentAction } + + var agentActionDidEnd: AgentActionDidEnd.Type { + AgentActionDidEnd.self + } } public struct AgentFinish: Equatable { diff --git a/Tool/Sources/LangChain/Callback.swift b/Tool/Sources/LangChain/Callback.swift index c6550e77..3c0a6561 100644 --- a/Tool/Sources/LangChain/Callback.swift +++ b/Tool/Sources/LangChain/Callback.swift @@ -5,7 +5,9 @@ public protocol CallbackEvent { var info: Info { get } } -public enum CallbackEvents {} +public struct CallbackEvents { + private init() {} +} public struct CallbackManager { fileprivate var observers = [Any]() @@ -25,19 +27,39 @@ public struct CallbackManager { observers.append(handler) } + public mutating func on( + _: KeyPath, + _ handler: @escaping (Event.Info) -> Void + ) { + observers.append(handler) + } + public func send(_ event: Event) { for case let observer as ((Event.Info) -> Void) in observers { observer(event.info) } } + + func send( + _: KeyPath, + _ info: Event.Info + ) { + for case let observer as ((Event.Info) -> Void) in observers { + observer(info) + } + } } public extension [CallbackManager] { func send(_ event: Event) { - for cb in self { - for case let observer as ((Event.Info) -> Void) in cb.observers { - observer(event.info) - } - } + for cb in self { cb.send(event) } + } + + func send( + _ keyPath: KeyPath, + _ info: Event.Info + ) { + for cb in self { cb.send(keyPath, info) } } } + diff --git a/Tool/Sources/LangChain/Chain.swift b/Tool/Sources/LangChain/Chain.swift index 6ff4cd8f..9533bcfd 100644 --- a/Tool/Sources/LangChain/Chain.swift +++ b/Tool/Sources/LangChain/Chain.swift @@ -10,7 +10,7 @@ public protocol Chain { public extension Chain { typealias ChainDidStart = CallbackEvents.ChainDidStart typealias ChainDidEnd = CallbackEvents.ChainDidEnd - + func run(_ input: Input, callbackManagers: [CallbackManager] = []) async throws -> String { let output = try await call(input, callbackManagers: callbackManagers) return parseOutput(output) diff --git a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift new file mode 100644 index 00000000..4af4b73e --- /dev/null +++ b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift @@ -0,0 +1,200 @@ +import Foundation +import OpenAIService + +public final class RefineDocumentChain: Chain { + public struct Input { + var question: String + var documents: [(document: Document, distance: Float)] + } + + struct RefinementInput { + var index: Int + var totalCount: Int + var question: String + var previousAnswer: String? + var document: String + var distance: Float + } + + public struct IntermediateAnswer: Decodable { + public var answer: String + public var usefulness: Double + public var more: Bool + + public enum CodingKeys: String, CodingKey { + case answer + case usefulness + case more + } + + init(answer: String, usefulness: Double, more: Bool) { + self.answer = answer + self.usefulness = usefulness + self.more = more + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + answer = try container.decode(String.self, forKey: .answer) + usefulness = (try? container.decode(Double.self, forKey: .usefulness)) ?? 0 + more = (try? container.decode(Bool.self, forKey: .more)) ?? true + } + } + + class FunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: FunctionCallStrategy? = .name("respond") + var functions: [any ChatGPTFunction] = [RespondFunction()] + } + + struct RespondFunction: ChatGPTFunction { + typealias Arguments = IntermediateAnswer + + struct Result: ChatGPTFunctionResult { + var botReadableContent: String { "" } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String = "respond" + var description: String = "Respond with the refined answer" + var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: [ + "answer": [ + .type: "string", + .description: "The refined answer", + ], + "usefulness": [ + .type: "number", + .description: "How useful the page of document is in generating the answer, the higher the better. 0 to 10", + ], + "more": [ + .type: "boolean", + .description: "Whether you want to read the next page. The next page maybe less relevant to the question", + ], + ], + .required: ["answer", "more", "usefulness"], + ] + } + + func prepare() async {} + + func call(arguments: Arguments) async throws -> Result { + return Result() + } + } + + func buildChatModel() -> ChatModelChain { + .init( + chatModel: OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.temperature = 0 + $0.runFunctionsAutomatically = false + }, + memory: EmptyChatGPTMemory(), + functionProvider: FunctionProvider(), + stream: false + ), + promptTemplate: { input in [ + .init( + role: .system, + content: { + if let previousAnswer = input.previousAnswer { + return """ + The user will send you a question about a document, you must refine your previous answer to it only according to the document. + Previous answer:### + \(previousAnswer) + ### + Page \(input.index) of \(input.totalCount) of the document:### + \(input.document) + ### + """ + } else { + return """ + The user will send you a question about a document, you must answer it only according to the document. + Page \(input.index) of \(input.totalCount) of the document:### + \(input.document) + ### + """ + } + }() + + ), + .init(role: .user, content: input.question), + ] } + ) + } + + public init() {} + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> String { + var intermediateAnswer: IntermediateAnswer? + + for (index, document) in input.documents.enumerated() { + if let intermediateAnswer, !intermediateAnswer.more { break } + + let output = try await buildChatModel().call( + .init( + index: index, + totalCount: input.documents.count, + question: input.question, + previousAnswer: intermediateAnswer?.answer, + document: document.document.pageContent, + distance: document.distance + ), + callbackManagers: callbackManagers + ) + intermediateAnswer = extractAnswer(output) + + if let intermediateAnswer { + callbackManagers.send( + \.refineDocumentChainDidGenerateIntermediateAnswer, + intermediateAnswer + ) + } + } + + return intermediateAnswer?.answer ?? "None" + } + + public func parseOutput(_ output: String) -> String { + return output + } + + func extractAnswer(_ chatMessage: ChatMessage) -> IntermediateAnswer { + if let functionCall = chatMessage.functionCall { + do { + let intermediateAnswer = try JSONDecoder().decode( + IntermediateAnswer.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + return intermediateAnswer + } catch { + let intermediateAnswer = IntermediateAnswer( + answer: functionCall.arguments, + usefulness: 0, + more: true + ) + return intermediateAnswer + } + } + return .init(answer: chatMessage.content ?? "", usefulness: 0, more: true) + } +} + +public extension CallbackEvents { + struct RefineDocumentChainDidGenerateIntermediateAnswer: CallbackEvent { + public let info: RefineDocumentChain.IntermediateAnswer + } + + var refineDocumentChainDidGenerateIntermediateAnswer: + RefineDocumentChainDidGenerateIntermediateAnswer.Type + { + RefineDocumentChainDidGenerateIntermediateAnswer.self + } +} + diff --git a/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift new file mode 100644 index 00000000..47bd7bbd --- /dev/null +++ b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift @@ -0,0 +1,140 @@ +import Foundation +import OpenAIService + +public final class RelevantInformationExtractionChain: Chain { + public struct Input { + var question: String + var documents: [(document: Document, distance: Float)] + } + + struct TaskInput { + var question: String + var document: Document + } + + public typealias Output = String + + class FunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: FunctionCallStrategy? = .auto + var functions: [any ChatGPTFunction] = [NoneFunction()] + } + + struct NoneFunction: ChatGPTFunction { + struct Arguments: Decodable {} + + struct Result: ChatGPTFunctionResult { + var botReadableContent: String { "" } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String = "noInformationFound" + var description: String = "Call when you can't find any relevant information from the document, or the question was not mentioned in the document" + var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: .hash([:]) + ] + } + + func prepare() async {} + + func call(arguments: Arguments) async throws -> Result { + return Result() + } + } + + func buildChatModel() -> ChatModelChain { + .init( + chatModel: OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration().overriding { + $0.temperature = 0 + $0.runFunctionsAutomatically = false + }, + memory: EmptyChatGPTMemory(), + functionProvider: FunctionProvider(), + stream: false + ) + ) { input in [ + .init( + role: .system, + content: """ + Extract the relevant information from the Document according to the Question. + Make the information clear, concise and short. + If found code, wrap it in markdown code block. + """ + ), + .init( + role: .user, + content: """ + Question:### + \(input.question) + ### + Document:### + \(input.document) + ### + """ + ), + ] } + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> Output { + await withTaskGroup(of: String.self) { group in + for document in input.documents { + let taskInput = TaskInput(question: input.question, document: document.document) + group.addTask { + func run() async throws -> String { + let model = self.buildChatModel() + let output = try await model.call( + taskInput, + callbackManagers: callbackManagers + ) + return output.content ?? "" + } + + var repeatCount = 0 + while repeatCount < 3 { + do { + return try await run() + } catch { + repeatCount += 1 + } + } + return "" + } + } + + var results = [String]() + for await output in group where !output.isEmpty { + callbackManagers.send( + \.relevantInformationExtractionChainDidExtractPartialRelevantContent, + output + ) + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + if results.contains(trimmed) { continue } + results.append(trimmed) + } + if results.isEmpty { return "No information found." } + return results.joined(separator: "") + } + } + + public func parseOutput(_ output: Output) -> String { + return output + } +} + +public extension CallbackEvents { + struct RelevantInformationExtractionChainDidExtractPartialRelevantContent: CallbackEvent { + public let info: String + } + + var relevantInformationExtractionChainDidExtractPartialRelevantContent: + RelevantInformationExtractionChainDidExtractPartialRelevantContent.Type + { + RelevantInformationExtractionChainDidExtractPartialRelevantContent.self + } +} diff --git a/Tool/Sources/LangChain/Chains/RetrievalQA.swift b/Tool/Sources/LangChain/Chains/RetrievalQA.swift index 5ef1af16..dabf0142 100644 --- a/Tool/Sources/LangChain/Chains/RetrievalQA.swift +++ b/Tool/Sources/LangChain/Chains/RetrievalQA.swift @@ -30,13 +30,13 @@ public final class RetrievalQAChain: Chain { callbackManagers.send(CallbackEvents.RetrievalQADidExtractRelevantContent(info: documents)) - let refinementChain = RefineDocumentChain() - let answer = try await refinementChain.run( + let relevantInformationChain = RelevantInformationExtractionChain() + let relevantInformation = try await relevantInformationChain.run( .init(question: input, documents: documents), callbackManagers: callbackManagers ) - return .init(answer: answer, sourceDocuments: documents.map(\.document)) + return .init(answer: relevantInformation, sourceDocuments: documents.map(\.document)) } public func parseOutput(_ output: Output) -> String { @@ -45,197 +45,15 @@ public final class RetrievalQAChain: Chain { } public extension CallbackEvents { - struct RetrievalQADidGenerateIntermediateAnswer: CallbackEvent { - public let info: RefineDocumentChain.IntermediateAnswer - } - struct RetrievalQADidExtractRelevantContent: CallbackEvent { public let info: [(document: Document, distance: Float)] } -} - -public final class RefineDocumentChain: Chain { - public struct Input { - var question: String - var documents: [(document: Document, distance: Float)] - } - - struct RefinementInput { - var index: Int - var totalCount: Int - var question: String - var previousAnswer: String? - var document: String - var distance: Float - } - - public struct IntermediateAnswer: Decodable { - public var answer: String - public var usefulness: Double - public var more: Bool - - public enum CodingKeys: String, CodingKey { - case answer - case usefulness - case more - } - - init(answer: String, usefulness: Double, more: Bool) { - self.answer = answer - self.usefulness = usefulness - self.more = more - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - answer = try container.decode(String.self, forKey: .answer) - usefulness = (try? container.decode(Double.self, forKey: .usefulness)) ?? 0 - more = (try? container.decode(Bool.self, forKey: .more)) ?? true - } - } - - class FunctionProvider: ChatGPTFunctionProvider { - var functionCallStrategy: FunctionCallStrategy? = .name("respond") - var functions: [any ChatGPTFunction] = [RespondFunction()] - } - - struct RespondFunction: ChatGPTFunction { - typealias Arguments = IntermediateAnswer - - struct Result: ChatGPTFunctionResult { - var botReadableContent: String { "" } - } - - var reportProgress: (String) async -> Void = { _ in } - - var name: String = "respond" - var description: String = "Respond with the refined answer" - var argumentSchema: JSONSchemaValue { - return [ - .type: "object", - .properties: [ - "answer": [ - .type: "string", - .description: "The refined answer", - ], - "usefulness": [ - .type: "number", - .description: "How useful the page of document is in generating the answer, the higher the better. 0 to 10", - ], - "more": [ - .type: "boolean", - .description: "Whether you want to read the next page. The next page maybe less relevant to the question", - ], - ], - .required: ["answer", "more", "usefulness"], - ] - } - - func prepare() async {} - - func call(arguments: Arguments) async throws -> Result { - return Result() - } - } - - func buildChatModel() -> ChatModelChain { - .init( - chatModel: OpenAIChat( - configuration: UserPreferenceChatGPTConfiguration().overriding { - $0.temperature = 0 - $0.runFunctionsAutomatically = false - }, - memory: EmptyChatGPTMemory(), - functionProvider: FunctionProvider(), - stream: false - ), - promptTemplate: { input in [ - .init( - role: .system, - content: { - if let previousAnswer = input.previousAnswer { - return """ - The user will send you a question about a document, you must refine your previous answer to it only according to the document. - Previous answer:### - \(previousAnswer) - ### - Page \(input.index) of \(input.totalCount) of the document:### - \(input.document) - ### - """ - } else { - return """ - The user will send you a question about a document, you must answer it only according to the document. - Page \(input.index) of \(input.totalCount) of the document:### - \(input.document) - ### - """ - } - }() - - ), - .init(role: .user, content: input.question), - ] } - ) + + var retrievalQADidExtractRelevantContent: RetrievalQADidExtractRelevantContent.Type { + RetrievalQADidExtractRelevantContent.self } +} - public init() {} - - public func callLogic( - _ input: Input, - callbackManagers: [CallbackManager] - ) async throws -> String { - var intermediateAnswer: IntermediateAnswer? - - for (index, document) in input.documents.enumerated() { - if let intermediateAnswer, !intermediateAnswer.more { break } - - let output = try await buildChatModel().call( - .init( - index: index, - totalCount: input.documents.count, - question: input.question, - previousAnswer: intermediateAnswer?.answer, - document: document.document.pageContent, - distance: document.distance - ), - callbackManagers: callbackManagers - ) - intermediateAnswer = extractAnswer(output) - - if let intermediateAnswer { - callbackManagers.send( - CallbackEvents - .RetrievalQADidGenerateIntermediateAnswer(info: intermediateAnswer) - ) - } - } - return intermediateAnswer?.answer ?? "None" - } - public func parseOutput(_ output: String) -> String { - return output - } - - func extractAnswer(_ chatMessage: ChatMessage) -> IntermediateAnswer { - if let functionCall = chatMessage.functionCall { - do { - let intermediateAnswer = try JSONDecoder().decode( - IntermediateAnswer.self, - from: functionCall.arguments.data(using: .utf8) ?? Data() - ) - return intermediateAnswer - } catch { - let intermediateAnswer = IntermediateAnswer( - answer: functionCall.arguments, - usefulness: 0, - more: true - ) - return intermediateAnswer - } - } - return .init(answer: chatMessage.content ?? "", usefulness: 0, more: true) - } -} diff --git a/Tool/Sources/LangChain/ChatModel/ChatModel.swift b/Tool/Sources/LangChain/ChatModel/ChatModel.swift index 8d85b6ee..75ba0233 100644 --- a/Tool/Sources/LangChain/ChatModel/ChatModel.swift +++ b/Tool/Sources/LangChain/ChatModel/ChatModel.swift @@ -15,4 +15,8 @@ public extension CallbackEvents { struct LLMDidProduceNewToken: CallbackEvent { public let info: String } + + var llmDidProduceNewToken: LLMDidProduceNewToken.Type { + LLMDidProduceNewToken.self + } } From 3881fafd5e29d60cddad4ee82e34ebe569086375 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 3 Aug 2023 00:05:41 +0800 Subject: [PATCH 52/94] Rename to QAInformationRetrievalChain --- .../QueryWebsiteFunction.swift | 8 ++++---- .../Contents.swift | 2 +- Tool/Sources/LangChain/Chains/RetrievalQA.swift | 17 ++++++++--------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift index 3aadef8d..50f0db01 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -64,8 +64,8 @@ struct QueryWebsiteFunction: ChatGPTFunction { if let database = await TemporaryUSearch.view(identifier: urlString) { await reportProgress("Generating answers..") - let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) - return try await qa.call(.init(arguments.query)).answer + let qa = QAInformationRetrievalChain(vectorStore: database, embedding: embedding) + return try await qa.call(.init(arguments.query)).information } let loader = WebLoader(urls: [url]) let documents = try await loader.load() @@ -83,9 +83,9 @@ struct QueryWebsiteFunction: ChatGPTFunction { try await database.set(embeddedDocuments) // 4. generate answer await reportProgress("Generating answers..") - let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) + let qa = QAInformationRetrievalChain(vectorStore: database, embedding: embedding) let result = try await qa.call(.init(arguments.query)) - return result.answer + return result.information } } diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift index 482ae5eb..55c4465c 100644 --- a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift @@ -106,7 +106,7 @@ struct QAForm: View { } }() - let qa = RetrievalQAChain( + let qa = QAInformationRetrievalChain( vectorStore: store, embedding: embedding ) diff --git a/Tool/Sources/LangChain/Chains/RetrievalQA.swift b/Tool/Sources/LangChain/Chains/RetrievalQA.swift index dabf0142..9cdcbd4b 100644 --- a/Tool/Sources/LangChain/Chains/RetrievalQA.swift +++ b/Tool/Sources/LangChain/Chains/RetrievalQA.swift @@ -1,12 +1,12 @@ import Foundation import OpenAIService -public final class RetrievalQAChain: Chain { +public final class QAInformationRetrievalChain: Chain { let vectorStore: VectorStore let embedding: Embeddings public struct Output { - public var answer: String + public var information: String public var sourceDocuments: [Document] } @@ -26,7 +26,9 @@ public final class RetrievalQAChain: Chain { let documents = try await vectorStore.searchWithDistance( embeddings: embeddedQuestion, count: 5 - ) + ).filter { item in + item.distance < 0.31 + } callbackManagers.send(CallbackEvents.RetrievalQADidExtractRelevantContent(info: documents)) @@ -36,11 +38,11 @@ public final class RetrievalQAChain: Chain { callbackManagers: callbackManagers ) - return .init(answer: relevantInformation, sourceDocuments: documents.map(\.document)) + return .init(information: relevantInformation, sourceDocuments: documents.map(\.document)) } public func parseOutput(_ output: Output) -> String { - return output.answer + return output.information } } @@ -48,12 +50,9 @@ public extension CallbackEvents { struct RetrievalQADidExtractRelevantContent: CallbackEvent { public let info: [(document: Document, distance: Float)] } - + var retrievalQADidExtractRelevantContent: RetrievalQADidExtractRelevantContent.Type { RetrievalQADidExtractRelevantContent.self } } - - - From a060715525eadcc079799106ac682a2613035f51 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 3 Aug 2023 16:33:15 +0800 Subject: [PATCH 53/94] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 9b31802d..e7fd25ba 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 9b31802d6bc150a2314d15dcfd1651b440ab6f0f +Subproject commit e7fd25baa1b1220d353d3b392de3b6445ac54728 From 7c400bb532620e1edb442206aa3890f43cc05edf Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 3 Aug 2023 21:28:02 +0800 Subject: [PATCH 54/94] Move Keychain to its own package, remove KeychainAccess --- .../xcshareddata/swiftpm/Package.resolved | 9 -- Core/Package.swift | 3 +- .../CodeiumService/CodeiumAuthService.swift | 14 +-- .../ServiceUpdateMigrator.swift | 18 ---- Pro | 2 +- Tool/Package.swift | 6 ++ Tool/Sources/Keychain/Keychain.swift | 89 +++++++++++++++++++ 7 files changed, 101 insertions(+), 40 deletions(-) create mode 100644 Tool/Sources/Keychain/Keychain.swift diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ab5e470..205cff91 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,15 +45,6 @@ "version" : "0.6.0" } }, - { - "identity" : "keychainaccess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kishikawakatsumi/KeychainAccess", - "state" : { - "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", - "version" : "4.2.2" - } - }, { "identity" : "languageclient", "kind" : "remoteSourceControl", diff --git a/Core/Package.swift b/Core/Package.swift index 6d39fa3f..0867eb77 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -45,7 +45,6 @@ let package = Package( .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), - .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.2"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.5.1"), .package( @@ -297,7 +296,7 @@ let package = Package( name: "CodeiumService", dependencies: [ "LanguageClient", - "KeychainAccess", + .product(name: "Keychain", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "Preferences", package: "Tool"), diff --git a/Core/Sources/CodeiumService/CodeiumAuthService.swift b/Core/Sources/CodeiumService/CodeiumAuthService.swift index dbb33903..0d2f3765 100644 --- a/Core/Sources/CodeiumService/CodeiumAuthService.swift +++ b/Core/Sources/CodeiumService/CodeiumAuthService.swift @@ -1,25 +1,19 @@ import Configs import Foundation -import KeychainAccess +import Keychain public final class CodeiumAuthService { public init() {} let codeiumKeyKey = "codeiumAuthKey" - let keychain: Keychain = { - let info = Bundle.main.infoDictionary - return Keychain(service: keychainService, accessGroup: keychainAccessGroup) - .attributes([ - kSecUseDataProtectionKeychain as String: true, - ]) - }() + let keychain = Keychain() - var key: String? { try? keychain.getString(codeiumKeyKey) } + var key: String? { try? keychain.get(codeiumKeyKey) } public var isSignedIn: Bool { return key != nil } public func signIn(token: String) async throws { let key = try await generate(token: token) - try keychain.set(key, key: codeiumKeyKey) + try keychain.update(key, key: codeiumKeyKey) } public func signOut() async throws { diff --git a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift index 0908730f..b47a4feb 100644 --- a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift +++ b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift @@ -1,7 +1,6 @@ import Configs import Foundation import GitHubCopilotService -import KeychainAccess import Preferences extension UserDefaultPreferenceKeys { @@ -28,10 +27,6 @@ public struct ServiceUpdateMigrator { if old <= 135 { try migrateFromLowerThanOrEqualToVersion135() } - - if old < 170 { - try migrateFromLowerThanOrEqualToVersion170() - } } } @@ -79,16 +74,3 @@ func migrateFromLowerThanOrEqualToVersion135() throws { ) } -func migrateFromLowerThanOrEqualToVersion170() throws { - let oldKeychain = Keychain(service: keychainService, accessGroup: keychainAccessGroup) - let newKeychain = oldKeychain.attributes([ - kSecUseDataProtectionKeychain as String: true, - ]) - - if (try? oldKeychain.contains("codeiumKey")) ?? false, - let key = try? oldKeychain.getString("codeiumKey") - { - try newKeychain.set(key, key: "codeiumAuthKey") - } -} - diff --git a/Pro b/Pro index e7fd25ba..751d7c60 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit e7fd25baa1b1220d353d3b392de3b6445ac54728 +Subproject commit 751d7c60cde0e068bd3fcdb3b7d2ee98527ef268 diff --git a/Tool/Package.swift b/Tool/Package.swift index 8002b3e5..4724b3e1 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), .library(name: "Toast", targets: ["Toast"]), + .library(name: "Keychain", targets: ["Keychain"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), .library( name: "AppMonitoring", @@ -57,6 +58,11 @@ let package = Package( .target(name: "ObjectiveCExceptionHandling"), + .target( + name: "Keychain", + dependencies: ["Configs"] + ), + .target( name: "Toast", dependencies: [.product( diff --git a/Tool/Sources/Keychain/Keychain.swift b/Tool/Sources/Keychain/Keychain.swift new file mode 100644 index 00000000..82b249dc --- /dev/null +++ b/Tool/Sources/Keychain/Keychain.swift @@ -0,0 +1,89 @@ +import Configs +import Foundation +import Security + +public struct Keychain { + let service = keychainService + let accessGroup = keychainAccessGroup + + public enum Error: Swift.Error { + case failedToDeleteFromKeyChain + case failedToUpdateOrSetItem + } + + public init() {} + + func query(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrAccount as String: key, + kSecUseDataProtectionKeychain as String: true, + ] + } + + func set(_ value: String, key: String) throws { + let query = query(key).merging([ + kSecValueData as String: value.data(using: .utf8) ?? Data(), + ], uniquingKeysWith: { _, b in b }) + + let result = SecItemAdd(query as CFDictionary, nil) + + switch result { + case noErr: + return + default: + throw Error.failedToUpdateOrSetItem + } + } + + public func update(_ value: String, key: String) throws { + let query = query(key).merging([ + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ], uniquingKeysWith: { _, b in b }) + + let attributes: [String: Any] = + [kSecValueData as String: value.data(using: .utf8) ?? Data()] + + let result = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + switch result { + case noErr: + return + case errSecItemNotFound: + try set(value, key: key) + default: + throw Error.failedToUpdateOrSetItem + } + } + + public func get(_ key: String) throws -> String? { + let query = query(key).merging([ + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + kSecReturnAttributes as String: true, + ], uniquingKeysWith: { _, b in b }) + + var item: CFTypeRef? + if SecItemCopyMatching(query as CFDictionary, &item) == noErr { + if let existingItem = item as? [String: Any], + let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: .utf8) + { + return password + } + return nil + } else { + return nil + } + } + + public func remove(_ key: String) throws { + if SecItemDelete(query(key) as CFDictionary) == noErr { + return + } + throw Error.failedToDeleteFromKeyChain + } +} From 2233553324d8005807165760c01c8f73fd986ba4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 3 Aug 2023 21:41:03 +0800 Subject: [PATCH 55/94] Update tests --- Pro | 2 +- TestPlan.xctestplan | 14 +++++++------- .../OpenAIServiceTests/ChatGPTStreamTests.swift | 16 ++++++++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Pro b/Pro index 751d7c60..cc117ac9 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 751d7c60cde0e068bd3fcdb3b7d2ee98527ef268 +Subproject commit cc117ac960fd376eaeb620100f695c71824a3b27 diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index aa20be7d..086b8f4b 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -85,13 +85,6 @@ "name" : "TokenEncoderTests" } }, - { - "target" : { - "containerPath" : "container:Pro", - "identifier" : "LicenseManagementTests", - "name" : "LicenseManagementTests" - } - }, { "target" : { "containerPath" : "container:Tool", @@ -105,6 +98,13 @@ "identifier" : "SharedUIComponentsTests", "name" : "SharedUIComponentsTests" } + }, + { + "target" : { + "containerPath" : "container:Pro", + "identifier" : "LicenseManagementTests", + "name" : "LicenseManagementTests" + } } ], "version" : 1 diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index 59813dc6..d55e2c9c 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -253,16 +253,16 @@ extension ChatGPTStreamTests { return ( AsyncThrowingStream { continuation in let trunks: [CompletionStreamDataTrunk] = [ - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init(delta: .init(role: .assistant), index: 0, finish_reason: ""), ]), - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init(delta: .init(content: "hello"), index: 0, finish_reason: ""), ]), - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init(delta: .init(content: "my"), index: 0, finish_reason: ""), ]), - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init(delta: .init(content: "friends"), index: 0, finish_reason: ""), ]), ] @@ -286,7 +286,7 @@ extension ChatGPTStreamTests { return ( AsyncThrowingStream { continuation in let trunks: [CompletionStreamDataTrunk] = [ - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init( delta: .init( role: .assistant, @@ -295,7 +295,7 @@ extension ChatGPTStreamTests { index: 0, finish_reason: "" )]), - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init( delta: .init( role: .assistant, @@ -304,7 +304,7 @@ extension ChatGPTStreamTests { index: 0, finish_reason: "" )]), - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init( delta: .init( role: .assistant, @@ -313,7 +313,7 @@ extension ChatGPTStreamTests { index: 0, finish_reason: "" )]), - .init(id: id, object: "", created: 0, model: "", choices: [ + .init(id: id, object: "", model: "", choices: [ .init( delta: .init( role: .assistant, From adeeba3b9fd9212fca4bf6bddb0900c85ab47016 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 4 Aug 2023 22:40:18 +0800 Subject: [PATCH 56/94] Update PlusView to disable buttons when needed --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index cc117ac9..da9420c3 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit cc117ac960fd376eaeb620100f695c71824a3b27 +Subproject commit da9420c332fab5f4bfe94a57eb1d3c0abad2ae5e From ba5a9c282b24fe3b9fcf7fb8f65cd25f5ec7e03e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 13 Jul 2023 00:14:24 +0800 Subject: [PATCH 57/94] Add tree sitter as AST parser --- .../xcshareddata/swiftpm/Package.resolved | 27 +++++++ .../Contents.swift | 37 ++++++++++ .../timeline.xctimeline | 8 +++ Playground.playground/contents.xcplayground | 1 + Tool/Package.swift | 15 ++++ Tool/Sources/ASTParser/ASTParser.swift | 42 +++++++++++ Tool/Sources/ASTParser/DumpSyntaxTree.swift | 71 +++++++++++++++++++ 7 files changed, 201 insertions(+) create mode 100644 Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift create mode 100644 Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline create mode 100644 Tool/Sources/ASTParser/ASTParser.swift create mode 100644 Tool/Sources/ASTParser/DumpSyntaxTree.swift diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 205cff91..ca39f2b8 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -207,6 +207,15 @@ "version" : "2.6.1" } }, + { + "identity" : "swifttreesitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/SwiftTreeSitter", + "state" : { + "revision" : "df25a52f72ebc5b50ae20d26d1363793408bb28b", + "version" : "0.7.1" + } + }, { "identity" : "swiftui-navigation", "kind" : "remoteSourceControl", @@ -225,6 +234,24 @@ "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea" } }, + { + "identity" : "tree-sitter-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukepistrol/tree-sitter-objc", + "state" : { + "branch" : "feature/spm", + "revision" : "1b54ef0b5efddddf393b45e173788499cc572048" + } + }, + { + "identity" : "tree-sitter-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alex-pinkus/tree-sitter-swift", + "state" : { + "branch" : "with-generated-files", + "revision" : "eda05af7ac41adb4eb19c346883c0fa32fe3bdd8" + } + }, { "identity" : "usearch", "kind" : "remoteSourceControl", diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..9b80e5d9 --- /dev/null +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftUI +import AppKit +import ASTParser +import PlaygroundSupport + +struct ParsingForm: View { + @State var filePath: String = "" + @State var result: String = "" + + var body: some View { + Form { + Section("Input") { + TextField("File Path", text: $filePath) + Button("Parse") { + result = "" + Task { + let fileContent = try String(contentsOfFile: filePath) + let parser = ASTParser(language: .swift) + let tree = parser.parse(fileContent) + result = tree?.dump() ?? "N/A" + } + } + } + + Section("Result") { + Text(result) + .fontDesign(.monospaced) + } + } + .formStyle(.grouped) + .frame(width: 600, height: 800) + } +} + +PlaygroundPage.current.needsIndefiniteExecution = true +PlaygroundPage.current.setLiveView(NSHostingController(rootView: ParsingForm())) diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline new file mode 100644 index 00000000..a7f8ac13 --- /dev/null +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline @@ -0,0 +1,8 @@ + + + + + + + diff --git a/Playground.playground/contents.xcplayground b/Playground.playground/contents.xcplayground index 2f0f29c9..fa85f6b4 100644 --- a/Playground.playground/contents.xcplayground +++ b/Playground.playground/contents.xcplayground @@ -3,5 +3,6 @@ + \ No newline at end of file diff --git a/Tool/Package.swift b/Tool/Package.swift index 4724b3e1..359ed610 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -16,6 +16,7 @@ let package = Package( .library(name: "ChatTab", targets: ["ChatTab"]), .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), + .library(name: "ASTParser", targets: ["ASTParser"]), .library(name: "Toast", targets: ["Toast"]), .library(name: "Keychain", targets: ["Keychain"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), @@ -44,6 +45,14 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), + + // TreeSitter + .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", from: "0.7.1"), + .package( + url: "https://github.com/alex-pinkus/tree-sitter-swift", + branch: "with-generated-files" + ), + .package(url: "https://github.com/lukepistrol/tree-sitter-objc", branch: "feature/spm"), ], targets: [ // MARK: - Helpers @@ -142,6 +151,12 @@ let package = Package( ), .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), + .target(name: "ASTParser", dependencies: [ + .product(name: "SwiftTreeSitter", package: "SwiftTreeSitter"), + .product(name: "TreeSitterObjC", package: "tree-sitter-objc"), + .product(name: "TreeSitterSwift", package: "tree-sitter-swift"), + ]), + // MARK: - Services .target( diff --git a/Tool/Sources/ASTParser/ASTParser.swift b/Tool/Sources/ASTParser/ASTParser.swift new file mode 100644 index 00000000..19cd89ab --- /dev/null +++ b/Tool/Sources/ASTParser/ASTParser.swift @@ -0,0 +1,42 @@ +import SwiftTreeSitter +import tree_sitter +import TreeSitterObjC +import TreeSitterSwift + +public enum ParsableLanguage { + case swift + case objectiveC + + var tsLanguage: UnsafeMutablePointer { + switch self { + case .swift: + return tree_sitter_swift() + case .objectiveC: + return tree_sitter_objc() + } + } +} + +public struct ASTParser { + let language: ParsableLanguage + let parser: Parser + + public init(language: ParsableLanguage) { + self.language = language + parser = Parser() + try! parser.setLanguage(Language(language: language.tsLanguage)) + } + + public func parse(_ source: String) -> ASTTree? { + return ASTTree(tree: parser.parse(source)) + } +} + +public struct ASTTree { + public let tree: Tree? + + public var rootNode: Node? { + return tree?.rootNode + } +} + diff --git a/Tool/Sources/ASTParser/DumpSyntaxTree.swift b/Tool/Sources/ASTParser/DumpSyntaxTree.swift new file mode 100644 index 00000000..6de4fd00 --- /dev/null +++ b/Tool/Sources/ASTParser/DumpSyntaxTree.swift @@ -0,0 +1,71 @@ +import SwiftTreeSitter + +public extension ASTTree { + /// Dumps the syntax tree as a string, for debugging purposes. + func dump() -> String { + guard let tree, let root = tree.rootNode else { return "" } + var result = "" + + let appendNode: (_ level: Int, _ node: Node) -> Void = { level, node in + let line = + "\(String(repeating: " ", count: level))\(node.nodeType ?? "N/A") \(node.pointRange)" + result += line + "\n" + } + + guard let node = root.descendant(in: root.byteRange) else { return result } + + appendNode(0, node) + + let cursor = node.treeCursor + let level = 1 + + if cursor.goToFirstChild(for: node.byteRange.lowerBound) == false { + return result + } + + cursor.enumerateCurrentAndDescendents(level: level + 1) { level, node in + appendNode(level, node) + } + + while cursor.gotoNextSibling() { + guard let node = cursor.currentNode else { + assertionFailure("no current node when gotoNextSibling succeeded") + break + } + + // once we are past the interesting range, stop + if node.byteRange.lowerBound > root.byteRange.upperBound { + break + } + + cursor.enumerateCurrentAndDescendents(level: level + 1) { level, node in + appendNode(level, node) + } + } + + return result + } +} + +private extension TreeCursor { + func enumerateCurrentAndDescendents(level: Int, block: (Int, Node) throws -> Void) rethrows { + if let node = currentNode { + try block(level, node) + } + + if goToFirstChild() == false { + return + } + + try enumerateCurrentAndDescendents(level: level + 1, block: block) + + while gotoNextSibling() { + try enumerateCurrentAndDescendents(level: level + 1, block: block) + } + + let success = gotoParent() + + assert(success) + } +} + From cb1a085bddd430a0b67f8eb9d4713bbab6cc1c81 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 13 Jul 2023 16:27:01 +0800 Subject: [PATCH 58/94] Move ActiveDocumentChatContextCollector to its own target --- Core/Package.swift | 13 +++++++++++++ .../ActiveDocumentChatContextCollector.swift | 1 + Core/Sources/ChatService/AllContextCollector.swift | 1 + 3 files changed, 15 insertions(+) rename Core/Sources/{ChatContextCollector => ChatContextCollectors/ActiveDocumentChatContextCollector}/ActiveDocumentChatContextCollector.swift (99%) diff --git a/Core/Package.swift b/Core/Package.swift index 0867eb77..eab4658e 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -178,6 +178,7 @@ let package = Package( // context collectors "WebChatContextCollector", + "ActiveDocumentChatContextCollector", .product(name: "AppMonitoring", package: "Tool"), .product(name: "Environment", package: "Tool"), @@ -350,6 +351,18 @@ let package = Package( ], path: "Sources/ChatContextCollectors/WebChatContextCollector" ), + + .target( + name: "ActiveDocumentChatContextCollector", + dependencies: [ + "ChatContextCollector", + .product(name: "LangChain", package: "Tool"), + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "ASTParser", package: "Tool"), + ], + path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" + ), ] ) diff --git a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift similarity index 99% rename from Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift rename to Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index a83f932f..62bc8f03 100644 --- a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -3,6 +3,7 @@ import OpenAIService import Preferences import SuggestionModel import XcodeInspector +import ChatContextCollector public struct ActiveDocumentChatContextCollector: ChatContextCollector { public init() {} diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift index a65ff5fd..ef4b3b79 100644 --- a/Core/Sources/ChatService/AllContextCollector.swift +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -1,3 +1,4 @@ +import ActiveDocumentChatContextCollector import ChatContextCollector import WebChatContextCollector From b03be819d54caee6d4564f87d88657fd46266437 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 13 Jul 2023 16:27:44 +0800 Subject: [PATCH 59/94] Rename to LegacyActiveDocumentChatContextCollector --- .../GetEditorInfo.swift | 46 +++++++++++++++++++ ...yActiveDocumentChatContextCollector.swift} | 45 +----------------- .../ChatService/AllContextCollector.swift | 2 +- 3 files changed, 48 insertions(+), 45 deletions(-) create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift rename Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/{ActiveDocumentChatContextCollector.swift => LegacyActiveDocumentChatContextCollector.swift} (70%) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift new file mode 100644 index 00000000..640e8388 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -0,0 +1,46 @@ +import Foundation +import SuggestionModel +import XcodeInspector + +struct Information { + let editorContent: SourceEditor.Content? + let selectedContent: String + let documentURL: URL + let projectURL: URL + let language: CodeLanguage +} + +func getEditorInformation() -> Information { + let editorContent = XcodeInspector.shared.focusedEditor?.content + let documentURL = XcodeInspector.shared.activeDocumentURL + let projectURL = XcodeInspector.shared.activeProjectURL + let language = languageIdentifierFromFileURL(documentURL) + + if let editorContent, let range = editorContent.selections.first { + let startIndex = min( + max(0, range.start.line), + editorContent.lines.endIndex - 1 + ) + let endIndex = min( + max(startIndex, range.end.line), + editorContent.lines.endIndex - 1 + ) + let selectedContent = editorContent.lines[startIndex...endIndex] + return .init( + editorContent: editorContent, + selectedContent: selectedContent.joined(), + documentURL: documentURL, + projectURL: projectURL, + language: language + ) + } + + return .init( + editorContent: editorContent, + selectedContent: "", + documentURL: documentURL, + projectURL: projectURL, + language: language + ) +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift similarity index 70% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift rename to Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index 62bc8f03..93486e29 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -5,7 +5,7 @@ import SuggestionModel import XcodeInspector import ChatContextCollector -public struct ActiveDocumentChatContextCollector: ChatContextCollector { +public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { public init() {} public func generateContext( @@ -104,47 +104,4 @@ public struct ActiveDocumentChatContextCollector: ChatContextCollector { } } -extension ActiveDocumentChatContextCollector { - struct Information { - let editorContent: SourceEditor.Content? - let selectedContent: String - let documentURL: URL - let projectURL: URL - let language: CodeLanguage - } - - func getEditorInformation() -> Information { - let editorContent = XcodeInspector.shared.focusedEditor?.content - let documentURL = XcodeInspector.shared.activeDocumentURL - let projectURL = XcodeInspector.shared.activeProjectURL - let language = languageIdentifierFromFileURL(documentURL) - - if let editorContent, let range = editorContent.selections.first { - let startIndex = min( - max(0, range.start.line), - editorContent.lines.endIndex - 1 - ) - let endIndex = min( - max(startIndex, range.end.line), - editorContent.lines.endIndex - 1 - ) - let selectedContent = editorContent.lines[startIndex...endIndex] - return .init( - editorContent: editorContent, - selectedContent: selectedContent.joined(), - documentURL: documentURL, - projectURL: projectURL, - language: language - ) - } - - return .init( - editorContent: editorContent, - selectedContent: "", - documentURL: documentURL, - projectURL: projectURL, - language: language - ) - } -} diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift index ef4b3b79..77af988e 100644 --- a/Core/Sources/ChatService/AllContextCollector.swift +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -3,7 +3,7 @@ import ChatContextCollector import WebChatContextCollector let allContextCollectors: [any ChatContextCollector] = [ - ActiveDocumentChatContextCollector(), + LegacyActiveDocumentChatContextCollector(), WebChatContextCollector(), ] From 9d0a6d55504775b8f2616e013b0fc806069c1a74 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 18 Jul 2023 21:42:33 +0800 Subject: [PATCH 60/94] Update AST parser --- .../ActiveDocumentChatContextCollector.swift | 53 ++++++ .../GetCodeFunction.swift | 160 ++++++++++++++++++ .../GetEditorInfo.swift | 23 ++- .../Contents.swift | 3 + .../timeline.xctimeline | 2 + Tool/Package.swift | 2 + Tool/Sources/ASTParser/ASTParser.swift | 44 ++++- Tool/Sources/ASTParser/DumpSyntaxTree.swift | 13 +- Tool/Sources/ASTParser/TreeCursor.swift | 67 ++++++++ .../SuggestionModel/ExportedFromLSP.swift | 2 +- .../CursorDeepFirstSearchTests.swift | 91 ++++++++++ 11 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift create mode 100644 Tool/Sources/ASTParser/TreeCursor.swift create mode 100644 Tool/Tests/ASTParserTests/CursorDeepFirstSearchTests.swift diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift new file mode 100644 index 00000000..f40bb861 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -0,0 +1,53 @@ +import ASTParser +import ChatContextCollector +import Foundation +import OpenAIService +import Preferences +import SuggestionModel +import XcodeInspector + +public final class ActiveDocumentChatContextCollector: ChatContextCollector { + public init() {} + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String + ) -> ChatContext? { + guard scopes.contains("file") else { return nil } + let info = getEditorInformation() + + return .init( + systemPrompt: extractSystemPrompt(info), + functions: [] + ) + } + + func extractSystemPrompt(_ info: EditorInformation) -> String { + let relativePath = info.documentURL.path + .replacingOccurrences(of: info.projectURL.path, with: "") + let selectionRange = info.editorContent?.selections.first ?? .outOfScope + let lineAnnotations = info.editorContent?.lineAnnotations ?? [] + + var result = """ + Active Document Context:### + Document Relative Path: \(relativePath) + Language: \(info.language.rawValue) + Selection Range [line, character]: \ + [\(selectionRange.start.line), \(selectionRange.start.character)] - \ + [\(selectionRange.end.line), \(selectionRange.end.character)] + ### + """ + + if !lineAnnotations.isEmpty { + result += """ + Line Annotations: + \(lineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + """ + } + + return result + } +} + + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift new file mode 100644 index 00000000..0d48d6fc --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift @@ -0,0 +1,160 @@ +import ASTParser +import Foundation +import OpenAIService +import SuggestionModel + +struct GetCodeFunction: ChatGPTFunction { + enum CodeType: String, Codable { + case selected + case focused + } + + struct Arguments: Codable { + var codeType: CodeType + } + + struct Result: ChatGPTFunctionResult { + struct Context { + var parentName: String + var parentType: String + } + + var relativePath: String + var code: String + var range: CursorRange + var context: Context + var type: CodeType + var language: String + + var botReadableContent: String { + """ + The \(type.rawValue) code is a part of `\(context.parentType) \(context.parentName)` \ + in file \(relativePath). + Range [\(range.start.line), \(range.start.character)] - \ + [\(range.end.line), \(range.end.character)] + ```\(language) + \(code) + ``` + """ + } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "getCode" + } + + var description: String { + "Get selected or focused code from the active document." + } + + var argumentSchema: JSONSchemaValue { [ + .type: "object", + .properties: [:], + ] } + + func prepare() async { + await reportProgress("Reading code..") + } + + func call(arguments: Arguments) async throws -> Result { + await reportProgress("Reading code..") + let content = getEditorInformation() + let selectionRange = content.editorContent?.selections.first ?? .outOfScope + let editorContent = { + if selectionRange.start == selectionRange.end { + return content.editorContent?.content ?? "" + } else { + return content.selectedContent + } + }() + + let language = content.language.rawValue + let type = CodeType.selected + let relativePath = content.documentURL.path + .replacingOccurrences(of: content.projectURL.path, with: "") + let context = Result.Context( + parentName: content.documentURL.lastPathComponent, + parentType: "File" + ) + let range = selectionRange + + await reportProgress("Finish reading code..") + return .init( + relativePath: relativePath, + code: editorContent, + range: range, + context: context, + type: type, + language: language + ) + } +} + +struct GetCodeResultParser { + let editorInformation: EditorInformation + + func parse() -> GetCodeFunction.Result { + let language = editorInformation.language.rawValue + let relativePath = editorInformation.relativePath + let selectionRange = editorInformation.editorContent?.selections.first + + if let selectionRange, let node = findSmallestScopeContainingRange(selectionRange) { + let code = { + if editorInformation.selectedContent.isEmpty { + return editorInformation.selectedLines.first ?? "" + } + return editorInformation.selectedContent + }() + + return .init( + relativePath: relativePath, + code: code, + range: selectionRange, + context: .init(parentName: "", parentType: ""), + type: .selected, + language: language + ) + } + + return .init( + relativePath: relativePath, + code: "", + range: selectionRange ?? .zero, + context: .init(parentName: "", parentType: ""), + type: .focused, + language: language + ) + } + + func findSmallestScopeContainingRange(_ range: CursorRange) -> ASTNode? { + guard let language = { + switch editorInformation.language { + case .builtIn(.swift): + return ParsableLanguage.swift + case .builtIn(.objc), .builtIn(.objcpp): + return ParsableLanguage.objectiveC + default: + return nil + } + }() else { return nil } + + let parser = ASTParser(language: language) + guard let tree = parser.parse(editorInformation.editorContent?.content ?? "") + else { return nil } + + return tree.smallestNodeContainingRange(range) { node in + ScopeType.allCases.map { $0.rawValue }.contains(node.nodeType) + } + } +} + +enum ScopeType: String, CaseIterable { + case protocolDeclaration = "protocol_declaration" + case classDeclaration = "class_declaration" + case functionDeclaration = "function_declaration" + case propertyDeclaration = "property_declaration" + case computedProperty = "computed_property" +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 640e8388..4aa1359d 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -2,19 +2,23 @@ import Foundation import SuggestionModel import XcodeInspector -struct Information { +struct EditorInformation { let editorContent: SourceEditor.Content? let selectedContent: String + let selectedLines: [String] let documentURL: URL let projectURL: URL + let relativePath: String let language: CodeLanguage } -func getEditorInformation() -> Information { +func getEditorInformation() -> EditorInformation { let editorContent = XcodeInspector.shared.focusedEditor?.content let documentURL = XcodeInspector.shared.activeDocumentURL let projectURL = XcodeInspector.shared.activeProjectURL let language = languageIdentifierFromFileURL(documentURL) + let relativePath = documentURL.path + .replacingOccurrences(of: projectURL.path, with: "") if let editorContent, let range = editorContent.selections.first { let startIndex = min( @@ -25,12 +29,23 @@ func getEditorInformation() -> Information { max(startIndex, range.end.line), editorContent.lines.endIndex - 1 ) - let selectedContent = editorContent.lines[startIndex...endIndex] + let selectedLines = editorContent.lines[startIndex...endIndex] + var selectedContent = selectedLines + if selectedContent.count > 0 { + selectedContent[0] = String(selectedContent[0].dropFirst(range.start.character)) + selectedContent[selectedContent.endIndex - 1] = String( + selectedContent[selectedContent.endIndex - 1].dropLast( + selectedContent[selectedContent.endIndex - 1].count - range.end.character + ) + ) + } return .init( editorContent: editorContent, selectedContent: selectedContent.joined(), + selectedLines: Array(selectedLines), documentURL: documentURL, projectURL: projectURL, + relativePath: relativePath, language: language ) } @@ -38,8 +53,10 @@ func getEditorInformation() -> Information { return .init( editorContent: editorContent, selectedContent: "", + selectedLines: [], documentURL: documentURL, projectURL: projectURL, + relativePath: relativePath, language: language ) } diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift index 9b80e5d9..78ec2daf 100644 --- a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift @@ -26,6 +26,7 @@ struct ParsingForm: View { Section("Result") { Text(result) .fontDesign(.monospaced) + .textSelection(.enabled) } } .formStyle(.grouped) @@ -35,3 +36,5 @@ struct ParsingForm: View { PlaygroundPage.current.needsIndefiniteExecution = true PlaygroundPage.current.setLiveView(NSHostingController(rootView: ParsingForm())) +// protocol_declaration, class_declaration, function_declaration, property_declaration, computed_property +// type_identifier, simple_identifier (for variables and funcs) diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline index a7f8ac13..767a3e7d 100644 --- a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline @@ -5,4 +5,6 @@ + + diff --git a/Tool/Package.swift b/Tool/Package.swift index 359ed610..201e0141 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -157,6 +157,8 @@ let package = Package( .product(name: "TreeSitterSwift", package: "tree-sitter-swift"), ]), + .testTarget(name: "ASTParserTests", dependencies: ["ASTParser"]), + // MARK: - Services .target( diff --git a/Tool/Sources/ASTParser/ASTParser.swift b/Tool/Sources/ASTParser/ASTParser.swift index 19cd89ab..ba765456 100644 --- a/Tool/Sources/ASTParser/ASTParser.swift +++ b/Tool/Sources/ASTParser/ASTParser.swift @@ -1,3 +1,4 @@ +import SuggestionModel import SwiftTreeSitter import tree_sitter import TreeSitterObjC @@ -32,11 +33,52 @@ public struct ASTParser { } } +public typealias ASTNode = Node + +public typealias ASTPoint = Point + public struct ASTTree { public let tree: Tree? - public var rootNode: Node? { + public var rootNode: ASTNode? { return tree?.rootNode } + + public func smallestNodeContainingRange( + _ range: CursorRange, + filter: (ASTNode) -> Bool = { _ in true } + ) -> ASTNode? { + guard var targetNode = rootNode else { return nil } + + func rangeContains(_ range: Range, _ another: Range) -> Bool { + return range.lowerBound <= another.lowerBound && range.upperBound >= another.upperBound + } + + for node in targetNode.treeCursor.deepFirstSearch(skipChildren: { node in + !rangeContains(node.pointRange, range.pointRange) + }) { + guard filter(node) else { continue } + if rangeContains(node.pointRange, range.pointRange) { + targetNode = node + } + } + + return targetNode + } +} + +extension CursorRange { + var pointRange: Range { + let bytePerCharacter = 2 // tree sitter uses UTF-16 + let startPoint = Point(row: start.line, column: start.character * bytePerCharacter) + let endPoint = Point(row: end.line, column: end.character * bytePerCharacter) + guard endPoint > startPoint else { + return startPoint.. Void = { level, node in + let range = node.pointRange + let lowerBoundL = range.lowerBound.row + let lowerBoundC = range.lowerBound.column / 2 + let upperBoundL = range.upperBound.row + let upperBoundC = range.upperBound.column / 2 let line = - "\(String(repeating: " ", count: level))\(node.nodeType ?? "N/A") \(node.pointRange)" + "\(String(repeating: " ", count: level))\(node.nodeType ?? "N/A") [\(lowerBoundL), \(lowerBoundC)] - [\(upperBoundL), \(upperBoundC)]" result += line + "\n" } @@ -17,7 +22,7 @@ public extension ASTTree { appendNode(0, node) let cursor = node.treeCursor - let level = 1 + let level = 0 if cursor.goToFirstChild(for: node.byteRange.lowerBound) == false { return result @@ -27,7 +32,7 @@ public extension ASTTree { appendNode(level, node) } - while cursor.gotoNextSibling() { + while cursor.goToNextSibling() { guard let node = cursor.currentNode else { assertionFailure("no current node when gotoNextSibling succeeded") break @@ -59,7 +64,7 @@ private extension TreeCursor { try enumerateCurrentAndDescendents(level: level + 1, block: block) - while gotoNextSibling() { + while goToNextSibling() { try enumerateCurrentAndDescendents(level: level + 1, block: block) } diff --git a/Tool/Sources/ASTParser/TreeCursor.swift b/Tool/Sources/ASTParser/TreeCursor.swift new file mode 100644 index 00000000..7898582f --- /dev/null +++ b/Tool/Sources/ASTParser/TreeCursor.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftTreeSitter + +extension TreeCursor { + /// Deep first search nodes. + /// - Parameter skipChildren: Check if children of a `Node` should be skipped. + func deepFirstSearch( + skipChildren: @escaping (Node) -> Bool + ) -> CursorDeepFirstSearchSequence { + return CursorDeepFirstSearchSequence(cursor: self, skipChildren: skipChildren) + } +} + +// MARK: - Search + +protocol Cursor { + associatedtype Node + var currentNode: Node? { get } + func goToFirstChild() -> Bool + func goToNextSibling() -> Bool + func goToParent() -> Bool +} + +extension TreeCursor: Cursor { + func goToParent() -> Bool { + gotoParent() + } +} + +struct CursorDeepFirstSearchSequence: Sequence { + let cursor: C + let skipChildren: (C.Node) -> Bool + + func makeIterator() -> CursorDeepFirstSearchIterator { + return CursorDeepFirstSearchIterator( + cursor: cursor, + skipChildren: skipChildren + ) + } + + struct CursorDeepFirstSearchIterator: IteratorProtocol { + let cursor: C + let skipChildren: (C.Node) -> Bool + var isEnded = false + + mutating func next() -> C.Node? { + guard !isEnded else { return nil } + let currentNode = cursor.currentNode + let hasChild = { + guard let n = currentNode else { return false } + if skipChildren(n) { return false } + return cursor.goToFirstChild() + }() + if !hasChild { + while !cursor.goToNextSibling() { + if !cursor.goToParent() { + isEnded = true + break + } + } + } + + return currentNode + } + } +} + diff --git a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift index 2239c839..14f8dd98 100644 --- a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift @@ -8,7 +8,7 @@ public extension CursorPosition { } public struct CursorRange: Codable, Hashable, Sendable { - static let zero = CursorRange(start: .zero, end: .zero) + public static let zero = CursorRange(start: .zero, end: .zero) public var start: CursorPosition public var end: CursorPosition diff --git a/Tool/Tests/ASTParserTests/CursorDeepFirstSearchTests.swift b/Tool/Tests/ASTParserTests/CursorDeepFirstSearchTests.swift new file mode 100644 index 00000000..cb70853a --- /dev/null +++ b/Tool/Tests/ASTParserTests/CursorDeepFirstSearchTests.swift @@ -0,0 +1,91 @@ +import Foundation +import XCTest + +@testable import ASTParser + +class CursorDeepFirstSearchTests: XCTestCase { + class TN { + var parent: TN? + var value: Int + var children: [TN] = [] + + init(_ value: Int, _ children: [TN] = []) { + self.value = value + self.children = children + children.forEach { $0.parent = self } + } + } + + class ACursor: Cursor { + var currentNode: TN? + init(currentNode: TN?) { + self.currentNode = currentNode + } + + func goToFirstChild() -> Bool { + if let first = currentNode?.children.first { + currentNode = first + return true + } + return false + } + + func goToNextSibling() -> Bool { + if let parent = currentNode?.parent, + let index = parent.children.firstIndex(where: { $0 === currentNode }), + index < parent.children.count - 1 { + currentNode = parent.children[index + 1] + return true + } + return false + } + + func goToParent() -> Bool { + if let parent = currentNode?.parent { + currentNode = parent + return true + } + return false + } + } + + func test_deep_first_search() { + let root = TN(0, [ + TN(1, [ + TN(2), + TN(3) + ]), + TN(4, [ + TN(5, [TN(6, [TN(7)])]), + TN(8) + ]) + ]) + let cursor = ACursor(currentNode: root) + var result = [Int]() + for node in CursorDeepFirstSearchSequence(cursor: cursor, skipChildren: { _ in true }) { + result.append(node.value) + } + + XCTAssertEqual(result, result.sorted()) + } + + func test_deep_first_search_skip_children() { + let root = TN(0, [ + TN(1, [ + TN(2), + TN(3) + ]), + TN(4, [ + TN(5, [TN(6, [TN(7)])]), + TN(8) + ]) + ]) + let cursor = ACursor(currentNode: root) + var result = [Int]() + for node in CursorDeepFirstSearchSequence(cursor: cursor, skipChildren: { $0.value == 5 }) { + result.append(node.value) + } + + XCTAssertEqual(result, [0, 1, 2, 3, 4, 5, 8]) + } +} From afd7975b8065c952a2ea19b15d91abab9df62bf6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 20 Jul 2023 15:05:42 +0800 Subject: [PATCH 61/94] WIP --- .../xcshareddata/swiftpm/Package.resolved | 9 + Core/Package.swift | 2 + .../GetCodeFunction.swift | 79 ++++----- .../GetEditorInfo.swift | 49 ++++-- .../ReadableCursorRange.swift | 13 ++ .../SwiftASTReader.swift | 164 ++++++++++++++++++ .../Contents.swift | 13 +- .../timeline.xctimeline | 2 + Playground.playground/contents.xcplayground | 1 - Tool/Sources/ASTParser/ASTParser.swift | 40 ++++- 10 files changed, 305 insertions(+), 67 deletions(-) create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index ca39f2b8..63235d66 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -198,6 +198,15 @@ "version" : "0.12.1" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "branch" : "main", + "revision" : "aa3b1e187c9cc568f9d1abc47feb11f6b044d284" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", diff --git a/Core/Package.swift b/Core/Package.swift index eab4658e..cdedda7b 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -51,6 +51,7 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), + .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), ].pro, targets: [ // MARK: - Main @@ -360,6 +361,7 @@ let package = Package( .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "ASTParser", package: "Tool"), + .product(name: "SwiftSyntax", package: "swift-syntax"), ], path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" ), diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift index 0d48d6fc..75526aaf 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift @@ -14,27 +14,22 @@ struct GetCodeFunction: ChatGPTFunction { } struct Result: ChatGPTFunctionResult { - struct Context { - var parentName: String - var parentType: String - } - var relativePath: String var code: String var range: CursorRange - var context: Context + var context: CodeContext var type: CodeType var language: String var botReadableContent: String { """ - The \(type.rawValue) code is a part of `\(context.parentType) \(context.parentName)` \ - in file \(relativePath). - Range [\(range.start.line), \(range.start.character)] - \ - [\(range.end.line), \(range.end.character)] + File: \(relativePath) + Range: \(range) + \(type.rawValue) code ```\(language) \(code) ``` + \(context) """ } } @@ -74,10 +69,6 @@ struct GetCodeFunction: ChatGPTFunction { let type = CodeType.selected let relativePath = content.documentURL.path .replacingOccurrences(of: content.projectURL.path, with: "") - let context = Result.Context( - parentName: content.documentURL.lastPathComponent, - parentType: "File" - ) let range = selectionRange await reportProgress("Finish reading code..") @@ -85,7 +76,7 @@ struct GetCodeFunction: ChatGPTFunction { relativePath: relativePath, code: editorContent, range: range, - context: context, + context: .top, type: type, language: language ) @@ -99,20 +90,34 @@ struct GetCodeResultParser { let language = editorInformation.language.rawValue let relativePath = editorInformation.relativePath let selectionRange = editorInformation.editorContent?.selections.first + let code = { + if editorInformation.selectedContent.isEmpty { + return editorInformation.selectedLines.first ?? "" + } + return editorInformation.selectedContent + }() - if let selectionRange, let node = findSmallestScopeContainingRange(selectionRange) { - let code = { - if editorInformation.selectedContent.isEmpty { - return editorInformation.selectedLines.first ?? "" - } - return editorInformation.selectedContent - }() + guard let astReader = createASTReader() else { + return .init( + relativePath: relativePath, + code: code, + range: selectionRange ?? .zero, + context: .top, + type: .selected, + language: language + ) + } + if let selectionRange { + let context = astReader.contextContainingRange( + selectionRange, + in: editorInformation.editorContent?.content ?? "" + ) return .init( relativePath: relativePath, code: code, range: selectionRange, - context: .init(parentName: "", parentType: ""), + context: .top, type: .selected, language: language ) @@ -122,30 +127,20 @@ struct GetCodeResultParser { relativePath: relativePath, code: "", range: selectionRange ?? .zero, - context: .init(parentName: "", parentType: ""), + context: .top, type: .focused, language: language ) } - func findSmallestScopeContainingRange(_ range: CursorRange) -> ASTNode? { - guard let language = { - switch editorInformation.language { - case .builtIn(.swift): - return ParsableLanguage.swift - case .builtIn(.objc), .builtIn(.objcpp): - return ParsableLanguage.objectiveC - default: - return nil - } - }() else { return nil } - - let parser = ASTParser(language: language) - guard let tree = parser.parse(editorInformation.editorContent?.content ?? "") - else { return nil } - - return tree.smallestNodeContainingRange(range) { node in - ScopeType.allCases.map { $0.rawValue }.contains(node.nodeType) + func createASTReader() -> ASTReader? { + switch editorInformation.language { + case .builtIn(.swift): + return SwiftASTReader() + case .builtIn(.objc), .builtIn(.objcpp): + return SwiftASTReader() + default: + return nil } } } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 4aa1359d..9b15e430 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -10,6 +10,31 @@ struct EditorInformation { let projectURL: URL let relativePath: String let language: CodeLanguage + + func code(in range: CursorRange) -> String { + return EditorInformation.code(in: selectedLines, inside: range).code + } + + static func lines(in code: [String], containing range: CursorRange) -> [String] { + let startIndex = min(max(0, range.start.line), code.endIndex - 1) + let endIndex = min(max(startIndex, range.end.line), code.endIndex - 1) + let selectedLines = code[startIndex...endIndex] + return Array(selectedLines) + } + + static func code(in code: [String], inside range: CursorRange) -> (code: String, lines: [String]) { + let rangeLines = lines(in: code, containing: range) + var selectedContent = rangeLines + if !selectedContent.isEmpty { + selectedContent[0] = String(selectedContent[0].dropFirst(range.start.character)) + selectedContent[selectedContent.endIndex - 1] = String( + selectedContent[selectedContent.endIndex - 1].dropLast( + selectedContent[selectedContent.endIndex - 1].count - range.end.character + ) + ) + } + return (selectedContent.joined(), rangeLines) + } } func getEditorInformation() -> EditorInformation { @@ -21,28 +46,14 @@ func getEditorInformation() -> EditorInformation { .replacingOccurrences(of: projectURL.path, with: "") if let editorContent, let range = editorContent.selections.first { - let startIndex = min( - max(0, range.start.line), - editorContent.lines.endIndex - 1 - ) - let endIndex = min( - max(startIndex, range.end.line), - editorContent.lines.endIndex - 1 + let (selectedContent, selectedLines) = EditorInformation.code( + in: editorContent.lines, + inside: range ) - let selectedLines = editorContent.lines[startIndex...endIndex] - var selectedContent = selectedLines - if selectedContent.count > 0 { - selectedContent[0] = String(selectedContent[0].dropFirst(range.start.character)) - selectedContent[selectedContent.endIndex - 1] = String( - selectedContent[selectedContent.endIndex - 1].dropLast( - selectedContent[selectedContent.endIndex - 1].count - range.end.character - ) - ) - } return .init( editorContent: editorContent, - selectedContent: selectedContent.joined(), - selectedLines: Array(selectedLines), + selectedContent: selectedContent, + selectedLines: selectedLines, documentURL: documentURL, projectURL: projectURL, relativePath: relativePath, diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift new file mode 100644 index 00000000..6db41a60 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift @@ -0,0 +1,13 @@ +import SuggestionModel + +extension CursorPosition: CustomStringConvertible { + var description: String { + "[\(line), \(character)]" + } +} + +extension CursorRange: CustomStringConvertible { + var description: String { + "\(start.description) - \(end.description)" + } +} diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift new file mode 100644 index 00000000..528fa1e3 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift @@ -0,0 +1,164 @@ +import ASTParser +import Foundation +import SuggestionModel + +protocol ASTReader { + func contextContainingRange( + _ range: CursorRange, + code: String, + codeLines: [String] + ) -> CodeContext +} + +struct CodeContext: CustomStringConvertible { + enum Scope { + case top + case scope( + type: String, + identifier: String, + range: CursorRange + ) + } + + var scope: Scope + var extraKnowledge: String = "" + + var description: String { + switch scope { + case .top: + return "\(extraKnowledge)" + case let .scope(type, identifier, range): + return """ + Inside \(type) \(identifier), range \(range) + \(extraKnowledge) + """ + } + } +} + +struct SwiftASTReader: ASTReader { + enum ScopeType: String, CaseIterable { + case protocolDeclaration = "protocol_declaration" + case classDeclaration = "class_declaration" + case functionDeclaration = "function_declaration" + case propertyDeclaration = "property_declaration" + case computedProperty = "computed_property" + } + + func createExtraKnowledge(_ code: String) -> String { + var all = [String]() + if code.contains("macro") { + all.append("macro: introduced since Swift 5.9") + } + return all.joined() + } + + func contextContainingRange( + _ range: CursorRange, + code: String, + codeLines: [String] + ) -> CodeContext { + let parser = ASTParser(language: .swift) + guard let tree = parser.parse(code) else { + return .init(scope: .top) + } + + guard let node = tree.smallestNodeContainingRange(range, filter: { node in + ScopeType.allCases.map { $0.rawValue }.contains(node.nodeType) + }) else { + return .init(scope: .top) + } + + switch ScopeType(rawValue: node.nodeType ?? "") { + case .protocolDeclaration: + // Example: + // comment [0, 0] - [1, 10] + // comment [1, 0] - [1, 10] + // protocol_declaration [2, 0] - [5, 1] + // protocol [2, 0] - [2, 8] + // type_identifier [2, 9] - [2, 15] + // protocol_body [2, 16] - [5, 1] + // { [2, 16] - [2, 17] + // protocol_property_declaration [3, 4] - [3, 28] + // ... + // protocol_function_declaration [4, 4] - [4, 16] + // ... + // } [5, 0] - [5, 1] + + var identifier = "unknown" + for child in node.children { + if child.nodeType == "type_identifier" { + let range = CursorRange(pointRange: child.pointRange) + let (code, _) = EditorInformation.code(in: codeLines, inside: range) + identifier = code + break + } + } + return .init(scope: .scope( + type: "protocol", + identifier: identifier, + range: .init(pointRange: node.pointRange) + )) + + case .classDeclaration: + // class_declaration [9, 0] - [14, 1] + // struct [9, 0] - [9, 6] + // type_identifier [9, 7] - [9, 10] + // : [9, 10] - [9, 11] + // inheritance_specifier [9, 12] - [9, 18] + // user_type [9, 12] - [9, 18] + // type_identifier [9, 12] - [9, 18] + // class_body [9, 19] - [14, 1] + // { [9, 19] - [9, 20] + // property_declaration [10, 4] - [10, 20] + // let [10, 4] - [10, 7] + // pattern [10, 8] - [10, 12] + // simple_identifier [10, 8] - [10, 12] + // type_annotation [10, 12] - [10, 20] + // : [10, 12] - [10, 13] + // user_type [10, 14] - [10, 20] + // type_identifier [10, 14] - [10, 20] + // function_declaration [11, 4] - [13, 5] + // ... + // } [14, 0] - [14, 1] + // can be struct, enum, class, or actor + + var type = "unknown" + var identifier = "unknown" + + for child in node.children { + switch child.nodeType { + case "struct": + type = "struct" + case "class": + type = "class" + case "enum": + type = "enum" + case "actor": + type = "actor" + case "type_identifier": + let range = CursorRange(pointRange: child.pointRange) + let (code, _) = EditorInformation.code(in: codeLines, inside: range) + identifier = code + default: continue + } + } + + return .init(scope: .scope( + type: type, + identifier: identifier, + range: .init(pointRange: node.pointRange) + )) + + case .functionDeclaration: + return .init(scope: .top) + case .propertyDeclaration: + return .init(scope: .top) + case .computedProperty: + return .init(scope: .top) + case .none: + return .init(scope: .top) + } + } +} + diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift index 78ec2daf..a50a91ab 100644 --- a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift @@ -15,10 +15,15 @@ struct ParsingForm: View { Button("Parse") { result = "" Task { - let fileContent = try String(contentsOfFile: filePath) - let parser = ASTParser(language: .swift) - let tree = parser.parse(fileContent) - result = tree?.dump() ?? "N/A" + do { + let fileContent = try String(contentsOfFile: filePath) + let parser = ASTParser(language: .swift) + let tree = parser.parse(fileContent) + result = tree?.dump() ?? "N/A" + print(result) + } catch { + result = error.localizedDescription + } } } } diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline index 767a3e7d..9d435df4 100644 --- a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline @@ -7,4 +7,6 @@ + + diff --git a/Playground.playground/contents.xcplayground b/Playground.playground/contents.xcplayground index fa85f6b4..2f0f29c9 100644 --- a/Playground.playground/contents.xcplayground +++ b/Playground.playground/contents.xcplayground @@ -3,6 +3,5 @@ - \ No newline at end of file diff --git a/Tool/Sources/ASTParser/ASTParser.swift b/Tool/Sources/ASTParser/ASTParser.swift index ba765456..257eb704 100644 --- a/Tool/Sources/ASTParser/ASTParser.swift +++ b/Tool/Sources/ASTParser/ASTParser.swift @@ -67,7 +67,32 @@ public struct ASTTree { } } -extension CursorRange { +public extension ASTNode { + var children: ASTNodeChildrenSequence { + return ASTNodeChildrenSequence(node: self) + } + + struct ASTNodeChildrenSequence: Sequence { + let node: ASTNode + + public struct ASTNodeChildrenIterator: IteratorProtocol { + let node: ASTNode + var index: UInt32 = 0 + + public mutating func next() -> ASTNode? { + guard index < node.childCount else { return nil } + defer { index += 1 } + return node.child(at: 1) + } + } + + public func makeIterator() -> ASTNodeChildrenIterator { + return ASTNodeChildrenIterator(node: node) + } + } +} + +public extension CursorRange { var pointRange: Range { let bytePerCharacter = 2 // tree sitter uses UTF-16 let startPoint = Point(row: start.line, column: start.character * bytePerCharacter) @@ -80,5 +105,18 @@ extension CursorRange { } return startPoint..) { + let bytePerCharacter = 2 // tree sitter uses UTF-16 + let start = CursorPosition( + line: Int(pointRange.lowerBound.row), + character: Int(pointRange.lowerBound.column) / bytePerCharacter + ) + let end = CursorPosition( + line: Int(pointRange.upperBound.row), + character: Int(pointRange.upperBound.column) / bytePerCharacter + ) + self.init(start: start, end: end) + } } From 430d6934e855247753121454b9e6b27a6bfa9e8d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 21 Jul 2023 11:08:41 +0800 Subject: [PATCH 62/94] WIP --- Core/Package.swift | 1 + .../GetEditorInfo.swift | 35 --- .../SwiftASTReader.swift | 205 +++++++++++++++++- .../SuggestionModel/EditorInformation.swift | 101 +++++++++ .../SuggestionModel/ExportedFromLSP.swift | 11 +- .../Sources/XcodeInspector/SourceEditor.swift | 29 +-- 6 files changed, 315 insertions(+), 67 deletions(-) create mode 100644 Tool/Sources/SuggestionModel/EditorInformation.swift diff --git a/Core/Package.swift b/Core/Package.swift index cdedda7b..25c9b089 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -362,6 +362,7 @@ let package = Package( .product(name: "Preferences", package: "Tool"), .product(name: "ASTParser", package: "Tool"), .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), ], path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" ), diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 9b15e430..94cd94ac 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -2,41 +2,6 @@ import Foundation import SuggestionModel import XcodeInspector -struct EditorInformation { - let editorContent: SourceEditor.Content? - let selectedContent: String - let selectedLines: [String] - let documentURL: URL - let projectURL: URL - let relativePath: String - let language: CodeLanguage - - func code(in range: CursorRange) -> String { - return EditorInformation.code(in: selectedLines, inside: range).code - } - - static func lines(in code: [String], containing range: CursorRange) -> [String] { - let startIndex = min(max(0, range.start.line), code.endIndex - 1) - let endIndex = min(max(startIndex, range.end.line), code.endIndex - 1) - let selectedLines = code[startIndex...endIndex] - return Array(selectedLines) - } - - static func code(in code: [String], inside range: CursorRange) -> (code: String, lines: [String]) { - let rangeLines = lines(in: code, containing: range) - var selectedContent = rangeLines - if !selectedContent.isEmpty { - selectedContent[0] = String(selectedContent[0].dropFirst(range.start.character)) - selectedContent[selectedContent.endIndex - 1] = String( - selectedContent[selectedContent.endIndex - 1].dropLast( - selectedContent[selectedContent.endIndex - 1].count - range.end.character - ) - ) - } - return (selectedContent.joined(), rangeLines) - } -} - func getEditorInformation() -> EditorInformation { let editorContent = XcodeInspector.shared.focusedEditor?.content let documentURL = XcodeInspector.shared.activeDocumentURL diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift index 528fa1e3..28da19d0 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift @@ -1,6 +1,8 @@ import ASTParser import Foundation import SuggestionModel +import SwiftParser +import SwiftSyntax protocol ASTReader { func contextContainingRange( @@ -44,7 +46,106 @@ struct SwiftASTReader: ASTReader { case propertyDeclaration = "property_declaration" case computedProperty = "computed_property" } - + + final class SwiftScopeHierarchySyntaxVisitor: SyntaxVisitor { + let tree: SyntaxProtocol + let code: String + let range: CursorRange + private var _scopeHierarchy: [SyntaxProtocol] = [] + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy(_ node: some SyntaxProtocol) -> [SyntaxProtocol] { + walk(node) + return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } + } + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy() -> [SyntaxProtocol] { + walk(tree) + return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } + } + + init(tree: SyntaxProtocol, code: String, range: CursorRange) { + self.tree = tree + self.code = code + self.range = range + super.init(viewMode: .all) + } + + func skipChildrenIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if !nodeContainsRange(node) { return .skipChildren } + return .visitChildren + } + + func captureNodeIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if !nodeContainsRange(node) { return .skipChildren } + _scopeHierarchy.append(node) + return .visitChildren + } + + func nodeContainsRange(_ node: SyntaxProtocol) -> Bool { + let sourceRange = node.sourceRange(converter: .init(file: code, tree: tree)) + let cursorRange = CursorRange(sourceRange: sourceRange) + return cursorRange.contains(range) + } + + // skip if possible + + override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + override func visit(_ node: MemberDeclBlockSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + override func visit(_ node: MemberDeclListItemSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + // capture if possible + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: MacroDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: MacroExpansionDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + } + func createExtraKnowledge(_ code: String) -> String { var all = [String]() if code.contains("macro") { @@ -57,6 +158,99 @@ struct SwiftASTReader: ASTReader { _ range: CursorRange, code: String, codeLines: [String] + ) -> CodeContext { + let tree = Parser.parse(source: code) + let visitor = SwiftScopeHierarchySyntaxVisitor(tree: tree, code: code, range: range) + let nodes = visitor.findScopeHierarchy() + + func convertRange(_ node: SyntaxProtocol) -> CursorRange { + .init(sourceRange: node.sourceRange(converter: .init(file: code, tree: tree))) + } + + if let node = nodes.first { + switch node.kind { + case .structDecl: + guard let node = node as? StructDeclSyntax else { break } + + return .init(scope: .scope( + type: node.structKeyword.text, + identifier: node.identifier.text, + range: convertRange(node) + )) + case .classDecl: + guard let node = node as? ClassDeclSyntax else { break } + + return .init(scope: .scope( + type: node.classKeyword.text, + identifier: node.identifier.text, + range: convertRange(node) + )) + case .enumDecl: + guard let node = node as? EnumDeclSyntax else { break } + + return .init(scope: .scope( + type: node.enumKeyword.text, + identifier: node.identifier.text, + range: convertRange(node) + )) + case .actorDecl: + guard let node = node as? ActorDeclSyntax else { break } + + return .init(scope: .scope( + type: node.actorKeyword.text, + identifier: node.identifier.text, + range: convertRange(node) + )) + case .macroDecl: + guard let node = node as? MacroDeclSyntax else { break } + + return .init(scope: .scope( + type: node.macroKeyword.text, + identifier: node.identifier.text, + range: convertRange(node) + )) + case .macroExpansionDecl: + guard let node = node as? MacroExpansionDeclSyntax else { break } + + return .init(scope: .scope( + type: "macro expansion", + identifier: node.macro.text, + range: convertRange(node) + )) + case .protocolDecl: + guard let node = node as? ProtocolDeclSyntax else { break } + + return .init(scope: .scope( + type: node.protocolKeyword.text, + identifier: node.identifier.text, + range: convertRange(node) + )) + case .extensionDecl: + guard let node = node as? ExtensionDeclSyntax else { break } + + return .init(scope: .scope( + type: node.extensionKeyword.text, + identifier: node.extendedType.description, + range: convertRange(node) + )) + case .functionDecl: + guard let node = node as? FunctionDeclSyntax else { break } + + return .init(scope: .scope( + type: node.funcKeyword.text, + identifier: node.identifier.text, + range: convertRange(node) + )) + } + } + + return .init(scope: .top) + } + + func contextContainingRange2( + _ range: CursorRange, + code: String, + codeLines: [String] ) -> CodeContext { let parser = ASTParser(language: .swift) guard let tree = parser.parse(code) else { @@ -162,3 +356,12 @@ struct SwiftASTReader: ASTReader { } } +extension CursorRange { + init(sourceRange: SourceRange) { + self.init( + start: .init(line: sourceRange.start.line - 1, character: sourceRange.start.column - 1), + end: .init(line: sourceRange.end.line - 1, character: sourceRange.end.column - 1) + ) + } +} + diff --git a/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionModel/EditorInformation.swift new file mode 100644 index 00000000..3de6b95a --- /dev/null +++ b/Tool/Sources/SuggestionModel/EditorInformation.swift @@ -0,0 +1,101 @@ +import Foundation + +public struct EditorInformation { + public struct SourceEditorContent { + /// The content of the source editor. + public var content: String + /// The content of the source editor in lines. + public var lines: [String] + /// The selection ranges of the source editor. + public var selections: [CursorRange] + /// The cursor position of the source editor. + public var cursorPosition: CursorPosition + /// Line annotations of the source editor. + public var lineAnnotations: [String] + + public var selectedContent: String { + if let range = selections.first { + let startIndex = min( + max(0, range.start.line), + lines.endIndex - 1 + ) + let endIndex = min( + max(startIndex, range.end.line), + lines.endIndex - 1 + ) + let selectedContent = lines[startIndex...endIndex] + return selectedContent.joined() + } + return "" + } + + public init( + content: String, + lines: [String], + selections: [CursorRange], + cursorPosition: CursorPosition, + lineAnnotations: [String] + ) { + self.content = content + self.lines = lines + self.selections = selections + self.cursorPosition = cursorPosition + self.lineAnnotations = lineAnnotations + } + } + + public let editorContent: SourceEditorContent? + public let selectedContent: String + public let selectedLines: [String] + public let documentURL: URL + public let projectURL: URL + public let relativePath: String + public let language: CodeLanguage + + public init( + editorContent: SourceEditorContent?, + selectedContent: String, + selectedLines: [String], + documentURL: URL, + projectURL: URL, + relativePath: String, + language: CodeLanguage + ) { + self.editorContent = editorContent + self.selectedContent = selectedContent + self.selectedLines = selectedLines + self.documentURL = documentURL + self.projectURL = projectURL + self.relativePath = relativePath + self.language = language + } + + public func code(in range: CursorRange) -> String { + return EditorInformation.code(in: selectedLines, inside: range).code + } + + public static func lines(in code: [String], containing range: CursorRange) -> [String] { + let startIndex = min(max(0, range.start.line), code.endIndex - 1) + let endIndex = min(max(startIndex, range.end.line), code.endIndex - 1) + let selectedLines = code[startIndex...endIndex] + return Array(selectedLines) + } + + public static func code( + in code: [String], + inside range: CursorRange + ) -> (code: String, lines: [String]) { + let rangeLines = lines(in: code, containing: range) + var selectedContent = rangeLines + if !selectedContent.isEmpty { + selectedContent[0] = String(selectedContent[0].dropFirst(range.start.character)) + selectedContent[selectedContent.endIndex - 1] = String( + selectedContent[selectedContent.endIndex - 1].dropLast( + selectedContent[selectedContent.endIndex - 1].count - range.end.character + ) + ) + } + return (selectedContent.joined(), rangeLines) + } +} + diff --git a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift index 14f8dd98..d0773c65 100644 --- a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift @@ -19,14 +19,18 @@ public struct CursorRange: Codable, Hashable, Sendable { } public init(startPair: (Int, Int), endPair: (Int, Int)) { - self.start = Position(startPair) - self.end = Position(endPair) + start = CursorPosition(startPair) + end = CursorPosition(endPair) } - public func contains(_ position: Position) -> Bool { + public func contains(_ position: CursorPosition) -> Bool { return position > start && position < end } + public func contains(_ range: CursorRange) -> Bool { + return range.start > start && range.end < end + } + public func intersects(_ other: LSPRange) -> Bool { return contains(other.start) || contains(other.end) } @@ -42,3 +46,4 @@ public extension CursorRange { return .init(start: position, end: position) } } + diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 3557a6ad..7937582e 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -5,34 +5,7 @@ import SuggestionModel /// Representing a source editor inside Xcode. public class SourceEditor { - public struct Content { - /// The content of the source editor. - public var content: String - /// The content of the source editor in lines. - public var lines: [String] - /// The selection ranges of the source editor. - public var selections: [CursorRange] - /// The cursor position of the source editor. - public var cursorPosition: CursorPosition - /// Line annotations of the source editor. - public var lineAnnotations: [String] - - public var selectedContent: String { - if let range = selections.first { - let startIndex = min( - max(0, range.start.line), - lines.endIndex - 1 - ) - let endIndex = min( - max(startIndex, range.end.line), - lines.endIndex - 1 - ) - let selectedContent = lines[startIndex...endIndex] - return selectedContent.joined() - } - return "" - } - } + public typealias Content = EditorInformation.SourceEditorContent let runningApplication: NSRunningApplication public let element: AXUIElement From df3e5de03c7ec6cc8aeea50e450162b81b6e8ea0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Jul 2023 01:49:46 +0800 Subject: [PATCH 63/94] Add new ActiveDocumentChatContextCollector --- Core/Package.swift | 33 +- .../ActiveDocumentChatContextCollector.swift | 261 +++++++- .../FocusedCodeFinder/FocusedCodeFinder.swift | 83 +++ .../SwiftFocusedCodeFinder.swift | 593 ++++++++++++++++++ .../Functions/ExpandFocusRangeFunction.swift | 54 ++ .../MoveToCodeAroundLineFunction.swift | 57 ++ .../Functions/MoveToFocusedCodeFunction.swift | 54 ++ .../GetCodeFunction.swift | 155 ----- .../GetEditorInfo.swift | 4 +- ...cyActiveDocumentChatContextCollector.swift | 5 +- .../ReadableCursorRange.swift | 8 +- .../SwiftASTReader.swift | 367 ----------- .../ChatService/AllContextCollector.swift | 2 +- .../DynamicContextController.swift | 8 +- .../SwiftASTReaderTests.swift | 256 ++++++++ Tool/Package.swift | 8 +- Tool/Sources/ASTParser/TreeCursor.swift | 4 + .../SuggestionModel/EditorInformation.swift | 52 +- .../SuggestionModel/ExportedFromLSP.swift | 20 +- .../LineAnnotationParsingTests.swift | 14 + 20 files changed, 1451 insertions(+), 587 deletions(-) create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift create mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift delete mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift delete mode 100644 Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift create mode 100644 Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift create mode 100644 Tool/Tests/SuggestionModelTests/LineAnnotationParsingTests.swift diff --git a/Core/Package.swift b/Core/Package.swift index 25c9b089..b0392ee9 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -352,20 +352,25 @@ let package = Package( ], path: "Sources/ChatContextCollectors/WebChatContextCollector" ), - - .target( - name: "ActiveDocumentChatContextCollector", - dependencies: [ - "ChatContextCollector", - .product(name: "LangChain", package: "Tool"), - .product(name: "OpenAIService", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "ASTParser", package: "Tool"), - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftParser", package: "swift-syntax"), - ], - path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" - ), + + .target( + name: "ActiveDocumentChatContextCollector", + dependencies: [ + "ChatContextCollector", + .product(name: "LangChain", package: "Tool"), + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "ASTParser", package: "Tool"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + ], + path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" + ), + + .testTarget( + name: "ActiveDocumentChatContextCollectorTests", + dependencies: ["ActiveDocumentChatContextCollector"] + ), ] ) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index f40bb861..02975270 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -9,45 +9,252 @@ import XcodeInspector public final class ActiveDocumentChatContextCollector: ChatContextCollector { public init() {} + var activeDocumentContext: ActiveDocumentContext? + public func generateContext( history: [ChatMessage], scopes: Set, content: String ) -> ChatContext? { - guard scopes.contains("file") else { return nil } - let info = getEditorInformation() + guard let info = getEditorInformation() else { return nil } + let context = getActiveDocumentContext(info) + activeDocumentContext = context + + guard scopes.contains("code") || scopes.contains("c") else { + if scopes.contains("file") || scopes.contains("f") { + var removedCode = context + removedCode.focusedContext = nil + return .init( + systemPrompt: extractSystemPrompt(removedCode), + functions: [] + ) + } + return nil + } + + var functions = [any ChatGPTFunction]() + + // When the bot is already focusing on a piece of code, it can expand the range. + + if context.focusedContext != nil { + functions.append(ExpandFocusRangeFunction(contextCollector: self)) + } + + // When the bot is not focusing on any code, or the focusing area is not the user's + // selection, it can move the focus back to the user's selection. + + if context.focusedContext == nil || + !(context.focusedContext?.codeRange.contains(context.selectionRange) ?? false) + { + functions.append(MoveToFocusedCodeFunction(contextCollector: self)) + } + + // When there is a line annotation not in the focused area, the bot can move the focus area + // to the code covering the line of the annotation. + + if let focusedContext = context.focusedContext, + !focusedContext.otherLineAnnotations.isEmpty + { + functions.append(MoveToCodeAroundLineFunction(contextCollector: self)) + } + + if context.focusedContext == nil, !context.lineAnnotations.isEmpty { + functions.append(MoveToCodeAroundLineFunction(contextCollector: self)) + } return .init( - systemPrompt: extractSystemPrompt(info), - functions: [] + systemPrompt: extractSystemPrompt(context), + functions: functions ) } - - func extractSystemPrompt(_ info: EditorInformation) -> String { - let relativePath = info.documentURL.path - .replacingOccurrences(of: info.projectURL.path, with: "") - let selectionRange = info.editorContent?.selections.first ?? .outOfScope - let lineAnnotations = info.editorContent?.lineAnnotations ?? [] - - var result = """ - Active Document Context:### - Document Relative Path: \(relativePath) - Language: \(info.language.rawValue) - Selection Range [line, character]: \ - [\(selectionRange.start.line), \(selectionRange.start.character)] - \ - [\(selectionRange.end.line), \(selectionRange.end.character)] - ### - """ - - if !lineAnnotations.isEmpty { - result += """ - Line Annotations: - \(lineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + + func getActiveDocumentContext(_ info: EditorInformation) -> ActiveDocumentContext { + var activeDocumentContext = activeDocumentContext ?? .init( + relativePath: "", + language: .builtIn(.swift), + fileContent: "", + lines: [], + selectedCode: "", + selectionRange: .outOfScope, + lineAnnotations: [], + imports: [] + ) + + activeDocumentContext.update(info) + return activeDocumentContext + } + + func extractSystemPrompt(_ context: ActiveDocumentContext) -> String { + let start = "User Editing Document Context:###" + let end = "###" + let relativePath = "Document Relative Path: \(context.relativePath)" + let language = "Language: \(context.language)" + + if let focusedContext = context.focusedContext { + let codeContext = "\(focusedContext.contextRange) \(focusedContext.context)" + let codeRange = "Focused Range [line, character]: \(focusedContext.codeRange)" + let code = """ + Focused Code (start from line \(focusedContext.codeRange.start.line)): + ```\(context.language.rawValue) + \(focusedContext.code) + ``` """ + let fileAnnotations = focusedContext.otherLineAnnotations.isEmpty + ? "" + : """ + File Annotations: + \(focusedContext.otherLineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + """ + let codeAnnotations = focusedContext.lineAnnotations.isEmpty + ? "" + : """ + Code Annotations: + \(focusedContext.lineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + """ + return [ + start, + relativePath, + language, + fileAnnotations, + codeContext, + codeRange, + codeAnnotations, + code, + end, + ] + .filter { !$0.isEmpty } + .joined(separator: "\n") + } else { + let selectionRange = "Selection Range [line, character]: \(context.selectionRange)" + let lineAnnotations = context.lineAnnotations.isEmpty + ? "" + : """ + Line Annotations: + \(context.lineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + """ + + return [ + start, + relativePath, + language, + lineAnnotations, + selectionRange, + end, + ] + .filter { !$0.isEmpty } + .joined(separator: "\n") } - - return result } } +struct ActiveDocumentContext { + var relativePath: String + var language: CodeLanguage + var fileContent: String + var lines: [String] + var selectedCode: String + var selectionRange: CursorRange + var lineAnnotations: [EditorInformation.LineAnnotation] + var imports: [String] + + struct FocusedContext { + var context: String + var contextRange: CursorRange + var codeRange: CursorRange + var code: String + var lineAnnotations: [EditorInformation.LineAnnotation] + var otherLineAnnotations: [EditorInformation.LineAnnotation] + } + + var focusedContext: FocusedContext? + + mutating func moveToFocusedCode() { + moveToCodeContainingRange(selectionRange) + } + + mutating func moveToCodeAroundLine(_ line: Int) { + moveToCodeContainingRange(.init( + start: .init(line: line, character: 0), + end: .init(line: line, character: 0) + )) + } + + mutating func expandFocusedRangeToContextRange() { + guard let focusedContext else { return } + moveToCodeContainingRange(focusedContext.contextRange) + } + + mutating func moveToCodeContainingRange(_ range: CursorRange) { + let finder: FocusedCodeFinder = { + switch language { + case .builtIn(.swift): + return SwiftFocusedCodeFinder() + default: + return UnknownLanguageFocusedCodeFinder() + } + }() + + let codeContext = finder.findFocusedCode( + containingRange: range, + activeDocumentContext: self + ) + + imports = codeContext.imports + + let startLine = codeContext.focusedRange.start.line + let endLine = codeContext.focusedRange.end.line + var matchedAnnotations = [EditorInformation.LineAnnotation]() + var otherAnnotations = [EditorInformation.LineAnnotation]() + for annotation in lineAnnotations { + if annotation.line >= startLine && annotation.line <= endLine { + matchedAnnotations.append(annotation) + } else { + otherAnnotations.append(annotation) + } + } + + focusedContext = .init( + context: { + switch codeContext.scope { + case .file: + return "File" + case .top: + return "Top level of the file" + case let .scope(signature): + return signature + } + }(), + contextRange: codeContext.contextRange, + codeRange: codeContext.focusedRange, + code: codeContext.focusedCode, + lineAnnotations: matchedAnnotations, + otherLineAnnotations: otherAnnotations + ) + } + + mutating func update(_ info: EditorInformation) { + /// Whenever the file content, relative path, or selection range changes, + /// we should reset the context. + let changed: Bool = { + if info.relativePath != relativePath { return true } + if info.editorContent?.content != fileContent { return true } + if let range = info.editorContent?.selections.first, + range != selectionRange { return true } + return false + }() + + relativePath = info.relativePath + language = info.language + fileContent = info.editorContent?.content ?? "" + lines = info.editorContent?.lines ?? [] + selectedCode = info.selectedContent + selectionRange = info.editorContent?.selections.first ?? .zero + lineAnnotations = info.editorContent?.lineAnnotations ?? [] + imports = [] + + if changed { + moveToFocusedCode() + } + } +} diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift new file mode 100644 index 00000000..c63294d5 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift @@ -0,0 +1,83 @@ +import Foundation +import SuggestionModel + +struct CodeContext: Equatable { + enum Scope: Equatable { + case file + case top + case scope(signature: String) + } + + var scope: Scope + var contextRange: CursorRange + var focusedRange: CursorRange + var focusedCode: String + var imports: [String] + + static var empty: CodeContext { + .init(scope: .file, contextRange: .zero, focusedRange: .zero, focusedCode: "", imports: []) + } +} + +protocol FocusedCodeFinder { + func findFocusedCode( + containingRange: CursorRange, + activeDocumentContext: ActiveDocumentContext + ) -> CodeContext +} + +struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { + func findFocusedCode( + containingRange: CursorRange, + activeDocumentContext: ActiveDocumentContext + ) -> CodeContext { + guard !activeDocumentContext.lines.isEmpty else { return .empty } + + // when user is not selecting any code. + if containingRange.start == containingRange.end { + // search up and down for up to 7 lines. + let lines = activeDocumentContext.lines + var startLineIndex = max(containingRange.start.line - 3, 0) + var endLineIndex = min(containingRange.start.line + 3, lines.count - 1) + if endLineIndex - startLineIndex <= 6, startLineIndex > 0 { + startLineIndex = max(startLineIndex - (6 - (endLineIndex - startLineIndex)), 0) + } + let focusedLines = lines[startLineIndex...endLineIndex] + + let contextStartLine = max(startLineIndex - 3, 0) + let contextEndLine = min(endLineIndex + 3, lines.count - 1) + + return .init( + scope: .top, + contextRange: .init( + start: .init(line: contextStartLine, character: 0), + end: .init(line: contextEndLine, character: 0) + ), + focusedRange: containingRange, + focusedCode: focusedLines.joined(separator: "\n"), + imports: [] + ) + } + + let startLine = max(containingRange.start.line, 0) + let endLine = min(containingRange.end.line, activeDocumentContext.lines.count - 1) + + if endLine < startLine { return .empty } + + let focusedLines = activeDocumentContext.lines[startLine...endLine] + let contextStartLine = max(startLine - 3, 0) + let contextEndLine = min(endLine + 3, activeDocumentContext.lines.count - 1) + + return CodeContext( + scope: .top, + contextRange: .init( + start: .init(line: contextStartLine, character: 0), + end: .init(line: contextEndLine, character: 0) + ), + focusedRange: containingRange, + focusedCode: focusedLines.joined(separator: "\n"), + imports: [] + ) + } +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift new file mode 100644 index 00000000..3ebc4cd6 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -0,0 +1,593 @@ +import ASTParser +import Foundation +import SuggestionModel +import SwiftParser +import SwiftSyntax + +struct SwiftFocusedCodeFinder: FocusedCodeFinder { + func findFocusedCode( + containingRange: CursorRange, + activeDocumentContext: ActiveDocumentContext + ) -> CodeContext { + let source = activeDocumentContext.fileContent + let tree = Parser.parse(source: source) + let visitor = SwiftScopeHierarchySyntaxVisitor( + tree: tree, + code: source, + range: containingRange + ) + var nodes = visitor.findScopeHierarchy() + + let code = EditorInformation.code(in: activeDocumentContext.lines, inside: containingRange) + .code + + while let node = nodes.first { + nodes.removeFirst() + if var context = contextContainingNode( + node, + parentNodes: nodes, + tree: tree, + activeDocumentContext: activeDocumentContext + ) { + if code.isEmpty { + context.focusedRange = context.contextRange + context.focusedCode = EditorInformation.code( + in: activeDocumentContext.lines, + inside: context.contextRange + ).code + } else { + context.focusedRange = containingRange + context.focusedCode = code + } + + context.imports = visitor.imports + return context + } + } + return .init( + scope: .file, + contextRange: .zero, + focusedRange: containingRange, + focusedCode: code, + imports: visitor.imports + ) + } +} + +extension SwiftFocusedCodeFinder { + func contextContainingNode( + _ node: SyntaxProtocol, + parentNodes: [SyntaxProtocol], + tree: SourceFileSyntax, + activeDocumentContext: ActiveDocumentContext + ) -> CodeContext? { + let source = activeDocumentContext.fileContent + + func convertRange(_ node: SyntaxProtocol) -> CursorRange { + .init(sourceRange: node.sourceRange(converter: .init(file: source, tree: tree))) + } + + func extractText(_ node: SyntaxProtocol) -> String { + EditorInformation.code(in: activeDocumentContext.lines, inside: convertRange(node)).code + } + + switch node { + case let node as StructDeclSyntax: + let type = node.structKeyword.text + let name = node.identifier.text + return .init( + scope: .scope( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as ClassDeclSyntax: + let type = node.classKeyword.text + let name = node.identifier.text + return .init( + scope: .scope( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as EnumDeclSyntax: + let type = node.enumKeyword.text + let name = node.identifier.text + return .init( + scope: .scope( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as ActorDeclSyntax: + let type = node.actorKeyword.text + let name = node.identifier.text + return .init( + scope: .scope( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as MacroDeclSyntax: + let type = node.macroKeyword.text + let name = node.identifier.text + return .init( + scope: .scope( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as ProtocolDeclSyntax: + let type = node.protocolKeyword.text + let name = node.identifier.text + return .init( + scope: .scope( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as ExtensionDeclSyntax: + let type = node.extensionKeyword.text + let name = node.extendedType.trimmedDescription + return .init( + scope: .scope( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as FunctionDeclSyntax: + let type = node.funcKeyword.text + let name = node.identifier.text + let signature = node.signature.trimmedDescription + + return .init( + scope: .scope( + signature: "\(type) \(name)\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as VariableDeclSyntax: + let type = node.bindingSpecifier.trimmedDescription + let name = node.bindings.first?.pattern.trimmedDescription ?? "" + let signature = node.bindings.first?.initializer?.value.trimmedDescription ?? "" + + return .init( + scope: .scope( + signature: "\(type) \(name)\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as AccessorDeclSyntax: + let keyword = node.accessorSpecifier.text + var signature = keyword + + for node in parentNodes { + if let (type, name, sig) = findAssigningToVariable(node) { + signature = "\(keyword) of \(type) \(name):\(sig)" + break + } + + if let node = node as? SubscriptDeclSyntax { + let genericPClause = node.genericWhereClause?.trimmedDescription ?? "" + let pClause = node.parameterClause.trimmedDescription + let whereClause = node.genericWhereClause?.trimmedDescription ?? "" + signature = "\(keyword) of subscript\(genericPClause)(\(pClause))\(whereClause)" + break + } + } + + return .init( + scope: .scope( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as SubscriptDeclSyntax: + let genericPClause = node.genericWhereClause?.trimmedDescription ?? "" + let pClause = node.parameterClause.trimmedDescription + let whereClause = node.genericWhereClause?.trimmedDescription ?? "" + let signature = "subscript\(genericPClause)(\(pClause))\(whereClause)" + + return .init( + scope: .scope( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as InitializerDeclSyntax: + var signature = "init" + for node in parentNodes { + if let typeName = findTypeNameFromNode(node) { + signature = "\(typeName).init" + break + } + } + + return .init( + scope: .scope( + signature: "\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as DeinitializerDeclSyntax: + var signature = "deinit" + for node in parentNodes { + if let typeName = findTypeNameFromNode(node) { + signature = "\(typeName).deinit" + break + } + } + + return .init( + scope: .scope( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + ), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as ClosureExprSyntax: + var signature = "anonymous closure" + + for node in parentNodes { + if let (type, name, sig) = findAssigningToVariable(node) { + signature = "closure assigned to \(type) \(name)\(sig)" + break + } + } + + return .init( + scope: .scope(signature: signature), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as FunctionCallExprSyntax: + var signature = "anonymous function call" + for node in parentNodes { + if let (type, name, sig) = findAssigningToVariable(node) { + signature = "function call assigned to \(type) \(name)\(sig)" + break + } + } + + return .init( + scope: .scope(signature: signature), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + case let node as SwitchCaseSyntax: + return .init( + scope: .scope(signature: node.trimmedDescription), + contextRange: convertRange(node), + focusedRange: .zero, + focusedCode: "", + imports: [] + ) + + default: + return nil + } + } + + func findAssigningToVariable(_ node: SyntaxProtocol) + -> (type: String, name: String, signature: String)? + { + if let node = node as? VariableDeclSyntax { + let type = node.bindingSpecifier.trimmedDescription + let name = node.bindings.first?.pattern.trimmedDescription ?? "" + let sig = node.bindings.first?.initializer?.value.trimmedDescription ?? "" + return (type, name, sig) + } + return nil + } + + func findTypeNameFromNode(_ node: SyntaxProtocol) -> String? { + switch node { + case let node as ClassDeclSyntax: + return node.identifier.text + case let node as StructDeclSyntax: + return node.identifier.text + case let node as EnumDeclSyntax: + return node.identifier.text + case let node as ActorDeclSyntax: + return node.identifier.text + case let node as ProtocolDeclSyntax: + return node.identifier.text + case let node as ExtensionDeclSyntax: + return node.extendedType.trimmedDescription + default: + return nil + } + } +} + +extension CursorRange { + init(sourceRange: SourceRange) { + self.init( + start: .init(line: sourceRange.start.line - 1, character: sourceRange.start.column - 1), + end: .init(line: sourceRange.end.line - 1, character: sourceRange.end.column - 1) + ) + } +} + +// MARK: - Helper Types + +protocol AttributeAndModifierApplicableSyntax { + var attributes: AttributeListSyntax? { get } + var modifiers: ModifierListSyntax? { get } +} + +extension AttributeAndModifierApplicableSyntax { + func modifierAndAttributeText(_ extractText: (SyntaxProtocol) -> String) -> String { + let attributeTexts = attributes?.map { attribute in + extractText(attribute) + } ?? [] + let modifierTexts = modifiers?.map { modifier in + extractText(modifier) + } ?? [] + let prefix = (attributeTexts + modifierTexts).joined(separator: " ") + return prefix + } +} + +extension StructDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ClassDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension EnumDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ActorDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension MacroDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension MacroExpansionDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ProtocolDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension ExtensionDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension FunctionDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension VariableDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension InitializerDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension DeinitializerDeclSyntax: AttributeAndModifierApplicableSyntax {} +extension AccessorDeclSyntax: AttributeAndModifierApplicableSyntax { + var modifiers: SwiftSyntax.ModifierListSyntax? { nil } +} + +extension SubscriptDeclSyntax: AttributeAndModifierApplicableSyntax {} + +protocol InheritanceClauseApplicableSyntax { + var inheritanceClause: TypeInheritanceClauseSyntax? { get } +} + +extension StructDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ClassDeclSyntax: InheritanceClauseApplicableSyntax {} +extension EnumDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ActorDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ProtocolDeclSyntax: InheritanceClauseApplicableSyntax {} +extension ExtensionDeclSyntax: InheritanceClauseApplicableSyntax {} + +extension InheritanceClauseApplicableSyntax { + func inheritanceClauseTexts(_ extractText: (SyntaxProtocol) -> String) -> String { + inheritanceClause?.inheritedTypeCollection.map { clause in + extractText(clause).trimmingCharacters(in: [","]) + }.joined(separator: ", ") ?? "" + } +} + +extension String { + func prefixedModifiers(_ text: String) -> String { + if text.isEmpty { + return self + } + return "\(text) \(self)" + } + + func suffixedInheritance(_ text: String) -> String { + if text.isEmpty { + return self + } + return "\(self): \(text)" + } +} + +// MARK: - Visitors + +extension SwiftFocusedCodeFinder { + final class SwiftScopeHierarchySyntaxVisitor: SyntaxVisitor { + let tree: SyntaxProtocol + let code: String + let range: CursorRange + var imports: [String] = [] + private var _scopeHierarchy: [SyntaxProtocol] = [] + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy(_ node: some SyntaxProtocol) -> [SyntaxProtocol] { + walk(node) + return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } + } + + /// The nodes containing the current range, sorted from inner to outer. + func findScopeHierarchy() -> [SyntaxProtocol] { + walk(tree) + return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } + } + + init(tree: SyntaxProtocol, code: String, range: CursorRange) { + self.tree = tree + self.code = code + self.range = range + super.init(viewMode: .all) + } + + func skipChildrenIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if !nodeContainsRange(node) { return .skipChildren } + return .visitChildren + } + + func captureNodeIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if !nodeContainsRange(node) { return .skipChildren } + _scopeHierarchy.append(node) + return .visitChildren + } + + func nodeContainsRange(_ node: SyntaxProtocol) -> Bool { + let sourceRange = node.sourceRange(converter: .init(file: code, tree: tree)) + let cursorRange = CursorRange(sourceRange: sourceRange) + return cursorRange.strictlyContains(range) + } + + // skip if possible + + override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + override func visit(_ node: MemberDeclBlockSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + override func visit(_ node: MemberDeclListItemSyntax) -> SyntaxVisitorContinueKind { + skipChildrenIfPossible(node) + } + + // capture if possible + + override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { + imports.append(node.trimmedDescription) + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: MacroDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: AccessorDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: DeinitializerDeclSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + + override func visit(_ node: SwitchCaseSyntax) -> SyntaxVisitorContinueKind { + captureNodeIfPossible(node) + } + } +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift new file mode 100644 index 00000000..52ee9312 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift @@ -0,0 +1,54 @@ +import ASTParser +import Foundation +import OpenAIService +import SuggestionModel + +struct ExpandFocusRangeFunction: ChatGPTFunction { + struct Arguments: Codable {} + + struct Result: ChatGPTFunctionResult { + var text: String + + var botReadableContent: String { + text + } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "expandFocusRange" + } + + var description: String { + "When you need more context from the code, you can call it to expand focus range to context range in user editing document context" + } + + var argumentSchema: JSONSchemaValue { [ + .type: "object", + .properties: [:], + ] } + + weak var contextCollector: ActiveDocumentChatContextCollector? + + init(contextCollector: ActiveDocumentChatContextCollector) { + self.contextCollector = contextCollector + } + + func prepare() async { + await reportProgress("Finding the focused code..") + } + + func call(arguments: Arguments) async throws -> Result { + await reportProgress("Finding the focused code..") + contextCollector?.activeDocumentContext?.expandFocusedRangeToContextRange() + guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { + let progress = "Failed to move to focused code." + await reportProgress(progress) + return .init(text: progress) + } + let progress = "Looking at \(newContext.codeRange) inside \(newContext.context)" + await reportProgress(progress) + return .init(text: progress) + } +} diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift new file mode 100644 index 00000000..6e39fc58 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift @@ -0,0 +1,57 @@ +import ASTParser +import Foundation +import OpenAIService +import SuggestionModel + +struct MoveToCodeAroundLineFunction: ChatGPTFunction { + struct Arguments: Codable { + var line: Int + } + + struct Result: ChatGPTFunctionResult { + var text: String + + var botReadableContent: String { + text + } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "moveToCodeAroundLine" + } + + var description: String { + "Move user editing document context to code around a line when you need to answer a question the code in the line" + } + + var argumentSchema: JSONSchemaValue { [ + .type: "object", + .properties: [:], + ] } + + weak var contextCollector: ActiveDocumentChatContextCollector? + + init(contextCollector: ActiveDocumentChatContextCollector) { + self.contextCollector = contextCollector + } + + func prepare() async { + await reportProgress("Finding code around..") + } + + func call(arguments: Arguments) async throws -> Result { + await reportProgress("Finding code around line \(arguments.line)..") + contextCollector?.activeDocumentContext?.moveToCodeAroundLine(arguments.line) + guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { + let progress = "Failed to move to focused code." + await reportProgress(progress) + return .init(text: progress) + } + let progress = "Looking at \(newContext.codeRange) inside \(newContext.context)" + await reportProgress(progress) + return .init(text: progress) + } +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift new file mode 100644 index 00000000..45e9e84d --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift @@ -0,0 +1,54 @@ +import ASTParser +import Foundation +import OpenAIService +import SuggestionModel + +struct MoveToFocusedCodeFunction: ChatGPTFunction { + struct Arguments: Codable {} + + struct Result: ChatGPTFunctionResult { + var text: String + + var botReadableContent: String { + text + } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "moveToFocusedCode" + } + + var description: String { + "Move user editing document context to the selected or focused code" + } + + var argumentSchema: JSONSchemaValue { [ + .type: "object", + .properties: [:], + ] } + + weak var contextCollector: ActiveDocumentChatContextCollector? + + init(contextCollector: ActiveDocumentChatContextCollector) { + self.contextCollector = contextCollector + } + + func prepare() async { + await reportProgress("Finding the focused code..") + } + + func call(arguments: Arguments) async throws -> Result { + await reportProgress("Finding the focused code..") + contextCollector?.activeDocumentContext?.moveToFocusedCode() + guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { + let progress = "Failed to move to focused code." + await reportProgress(progress) + return .init(text: progress) + } + let progress = "Looking at \(newContext.codeRange) inside \(newContext.context)" + await reportProgress(progress) + return .init(text: progress) + } +} diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift deleted file mode 100644 index 75526aaf..00000000 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetCodeFunction.swift +++ /dev/null @@ -1,155 +0,0 @@ -import ASTParser -import Foundation -import OpenAIService -import SuggestionModel - -struct GetCodeFunction: ChatGPTFunction { - enum CodeType: String, Codable { - case selected - case focused - } - - struct Arguments: Codable { - var codeType: CodeType - } - - struct Result: ChatGPTFunctionResult { - var relativePath: String - var code: String - var range: CursorRange - var context: CodeContext - var type: CodeType - var language: String - - var botReadableContent: String { - """ - File: \(relativePath) - Range: \(range) - \(type.rawValue) code - ```\(language) - \(code) - ``` - \(context) - """ - } - } - - var reportProgress: (String) async -> Void = { _ in } - - var name: String { - "getCode" - } - - var description: String { - "Get selected or focused code from the active document." - } - - var argumentSchema: JSONSchemaValue { [ - .type: "object", - .properties: [:], - ] } - - func prepare() async { - await reportProgress("Reading code..") - } - - func call(arguments: Arguments) async throws -> Result { - await reportProgress("Reading code..") - let content = getEditorInformation() - let selectionRange = content.editorContent?.selections.first ?? .outOfScope - let editorContent = { - if selectionRange.start == selectionRange.end { - return content.editorContent?.content ?? "" - } else { - return content.selectedContent - } - }() - - let language = content.language.rawValue - let type = CodeType.selected - let relativePath = content.documentURL.path - .replacingOccurrences(of: content.projectURL.path, with: "") - let range = selectionRange - - await reportProgress("Finish reading code..") - return .init( - relativePath: relativePath, - code: editorContent, - range: range, - context: .top, - type: type, - language: language - ) - } -} - -struct GetCodeResultParser { - let editorInformation: EditorInformation - - func parse() -> GetCodeFunction.Result { - let language = editorInformation.language.rawValue - let relativePath = editorInformation.relativePath - let selectionRange = editorInformation.editorContent?.selections.first - let code = { - if editorInformation.selectedContent.isEmpty { - return editorInformation.selectedLines.first ?? "" - } - return editorInformation.selectedContent - }() - - guard let astReader = createASTReader() else { - return .init( - relativePath: relativePath, - code: code, - range: selectionRange ?? .zero, - context: .top, - type: .selected, - language: language - ) - } - - if let selectionRange { - let context = astReader.contextContainingRange( - selectionRange, - in: editorInformation.editorContent?.content ?? "" - ) - return .init( - relativePath: relativePath, - code: code, - range: selectionRange, - context: .top, - type: .selected, - language: language - ) - } - - return .init( - relativePath: relativePath, - code: "", - range: selectionRange ?? .zero, - context: .top, - type: .focused, - language: language - ) - } - - func createASTReader() -> ASTReader? { - switch editorInformation.language { - case .builtIn(.swift): - return SwiftASTReader() - case .builtIn(.objc), .builtIn(.objcpp): - return SwiftASTReader() - default: - return nil - } - } -} - -enum ScopeType: String, CaseIterable { - case protocolDeclaration = "protocol_declaration" - case classDeclaration = "class_declaration" - case functionDeclaration = "function_declaration" - case propertyDeclaration = "property_declaration" - case computedProperty = "computed_property" -} - diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 94cd94ac..7c88aa27 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -2,7 +2,9 @@ import Foundation import SuggestionModel import XcodeInspector -func getEditorInformation() -> EditorInformation { +func getEditorInformation() -> EditorInformation? { + guard !XcodeInspector.shared.xcodes.isEmpty else { return nil } + let editorContent = XcodeInspector.shared.focusedEditor?.content let documentURL = XcodeInspector.shared.activeDocumentURL let projectURL = XcodeInspector.shared.activeProjectURL diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index 93486e29..c57d336c 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -13,9 +13,8 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { scopes: Set, content: String ) -> ChatContext? { - let content = getEditorInformation() - let relativePath = content.documentURL.path - .replacingOccurrences(of: content.projectURL.path, with: "") + guard let content = getEditorInformation() else { return nil } + let relativePath = content.relativePath let selectionRange = content.editorContent?.selections.first ?? .outOfScope let editorContent = { if scopes.contains("file") || scopes.contains("f") { diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift index 6db41a60..9d3dfaa0 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift @@ -1,13 +1,13 @@ import SuggestionModel -extension CursorPosition: CustomStringConvertible { - var description: String { +extension CursorPosition { + var text: String { "[\(line), \(character)]" } } -extension CursorRange: CustomStringConvertible { - var description: String { +extension CursorRange { + var text: String { "\(start.description) - \(end.description)" } } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift deleted file mode 100644 index 28da19d0..00000000 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/SwiftASTReader.swift +++ /dev/null @@ -1,367 +0,0 @@ -import ASTParser -import Foundation -import SuggestionModel -import SwiftParser -import SwiftSyntax - -protocol ASTReader { - func contextContainingRange( - _ range: CursorRange, - code: String, - codeLines: [String] - ) -> CodeContext -} - -struct CodeContext: CustomStringConvertible { - enum Scope { - case top - case scope( - type: String, - identifier: String, - range: CursorRange - ) - } - - var scope: Scope - var extraKnowledge: String = "" - - var description: String { - switch scope { - case .top: - return "\(extraKnowledge)" - case let .scope(type, identifier, range): - return """ - Inside \(type) \(identifier), range \(range) - \(extraKnowledge) - """ - } - } -} - -struct SwiftASTReader: ASTReader { - enum ScopeType: String, CaseIterable { - case protocolDeclaration = "protocol_declaration" - case classDeclaration = "class_declaration" - case functionDeclaration = "function_declaration" - case propertyDeclaration = "property_declaration" - case computedProperty = "computed_property" - } - - final class SwiftScopeHierarchySyntaxVisitor: SyntaxVisitor { - let tree: SyntaxProtocol - let code: String - let range: CursorRange - private var _scopeHierarchy: [SyntaxProtocol] = [] - - /// The nodes containing the current range, sorted from inner to outer. - func findScopeHierarchy(_ node: some SyntaxProtocol) -> [SyntaxProtocol] { - walk(node) - return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } - } - - /// The nodes containing the current range, sorted from inner to outer. - func findScopeHierarchy() -> [SyntaxProtocol] { - walk(tree) - return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } - } - - init(tree: SyntaxProtocol, code: String, range: CursorRange) { - self.tree = tree - self.code = code - self.range = range - super.init(viewMode: .all) - } - - func skipChildrenIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { - if !nodeContainsRange(node) { return .skipChildren } - return .visitChildren - } - - func captureNodeIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { - if !nodeContainsRange(node) { return .skipChildren } - _scopeHierarchy.append(node) - return .visitChildren - } - - func nodeContainsRange(_ node: SyntaxProtocol) -> Bool { - let sourceRange = node.sourceRange(converter: .init(file: code, tree: tree)) - let cursorRange = CursorRange(sourceRange: sourceRange) - return cursorRange.contains(range) - } - - // skip if possible - - override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind { - skipChildrenIfPossible(node) - } - - override func visit(_ node: MemberDeclBlockSyntax) -> SyntaxVisitorContinueKind { - skipChildrenIfPossible(node) - } - - override func visit(_ node: MemberDeclListItemSyntax) -> SyntaxVisitorContinueKind { - skipChildrenIfPossible(node) - } - - // capture if possible - - override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: MacroDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: MacroExpansionDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - - override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { - captureNodeIfPossible(node) - } - } - - func createExtraKnowledge(_ code: String) -> String { - var all = [String]() - if code.contains("macro") { - all.append("macro: introduced since Swift 5.9") - } - return all.joined() - } - - func contextContainingRange( - _ range: CursorRange, - code: String, - codeLines: [String] - ) -> CodeContext { - let tree = Parser.parse(source: code) - let visitor = SwiftScopeHierarchySyntaxVisitor(tree: tree, code: code, range: range) - let nodes = visitor.findScopeHierarchy() - - func convertRange(_ node: SyntaxProtocol) -> CursorRange { - .init(sourceRange: node.sourceRange(converter: .init(file: code, tree: tree))) - } - - if let node = nodes.first { - switch node.kind { - case .structDecl: - guard let node = node as? StructDeclSyntax else { break } - - return .init(scope: .scope( - type: node.structKeyword.text, - identifier: node.identifier.text, - range: convertRange(node) - )) - case .classDecl: - guard let node = node as? ClassDeclSyntax else { break } - - return .init(scope: .scope( - type: node.classKeyword.text, - identifier: node.identifier.text, - range: convertRange(node) - )) - case .enumDecl: - guard let node = node as? EnumDeclSyntax else { break } - - return .init(scope: .scope( - type: node.enumKeyword.text, - identifier: node.identifier.text, - range: convertRange(node) - )) - case .actorDecl: - guard let node = node as? ActorDeclSyntax else { break } - - return .init(scope: .scope( - type: node.actorKeyword.text, - identifier: node.identifier.text, - range: convertRange(node) - )) - case .macroDecl: - guard let node = node as? MacroDeclSyntax else { break } - - return .init(scope: .scope( - type: node.macroKeyword.text, - identifier: node.identifier.text, - range: convertRange(node) - )) - case .macroExpansionDecl: - guard let node = node as? MacroExpansionDeclSyntax else { break } - - return .init(scope: .scope( - type: "macro expansion", - identifier: node.macro.text, - range: convertRange(node) - )) - case .protocolDecl: - guard let node = node as? ProtocolDeclSyntax else { break } - - return .init(scope: .scope( - type: node.protocolKeyword.text, - identifier: node.identifier.text, - range: convertRange(node) - )) - case .extensionDecl: - guard let node = node as? ExtensionDeclSyntax else { break } - - return .init(scope: .scope( - type: node.extensionKeyword.text, - identifier: node.extendedType.description, - range: convertRange(node) - )) - case .functionDecl: - guard let node = node as? FunctionDeclSyntax else { break } - - return .init(scope: .scope( - type: node.funcKeyword.text, - identifier: node.identifier.text, - range: convertRange(node) - )) - } - } - - return .init(scope: .top) - } - - func contextContainingRange2( - _ range: CursorRange, - code: String, - codeLines: [String] - ) -> CodeContext { - let parser = ASTParser(language: .swift) - guard let tree = parser.parse(code) else { - return .init(scope: .top) - } - - guard let node = tree.smallestNodeContainingRange(range, filter: { node in - ScopeType.allCases.map { $0.rawValue }.contains(node.nodeType) - }) else { - return .init(scope: .top) - } - - switch ScopeType(rawValue: node.nodeType ?? "") { - case .protocolDeclaration: - // Example: - // comment [0, 0] - [1, 10] - // comment [1, 0] - [1, 10] - // protocol_declaration [2, 0] - [5, 1] - // protocol [2, 0] - [2, 8] - // type_identifier [2, 9] - [2, 15] - // protocol_body [2, 16] - [5, 1] - // { [2, 16] - [2, 17] - // protocol_property_declaration [3, 4] - [3, 28] - // ... - // protocol_function_declaration [4, 4] - [4, 16] - // ... - // } [5, 0] - [5, 1] - - var identifier = "unknown" - for child in node.children { - if child.nodeType == "type_identifier" { - let range = CursorRange(pointRange: child.pointRange) - let (code, _) = EditorInformation.code(in: codeLines, inside: range) - identifier = code - break - } - } - return .init(scope: .scope( - type: "protocol", - identifier: identifier, - range: .init(pointRange: node.pointRange) - )) - - case .classDeclaration: - // class_declaration [9, 0] - [14, 1] - // struct [9, 0] - [9, 6] - // type_identifier [9, 7] - [9, 10] - // : [9, 10] - [9, 11] - // inheritance_specifier [9, 12] - [9, 18] - // user_type [9, 12] - [9, 18] - // type_identifier [9, 12] - [9, 18] - // class_body [9, 19] - [14, 1] - // { [9, 19] - [9, 20] - // property_declaration [10, 4] - [10, 20] - // let [10, 4] - [10, 7] - // pattern [10, 8] - [10, 12] - // simple_identifier [10, 8] - [10, 12] - // type_annotation [10, 12] - [10, 20] - // : [10, 12] - [10, 13] - // user_type [10, 14] - [10, 20] - // type_identifier [10, 14] - [10, 20] - // function_declaration [11, 4] - [13, 5] - // ... - // } [14, 0] - [14, 1] - // can be struct, enum, class, or actor - - var type = "unknown" - var identifier = "unknown" - - for child in node.children { - switch child.nodeType { - case "struct": - type = "struct" - case "class": - type = "class" - case "enum": - type = "enum" - case "actor": - type = "actor" - case "type_identifier": - let range = CursorRange(pointRange: child.pointRange) - let (code, _) = EditorInformation.code(in: codeLines, inside: range) - identifier = code - default: continue - } - } - - return .init(scope: .scope( - type: type, - identifier: identifier, - range: .init(pointRange: node.pointRange) - )) - - case .functionDeclaration: - return .init(scope: .top) - case .propertyDeclaration: - return .init(scope: .top) - case .computedProperty: - return .init(scope: .top) - case .none: - return .init(scope: .top) - } - } -} - -extension CursorRange { - init(sourceRange: SourceRange) { - self.init( - start: .init(line: sourceRange.start.line - 1, character: sourceRange.start.column - 1), - end: .init(line: sourceRange.end.line - 1, character: sourceRange.end.column - 1) - ) - } -} - diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift index 77af988e..ef4b3b79 100644 --- a/Core/Sources/ChatService/AllContextCollector.swift +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -3,7 +3,7 @@ import ChatContextCollector import WebChatContextCollector let allContextCollectors: [any ChatContextCollector] = [ - LegacyActiveDocumentChatContextCollector(), + ActiveDocumentChatContextCollector(), WebChatContextCollector(), ] diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index eca176dc..3d5370a3 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -34,7 +34,13 @@ final class DynamicContextController { func updatePromptToMatchContent(systemPrompt: String, content: String) async throws { var content = content - let scopes = Self.parseScopes(&content) + var scopes = Self.parseScopes(&content) + if UserDefaults.shared.value(for: \.useSelectionScopeByDefaultInChatContext) { + scopes.insert("code") + } else { + scopes.insert("file") + } + functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) let oldMessages = await memory.history diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift new file mode 100644 index 00000000..89c5acbe --- /dev/null +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift @@ -0,0 +1,256 @@ +import Foundation +import SuggestionModel +import XCTest + +@testable import ActiveDocumentChatContextCollector + +final class SwiftASTReaderTests: XCTestCase { + func editorInformation(code: String) -> EditorInformation { + .init( + editorContent: .init( + content: code, + lines: code.components(separatedBy: "\n"), + selections: [], + cursorPosition: .outOfScope, + lineAnnotations: [] + ), + selectedContent: "", + selectedLines: [], + documentURL: URL(fileURLWithPath: ""), + projectURL: URL(fileURLWithPath: ""), + relativePath: "", + language: .builtIn(.swift) + ) + } + + func test_selecting_a_line_inside_the_function_the_scope_should_be_the_function() { + let code = """ + public struct A: B, C { + @ViewBuilder private func f(_ a: String) -> String { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 4, character: 0), + end: CursorPosition(line: 4, character: 13) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "@ViewBuilder private func f(_ a: String) -> String", + range: .init(start: .init(line: 1, character: 4), end: .init(line: 7, character: 5)) + ) + ) + } + + func test_selecting_a_function_inside_a_struct_the_scope_should_be_the_struct() { + let code = """ + @MainActor + public struct A: B, C { + func f() { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 7, character: 5) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "@MainActor public struct A: B, C", + range: .init(start: .init(line: 0, character: 0), end: .init(line: 9, character: 1)) + ) + ) + } + + func test_selecting_a_variable_inside_a_class_the_scope_should_be_the_class() { + let code = """ + @MainActor final public class A: P, K { + var a = 1 + var b = 2 + var c = 3 + var d = 4 + var e = 5 + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "@MainActor final public class A: P, K", + range: .init(start: .init(line: 0, character: 0), end: .init(line: 6, character: 1)) + ) + ) + } + + func test_selecting_a_function_inside_a_protocol_the_scope_should_be_the_protocol() { + let code = """ + public protocol A: Hashable { + func f() + func g() + func h() + func i() + func j() + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "public protocol A: Hashable", + range: .init(start: .init(line: 0, character: 0), end: .init(line: 6, character: 1)) + ) + ) + } + + func test_selecting_a_variable_inside_an_extension_the_scope_should_be_the_extension() { + let code = """ + private extension A: Equatable { + var a = 1 + var b = 2 + var c = 3 + var d = 4 + var e = 5 + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "private extension A: Equatable", + range: .init(start: .init(line: 0, character: 0), end: .init(line: 6, character: 1)) + ) + ) + } + + func test_selecting_a_static_function_from_an_actor_the_scope_should_be_the_actor() { + let code = """ + @gloablActor + public actor A { + static func f() {} + static func g() {} + static func h() {} + static func i() {} + static func j() {} + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 9) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "@gloablActor public actor A", + range: .init(start: .init(line: 0, character: 0), end: .init(line: 7, character: 1)) + ) + ) + } + + func test_selecting_a_case_inside_an_enum_the_scope_should_be_the_enum() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange( + start: CursorPosition(line: 3, character: 0), + end: CursorPosition(line: 3, character: 9) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "@MainActor public indirect enum A", + range: .init(start: .init(line: 0, character: 0), end: .init(line: 8, character: 1)) + ) + ) + } + + func test_selecting_a_line_inside_computed_variable_the_scope_should_be_the_variable() { + let code = """ + struct A { + @SomeWrapper public private(set) var a: Int { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 9) + ) + let context = SwiftASTReader().contextContainingRange( + range, + editorInformation: editorInformation(code: code) + ) + XCTAssertEqual( + context.scope, + .scope( + signature: "@SomeWrapper public private(set) var a: Int", + range: .init(start: .init(line: 1, character: 0), end: .init(line: 7, character: 1)) + ) + ) + } + + func test_selecting_a_line_in_freestanding_macro_the_scope_should_be_the_macro() { + + } +} + diff --git a/Tool/Package.swift b/Tool/Package.swift index 201e0141..2c819037 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -112,7 +112,10 @@ let package = Package( .target( name: "SuggestionModel", - dependencies: ["LanguageClient"] + dependencies: [ + "LanguageClient", + .product(name: "Parsing", package: "swift-parsing"), + ] ), .testTarget( @@ -140,7 +143,7 @@ let package = Package( .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), - + .target( name: "SharedUIComponents", dependencies: [ @@ -152,6 +155,7 @@ let package = Package( .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), .target(name: "ASTParser", dependencies: [ + "SuggestionModel", .product(name: "SwiftTreeSitter", package: "SwiftTreeSitter"), .product(name: "TreeSitterObjC", package: "tree-sitter-objc"), .product(name: "TreeSitterSwift", package: "tree-sitter-swift"), diff --git a/Tool/Sources/ASTParser/TreeCursor.swift b/Tool/Sources/ASTParser/TreeCursor.swift index 7898582f..7cade565 100644 --- a/Tool/Sources/ASTParser/TreeCursor.swift +++ b/Tool/Sources/ASTParser/TreeCursor.swift @@ -22,6 +22,10 @@ protocol Cursor { } extension TreeCursor: Cursor { + func goToNextSibling() -> Bool { + gotoNextSibling() + } + func goToParent() -> Bool { gotoParent() } diff --git a/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionModel/EditorInformation.swift index 3de6b95a..9746f375 100644 --- a/Tool/Sources/SuggestionModel/EditorInformation.swift +++ b/Tool/Sources/SuggestionModel/EditorInformation.swift @@ -1,6 +1,13 @@ import Foundation +import Parsing public struct EditorInformation { + public struct LineAnnotation { + public var type: String + public var line: Int + public var message: String + } + public struct SourceEditorContent { /// The content of the source editor. public var content: String @@ -11,7 +18,7 @@ public struct EditorInformation { /// The cursor position of the source editor. public var cursorPosition: CursorPosition /// Line annotations of the source editor. - public var lineAnnotations: [String] + public var lineAnnotations: [LineAnnotation] public var selectedContent: String { if let range = selections.first { @@ -40,7 +47,7 @@ public struct EditorInformation { self.lines = lines self.selections = selections self.cursorPosition = cursorPosition - self.lineAnnotations = lineAnnotations + self.lineAnnotations = lineAnnotations.map(EditorInformation.parseLineAnnotation) } } @@ -71,7 +78,7 @@ public struct EditorInformation { } public func code(in range: CursorRange) -> String { - return EditorInformation.code(in: selectedLines, inside: range).code + return EditorInformation.code(in: editorContent?.lines ?? [], inside: range).code } public static func lines(in code: [String], containing range: CursorRange) -> [String] { @@ -86,16 +93,41 @@ public struct EditorInformation { inside range: CursorRange ) -> (code: String, lines: [String]) { let rangeLines = lines(in: code, containing: range) - var selectedContent = rangeLines - if !selectedContent.isEmpty { - selectedContent[0] = String(selectedContent[0].dropFirst(range.start.character)) - selectedContent[selectedContent.endIndex - 1] = String( - selectedContent[selectedContent.endIndex - 1].dropLast( - selectedContent[selectedContent.endIndex - 1].count - range.end.character + var content = rangeLines + if !content.isEmpty { + content[content.endIndex - 1] = String( + content[content.endIndex - 1].dropLast( + content[content.endIndex - 1].count - range.end.character ) ) + content[0] = String(content[0].dropFirst(range.start.character)) + } + return (content.joined(), rangeLines) + } + + /// Error Line 25: FileName.swift:25 Cannot convert Type + static func parseLineAnnotation(_ annotation: String) -> LineAnnotation { + let lineAnnotationParser = Parse(input: Substring.self) { + PrefixUpTo(":") + ":" + PrefixUpTo(":") + ":" + Int.parser() + Prefix(while: { _ in true }) + }.map { (prefix: Substring, _: Substring, line: Int, message: Substring) in + let type = String(prefix.split(separator: " ").first ?? prefix) + return LineAnnotation( + type: type.trimmingCharacters(in: .whitespacesAndNewlines), + line: line, + message: message.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + do { + return try lineAnnotationParser.parse(annotation[...]) + } catch { + return .init(type: "", line: 0, message: annotation) } - return (selectedContent.joined(), rangeLines) } } diff --git a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift index d0773c65..ac49e250 100644 --- a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift @@ -5,9 +5,13 @@ public typealias CursorPosition = LanguageServerProtocol.Position public extension CursorPosition { static let zero = CursorPosition(line: 0, character: 0) static var outOfScope: CursorPosition { .init(line: -1, character: -1) } + + var readableText: String { + return "[\(line), \(character)]" + } } -public struct CursorRange: Codable, Hashable, Sendable { +public struct CursorRange: Codable, Hashable, Sendable, Equatable, CustomStringConvertible { public static let zero = CursorRange(start: .zero, end: .zero) public var start: CursorPosition @@ -24,10 +28,14 @@ public struct CursorRange: Codable, Hashable, Sendable { } public func contains(_ position: CursorPosition) -> Bool { - return position > start && position < end + return position >= start && position <= end } public func contains(_ range: CursorRange) -> Bool { + return range.start >= start && range.end <= end + } + + public func strictlyContains(_ range: CursorRange) -> Bool { return range.start > start && range.end < end } @@ -38,6 +46,14 @@ public struct CursorRange: Codable, Hashable, Sendable { public var isEmpty: Bool { return start == end } + + public static func == (lhs: CursorRange, rhs: CursorRange) -> Bool { + return lhs.start == rhs.start && lhs.end == rhs.end + } + + public var description: String { + return "\(start.readableText) - \(end.readableText)" + } } public extension CursorRange { diff --git a/Tool/Tests/SuggestionModelTests/LineAnnotationParsingTests.swift b/Tool/Tests/SuggestionModelTests/LineAnnotationParsingTests.swift new file mode 100644 index 00000000..1471aa33 --- /dev/null +++ b/Tool/Tests/SuggestionModelTests/LineAnnotationParsingTests.swift @@ -0,0 +1,14 @@ +import Foundation +import XCTest + +@testable import SuggestionModel + +class LineAnnotationParsingTests: XCTestCase { + func test_parse_line_annotation() { + let annotation = "Error Line 25: FileName.swift:25 Cannot convert Type" + let parsed = EditorInformation.parseLineAnnotation(annotation) + XCTAssertEqual(parsed.type, "Error") + XCTAssertEqual(parsed.line, 25) + XCTAssertEqual(parsed.message, "Cannot convert Type") + } +} From a0885cc9c2a66a7e0d355c0cb9df1f465a8b0059 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 7 Aug 2023 01:44:57 +0800 Subject: [PATCH 64/94] Adjust ActiveDocumentChatContextCollector --- .../ActiveDocumentChatContextCollector.swift | 15 +- .../FocusedCodeFinder/FocusedCodeFinder.swift | 2 +- .../SwiftFocusedCodeFinder.swift | 417 ++++++++---------- .../Functions/ExpandFocusRangeFunction.swift | 4 +- .../MoveToCodeAroundLineFunction.swift | 10 +- .../Functions/MoveToFocusedCodeFunction.swift | 2 +- .../SuggestionModel/EditorInformation.swift | 6 +- 7 files changed, 212 insertions(+), 244 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 02975270..4574fea8 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -70,6 +70,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { func getActiveDocumentContext(_ info: EditorInformation) -> ActiveDocumentContext { var activeDocumentContext = activeDocumentContext ?? .init( + filePath: "", relativePath: "", language: .builtIn(.swift), fileContent: "", @@ -85,16 +86,19 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { } func extractSystemPrompt(_ context: ActiveDocumentContext) -> String { - let start = "User Editing Document Context:###" + let start = """ + User Editing Document Context:### + (The context may change during the conversation, so it may not match our conversation.) + """ let end = "###" let relativePath = "Document Relative Path: \(context.relativePath)" - let language = "Language: \(context.language)" + let language = "Language: \(context.language.rawValue)" if let focusedContext = context.focusedContext { - let codeContext = "\(focusedContext.contextRange) \(focusedContext.context)" + let codeContext = "Focused Context:\(focusedContext.contextRange) \(focusedContext.context)" let codeRange = "Focused Range [line, character]: \(focusedContext.codeRange)" let code = """ - Focused Code (start from line \(focusedContext.codeRange.start.line)): + Focused Code (start from line \(focusedContext.codeRange.start.line), call `expandFocusRange` if you need more context): ```\(context.language.rawValue) \(focusedContext.code) ``` @@ -104,6 +108,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { : """ File Annotations: \(focusedContext.otherLineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + (call `moveToCodeAroundLine` if you context about the line annotations here) """ let codeAnnotations = focusedContext.lineAnnotations.isEmpty ? "" @@ -148,6 +153,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { } struct ActiveDocumentContext { + var filePath: String var relativePath: String var language: CodeLanguage var fileContent: String @@ -243,6 +249,7 @@ struct ActiveDocumentContext { return false }() + filePath = info.documentURL.path relativePath = info.relativePath language = info.language fileContent = info.editorContent?.content ?? "" diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift index c63294d5..bee717e8 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift @@ -38,7 +38,7 @@ struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { // search up and down for up to 7 lines. let lines = activeDocumentContext.lines var startLineIndex = max(containingRange.start.line - 3, 0) - var endLineIndex = min(containingRange.start.line + 3, lines.count - 1) + let endLineIndex = min(containingRange.start.line + 3, lines.count - 1) if endLineIndex - startLineIndex <= 6, startLineIndex > 0 { startLineIndex = max(startLineIndex - (6 - (endLineIndex - startLineIndex)), 0) } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index 3ebc4cd6..fe456b43 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -6,48 +6,95 @@ import SwiftSyntax struct SwiftFocusedCodeFinder: FocusedCodeFinder { func findFocusedCode( - containingRange: CursorRange, + containingRange range: CursorRange, activeDocumentContext: ActiveDocumentContext ) -> CodeContext { let source = activeDocumentContext.fileContent let tree = Parser.parse(source: source) + + let locationConverter = SourceLocationConverter( + file: activeDocumentContext.filePath, + tree: tree + ) + let visitor = SwiftScopeHierarchySyntaxVisitor( tree: tree, code: source, - range: containingRange + range: range, + locationConverter: locationConverter ) + var nodes = visitor.findScopeHierarchy() - let code = EditorInformation.code(in: activeDocumentContext.lines, inside: containingRange) - .code + let code: String + let codeRange: CursorRange + + func convertRange(_ node: SyntaxProtocol) -> CursorRange { + .init(sourceRange: node.sourceRange(converter: locationConverter)) + } + + if range.isEmpty { + // use the first scope as code, the second as context + var focusedNode: SyntaxProtocol? + while let node = nodes.first { + nodes.removeFirst() + let (context, _) = contextContainingNode( + node, + parentNodes: nodes, + tree: tree, + activeDocumentContext: activeDocumentContext, + locationConverter: locationConverter + ) + if context?.canBeUsedAsCodeRange ?? false { + focusedNode = node + break + } + } + guard let focusedNode else { + var result = UnknownLanguageFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: activeDocumentContext + ) + result.imports = visitor.imports + return result + } + nodes.removeFirst() + codeRange = convertRange(focusedNode) + } else { + codeRange = range + } + + code = EditorInformation + .code(in: activeDocumentContext.lines, inside: codeRange, ignoreColumns: true).code + + var contextRange = CursorRange.zero + var signature = [String]() while let node = nodes.first { nodes.removeFirst() - if var context = contextContainingNode( + let (context, more) = contextContainingNode( node, parentNodes: nodes, tree: tree, - activeDocumentContext: activeDocumentContext - ) { - if code.isEmpty { - context.focusedRange = context.contextRange - context.focusedCode = EditorInformation.code( - in: activeDocumentContext.lines, - inside: context.contextRange - ).code - } else { - context.focusedRange = containingRange - context.focusedCode = code - } + activeDocumentContext: activeDocumentContext, + locationConverter: locationConverter + ) - context.imports = visitor.imports - return context + if let context { + contextRange = context.contextRange + signature.insert(context.signature, at: 0) + } + + if !more { + break } } + return .init( - scope: .file, - contextRange: .zero, - focusedRange: containingRange, + scope: signature + .isEmpty ? .file : .scope(signature: signature.joined(separator: " > ")), + contextRange: contextRange, + focusedRange: codeRange, focusedCode: code, imports: visitor.imports ) @@ -55,16 +102,21 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { } extension SwiftFocusedCodeFinder { + struct ContextInfo { + var signature: String + var contextRange: CursorRange + var canBeUsedAsCodeRange: Bool = true + } + func contextContainingNode( _ node: SyntaxProtocol, parentNodes: [SyntaxProtocol], tree: SourceFileSyntax, - activeDocumentContext: ActiveDocumentContext - ) -> CodeContext? { - let source = activeDocumentContext.fileContent - + activeDocumentContext: ActiveDocumentContext, + locationConverter: SourceLocationConverter + ) -> (context: ContextInfo?, more: Bool) { func convertRange(_ node: SyntaxProtocol) -> CursorRange { - .init(sourceRange: node.sourceRange(converter: .init(file: source, tree: tree))) + .init(sourceRange: node.sourceRange(converter: locationConverter)) } func extractText(_ node: SyntaxProtocol) -> String { @@ -75,168 +127,103 @@ extension SwiftFocusedCodeFinder { case let node as StructDeclSyntax: let type = node.structKeyword.text let name = node.identifier.text - return .init( - scope: .scope( - signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + contextRange: convertRange(node) + ), false) case let node as ClassDeclSyntax: let type = node.classKeyword.text let name = node.identifier.text - return .init( - scope: .scope( - signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + contextRange: convertRange(node) + ), false) case let node as EnumDeclSyntax: let type = node.enumKeyword.text let name = node.identifier.text - return .init( - scope: .scope( - signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + contextRange: convertRange(node) + ), false) case let node as ActorDeclSyntax: let type = node.actorKeyword.text let name = node.identifier.text - return .init( - scope: .scope( - signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + contextRange: convertRange(node) + ), false) case let node as MacroDeclSyntax: let type = node.macroKeyword.text let name = node.identifier.text - return .init( - scope: .scope( - signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)), + contextRange: convertRange(node) + ), false) case let node as ProtocolDeclSyntax: let type = node.protocolKeyword.text let name = node.identifier.text - return .init( - scope: .scope( - signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + contextRange: convertRange(node) + ), false) case let node as ExtensionDeclSyntax: let type = node.extensionKeyword.text let name = node.extendedType.trimmedDescription - return .init( - scope: .scope( - signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + contextRange: convertRange(node) + ), false) case let node as FunctionDeclSyntax: let type = node.funcKeyword.text let name = node.identifier.text let signature = node.signature.trimmedDescription - return .init( - scope: .scope( - signature: "\(type) \(name)\(signature)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)), + contextRange: convertRange(node) + ), true) case let node as VariableDeclSyntax: let type = node.bindingSpecifier.trimmedDescription let name = node.bindings.first?.pattern.trimmedDescription ?? "" - let signature = node.bindings.first?.initializer?.value.trimmedDescription ?? "" + let signature = node.bindings.first?.typeAnnotation?.trimmedDescription ?? "" - return .init( - scope: .scope( - signature: "\(type) \(name)\(signature)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(type) \(name)\(signature.isEmpty ? "" : ": \(signature)")" + .prefixedModifiers(node.modifierAndAttributeText(extractText)), + contextRange: convertRange(node) + ), true) case let node as AccessorDeclSyntax: let keyword = node.accessorSpecifier.text - var signature = keyword - - for node in parentNodes { - if let (type, name, sig) = findAssigningToVariable(node) { - signature = "\(keyword) of \(type) \(name):\(sig)" - break - } - - if let node = node as? SubscriptDeclSyntax { - let genericPClause = node.genericWhereClause?.trimmedDescription ?? "" - let pClause = node.parameterClause.trimmedDescription - let whereClause = node.genericWhereClause?.trimmedDescription ?? "" - signature = "\(keyword) of subscript\(genericPClause)(\(pClause))\(whereClause)" - break - } - } + let signature = keyword - return .init( - scope: .scope( - signature: signature - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)), + contextRange: convertRange(node) + ), true) case let node as SubscriptDeclSyntax: let genericPClause = node.genericWhereClause?.trimmedDescription ?? "" @@ -244,103 +231,57 @@ extension SwiftFocusedCodeFinder { let whereClause = node.genericWhereClause?.trimmedDescription ?? "" let signature = "subscript\(genericPClause)(\(pClause))\(whereClause)" - return .init( - scope: .scope( - signature: signature - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)), + contextRange: convertRange(node) + ), true) case let node as InitializerDeclSyntax: - var signature = "init" - for node in parentNodes { - if let typeName = findTypeNameFromNode(node) { - signature = "\(typeName).init" - break - } - } + let signature = "init" - return .init( - scope: .scope( - signature: "\(signature)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: "\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)), + + contextRange: convertRange(node) + ), true) case let node as DeinitializerDeclSyntax: - var signature = "deinit" - for node in parentNodes { - if let typeName = findTypeNameFromNode(node) { - signature = "\(typeName).deinit" - break - } - } + let signature = "deinit" - return .init( - scope: .scope( - signature: signature - .prefixedModifiers(node.modifierAndAttributeText(extractText)) - ), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)), - case let node as ClosureExprSyntax: - var signature = "anonymous closure" + contextRange: convertRange(node) + ), true) - for node in parentNodes { - if let (type, name, sig) = findAssigningToVariable(node) { - signature = "closure assigned to \(type) \(name)\(sig)" - break - } - } + case let node as ClosureExprSyntax: + let signature = "closure" - return .init( - scope: .scope(signature: signature), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: signature, + contextRange: convertRange(node) + ), true) case let node as FunctionCallExprSyntax: - var signature = "anonymous function call" - for node in parentNodes { - if let (type, name, sig) = findAssigningToVariable(node) { - signature = "function call assigned to \(type) \(name)\(sig)" - break - } - } + let signature = "function call" - return .init( - scope: .scope(signature: signature), + return (.init( + signature: signature, contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + canBeUsedAsCodeRange: false + ), true) case let node as SwitchCaseSyntax: - return .init( - scope: .scope(signature: node.trimmedDescription), - contextRange: convertRange(node), - focusedRange: .zero, - focusedCode: "", - imports: [] - ) + return (.init( + signature: node.trimmedDescription, + contextRange: convertRange(node) + ), true) default: - return nil + return (nil, true) } } @@ -465,6 +406,8 @@ extension SwiftFocusedCodeFinder { let tree: SyntaxProtocol let code: String let range: CursorRange + let locationConverter: SourceLocationConverter + var imports: [String] = [] private var _scopeHierarchy: [SyntaxProtocol] = [] @@ -480,26 +423,34 @@ extension SwiftFocusedCodeFinder { return _scopeHierarchy.sorted { $0.position.utf8Offset > $1.position.utf8Offset } } - init(tree: SyntaxProtocol, code: String, range: CursorRange) { + init( + tree: SyntaxProtocol, + code: String, + range: CursorRange, + locationConverter: SourceLocationConverter + ) { self.tree = tree self.code = code self.range = range - super.init(viewMode: .all) + self.locationConverter = locationConverter + super.init(viewMode: .sourceAccurate) } func skipChildrenIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if _scopeHierarchy.count > 5 { return .skipChildren } if !nodeContainsRange(node) { return .skipChildren } return .visitChildren } func captureNodeIfPossible(_ node: SyntaxProtocol) -> SyntaxVisitorContinueKind { + if _scopeHierarchy.count > 5 { return .skipChildren } if !nodeContainsRange(node) { return .skipChildren } _scopeHierarchy.append(node) return .visitChildren } func nodeContainsRange(_ node: SyntaxProtocol) -> Bool { - let sourceRange = node.sourceRange(converter: .init(file: code, tree: tree)) + let sourceRange = node.sourceRange(converter: locationConverter) let cursorRange = CursorRange(sourceRange: sourceRange) return cursorRange.strictlyContains(range) } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift index 52ee9312..76806737 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift @@ -10,7 +10,7 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { var text: String var botReadableContent: String { - text + "User Editing Document Context is updated" } } @@ -21,7 +21,7 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { } var description: String { - "When you need more context from the code, you can call it to expand focus range to context range in user editing document context" + "Call when User Editing Document Context provides too little context to answer a question." } var argumentSchema: JSONSchemaValue { [ diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift index 6e39fc58..1e2bc3cf 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift @@ -12,7 +12,7 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { var text: String var botReadableContent: String { - text + "User Editing Document Context is updated" } } @@ -28,7 +28,13 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { var argumentSchema: JSONSchemaValue { [ .type: "object", - .properties: [:], + .properties: [ + "line": [ + .type: "number", + .description: "The line number to move to", + ] + ], + .required: ["line"], ] } weak var contextCollector: ActiveDocumentChatContextCollector? diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift index 45e9e84d..430a1d82 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift @@ -10,7 +10,7 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { var text: String var botReadableContent: String { - text + "User Editing Document Context is updated" } } diff --git a/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionModel/EditorInformation.swift index 9746f375..2e083189 100644 --- a/Tool/Sources/SuggestionModel/EditorInformation.swift +++ b/Tool/Sources/SuggestionModel/EditorInformation.swift @@ -90,9 +90,13 @@ public struct EditorInformation { public static func code( in code: [String], - inside range: CursorRange + inside range: CursorRange, + ignoreColumns: Bool = false ) -> (code: String, lines: [String]) { let rangeLines = lines(in: code, containing: range) + if ignoreColumns { + return (rangeLines.joined(), rangeLines) + } var content = rangeLines if !content.isEmpty { content[content.endIndex - 1] = String( From 07630c4e8beaf5817f1444c639283905f2b6ff32 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 7 Aug 2023 15:43:49 +0800 Subject: [PATCH 65/94] Fix that system prompt is not updated on function call --- .../ActiveDocumentChatContextCollector.swift | 84 ++++++++++++------- .../FocusedCodeFinder/FocusedCodeFinder.swift | 12 ++- .../SwiftFocusedCodeFinder.swift | 50 +++++++---- .../MoveToCodeAroundLineFunction.swift | 8 +- Core/Sources/ChatService/ChatService.swift | 28 ++----- ...ContextAwareAutoManagedChatGPTMemory.swift | 57 +++++++++++++ .../OpenAIService/ChatGPTService.swift | 4 + .../Memory/AutoManagedChatGPTMemory.swift | 15 ++-- .../OpenAIService/Memory/ChatGPTMemory.swift | 5 ++ .../Memory/ConversationChatGPTMemory.swift | 2 + .../Memory/EmptyChatGPTMemory.swift | 2 + Tool/Sources/Preferences/Keys.swift | 2 +- 12 files changed, 186 insertions(+), 83 deletions(-) create mode 100644 Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 4574fea8..05e70f43 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -62,6 +62,8 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { functions.append(MoveToCodeAroundLineFunction(contextCollector: self)) } + print(extractSystemPrompt(context)) + return .init( systemPrompt: extractSystemPrompt(context), functions: functions @@ -87,55 +89,84 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { func extractSystemPrompt(_ context: ActiveDocumentContext) -> String { let start = """ - User Editing Document Context:### - (The context may change during the conversation, so it may not match our conversation.) + ## File and Code Scope + + You can use the following context to answer user's questions about the editing document or code. The context shows only a part of the code in the editing document, and will change during the conversation, so it may not match our conversation. + + User Editing Document Context: ### """ let end = "###" let relativePath = "Document Relative Path: \(context.relativePath)" let language = "Language: \(context.language.rawValue)" if let focusedContext = context.focusedContext { - let codeContext = "Focused Context:\(focusedContext.contextRange) \(focusedContext.context)" + let codeContext = focusedContext.context.isEmpty + ? "" + : """ + Focused Context: + ``` + \(focusedContext.context.joined(separator: "\n")) + ``` + """ + let codeRange = "Focused Range [line, character]: \(focusedContext.codeRange)" + let code = """ - Focused Code (start from line \(focusedContext.codeRange.start.line), call `expandFocusRange` if you need more context): + Focused Code (start from line \( + focusedContext.codeRange.start + .line + )): ```\(context.language.rawValue) \(focusedContext.code) ``` """ + let fileAnnotations = focusedContext.otherLineAnnotations.isEmpty ? "" : """ - File Annotations: - \(focusedContext.otherLineAnnotations.map { " - \($0)" }.joined(separator: "\n")) - (call `moveToCodeAroundLine` if you context about the line annotations here) + Other Annotations:\""" + (They are not inside the focused code. You don't known how to handle them until you get the code at the line) + \( + focusedContext.otherLineAnnotations + .map(convertAnnotationToText) + .joined(separator: "\n") + ) + \""" """ + let codeAnnotations = focusedContext.lineAnnotations.isEmpty ? "" : """ - Code Annotations: - \(focusedContext.lineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + Annotations Inside Focused Range:\""" + \( + focusedContext.lineAnnotations + .map(convertAnnotationToText) + .joined(separator: "\n") + ) + \""" """ + return [ start, relativePath, language, - fileAnnotations, codeContext, codeRange, - codeAnnotations, code, + codeAnnotations, + fileAnnotations, end, ] .filter { !$0.isEmpty } - .joined(separator: "\n") + .joined(separator: "\n\n") } else { let selectionRange = "Selection Range [line, character]: \(context.selectionRange)" let lineAnnotations = context.lineAnnotations.isEmpty ? "" : """ - Line Annotations: - \(context.lineAnnotations.map { " - \($0)" }.joined(separator: "\n")) + Line Annotations:\""" + \(context.lineAnnotations.map(convertAnnotationToText).joined(separator: "\n")) + \""" """ return [ @@ -150,6 +181,10 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { .joined(separator: "\n") } } + + func convertAnnotationToText(_ annotation: EditorInformation.LineAnnotation) -> String { + return "- Line \(annotation.line), \(annotation.type): \(annotation.message)" + } } struct ActiveDocumentContext { @@ -164,7 +199,7 @@ struct ActiveDocumentContext { var imports: [String] struct FocusedContext { - var context: String + var context: [String] var contextRange: CursorRange var codeRange: CursorRange var code: String @@ -206,30 +241,21 @@ struct ActiveDocumentContext { ) imports = codeContext.imports - + let startLine = codeContext.focusedRange.start.line let endLine = codeContext.focusedRange.end.line var matchedAnnotations = [EditorInformation.LineAnnotation]() var otherAnnotations = [EditorInformation.LineAnnotation]() for annotation in lineAnnotations { - if annotation.line >= startLine && annotation.line <= endLine { + if annotation.line >= startLine, annotation.line <= endLine { matchedAnnotations.append(annotation) } else { otherAnnotations.append(annotation) } } - + focusedContext = .init( - context: { - switch codeContext.scope { - case .file: - return "File" - case .top: - return "Top level of the file" - case let .scope(signature): - return signature - } - }(), + context: codeContext.scopeSignatures, contextRange: codeContext.contextRange, codeRange: codeContext.focusedRange, code: codeContext.focusedCode, @@ -258,7 +284,7 @@ struct ActiveDocumentContext { selectionRange = info.editorContent?.selections.first ?? .zero lineAnnotations = info.editorContent?.lineAnnotations ?? [] imports = [] - + if changed { moveToFocusedCode() } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift index bee717e8..168871e9 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift @@ -5,9 +5,19 @@ struct CodeContext: Equatable { enum Scope: Equatable { case file case top - case scope(signature: String) + case scope(signature: [String]) } + var scopeSignatures: [String] { + switch scope { + case .file: + return [] + case .top: + return ["Top level of the file"] + case .scope(let signature): + return signature + } + } var scope: Scope var contextRange: CursorRange var focusedRange: CursorRange diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index fe456b43..c36d54d7 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -10,6 +10,7 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { activeDocumentContext: ActiveDocumentContext ) -> CodeContext { let source = activeDocumentContext.fileContent + #warning("TODO: cache the tree") let tree = Parser.parse(source: source) let locationConverter = SourceLocationConverter( @@ -58,7 +59,6 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { result.imports = visitor.imports return result } - nodes.removeFirst() codeRange = convertRange(focusedNode) } else { codeRange = range @@ -91,8 +91,7 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { } return .init( - scope: signature - .isEmpty ? .file : .scope(signature: signature.joined(separator: " > ")), + scope: signature.isEmpty ? .file : .scope(signature: signature), contextRange: contextRange, focusedRange: codeRange, focusedCode: code, @@ -130,7 +129,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), false) @@ -140,7 +140,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), false) @@ -150,7 +151,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), false) @@ -160,7 +162,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: ""), contextRange: convertRange(node) ), false) @@ -169,7 +172,8 @@ extension SwiftFocusedCodeFinder { let name = node.identifier.text return (.init( signature: "\(type) \(name)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)), + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), false) @@ -179,7 +183,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), false) @@ -189,7 +194,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) - .suffixedInheritance(node.inheritanceClauseTexts(extractText)), + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), false) @@ -197,6 +203,9 @@ extension SwiftFocusedCodeFinder { let type = node.funcKeyword.text let name = node.identifier.text let signature = node.signature.trimmedDescription + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .joined(separator: " ") return (.init( signature: "\(type) \(name)\(signature)" @@ -211,7 +220,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)\(signature.isEmpty ? "" : ": \(signature)")" - .prefixedModifiers(node.modifierAndAttributeText(extractText)), + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), true) @@ -221,7 +231,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: signature - .prefixedModifiers(node.modifierAndAttributeText(extractText)), + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), true) @@ -233,7 +244,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: signature - .prefixedModifiers(node.modifierAndAttributeText(extractText)), + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), true) @@ -242,7 +254,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(signature)" - .prefixedModifiers(node.modifierAndAttributeText(extractText)), + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), true) @@ -252,7 +265,8 @@ extension SwiftFocusedCodeFinder { return (.init( signature: signature - .prefixedModifiers(node.modifierAndAttributeText(extractText)), + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), true) @@ -261,7 +275,7 @@ extension SwiftFocusedCodeFinder { let signature = "closure" return (.init( - signature: signature, + signature: signature.replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), true) @@ -269,14 +283,14 @@ extension SwiftFocusedCodeFinder { let signature = "function call" return (.init( - signature: signature, + signature: signature.replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node), canBeUsedAsCodeRange: false ), true) case let node as SwitchCaseSyntax: return (.init( - signature: node.trimmedDescription, + signature: node.trimmedDescription.replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) ), true) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift index 1e2bc3cf..7177a3ab 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift @@ -19,11 +19,11 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { var reportProgress: (String) async -> Void = { _ in } var name: String { - "moveToCodeAroundLine" + "getCodeAtLine" } var description: String { - "Move user editing document context to code around a line when you need to answer a question the code in the line" + "Get the code at the given line, so you can answer the question about the code at that line." } var argumentSchema: JSONSchemaValue { [ @@ -31,7 +31,7 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { .properties: [ "line": [ .type: "number", - .description: "The line number to move to", + .description: "The line number in the file", ] ], .required: ["line"], @@ -55,7 +55,7 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { await reportProgress(progress) return .init(text: progress) } - let progress = "Looking at \(newContext.codeRange) inside \(newContext.context)" + let progress = "Looking at \(newContext.codeRange)" await reportProgress(progress) return .init(text: progress) } diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 2720d64f..8b0e5c9b 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -6,7 +6,7 @@ import OpenAIService import Preferences public final class ChatService: ObservableObject { - public let memory: AutoManagedChatGPTMemory + public let memory: ContextAwareAutoManagedChatGPTMemory public let configuration: OverridingChatGPTConfiguration public let chatGPTService: any ChatGPTServiceType public var allPluginCommands: [String] { allPlugins.map { $0.command } } @@ -16,49 +16,37 @@ public final class ChatService: ObservableObject { @Published public internal(set) var extraSystemPrompt = "" let pluginController: ChatPluginController - let contextController: DynamicContextController - let functionProvider: ChatFunctionProvider var cancellable = Set() init( - memory: AutoManagedChatGPTMemory, + memory: ContextAwareAutoManagedChatGPTMemory, configuration: OverridingChatGPTConfiguration, - functionProvider: ChatFunctionProvider, chatGPTService: T ) { self.memory = memory self.configuration = configuration self.chatGPTService = chatGPTService - self.functionProvider = functionProvider pluginController = ChatPluginController( chatGPTService: chatGPTService, plugins: allPlugins ) - contextController = DynamicContextController( - memory: memory, - functionProvider: functionProvider, - contextCollectors: allContextCollectors - ) pluginController.chatService = self } public convenience init() { let configuration = UserPreferenceChatGPTConfiguration().overriding() - let functionProvider = ChatFunctionProvider() - let memory = AutoManagedChatGPTMemory( - systemPrompt: "", + let memory = ContextAwareAutoManagedChatGPTMemory( configuration: configuration, - functionProvider: functionProvider + functionProvider: ChatFunctionProvider() ) self.init( memory: memory, configuration: configuration, - functionProvider: functionProvider, chatGPTService: ChatGPTService( memory: memory, configuration: configuration, - functionProvider: functionProvider + functionProvider: memory.functionProvider ) ) @@ -71,11 +59,7 @@ public final class ChatService: ObservableObject { guard !isReceivingMessage else { throw CancellationError() } let handledInPlugin = try await pluginController.handleContent(content) if handledInPlugin { return } - try await contextController.updatePromptToMatchContent(systemPrompt: """ - \(systemPrompt) - \(extraSystemPrompt) - """, content: content) - + let stream = try await chatGPTService.send(content: content, summary: nil) isReceivingMessage = true do { diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift new file mode 100644 index 00000000..a5a39040 --- /dev/null +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift @@ -0,0 +1,57 @@ +import Foundation +import OpenAIService + +public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { + private let memory: AutoManagedChatGPTMemory + let contextController: DynamicContextController + let functionProvider: ChatFunctionProvider + weak var chatService: ChatService? + + public var messages: [ChatMessage] { + get async { await memory.messages } + } + + public var remainingTokens: Int? { + get async { await memory.remainingTokens } + } + + public var history: [ChatMessage] { + get async { await memory.history } + } + + func observeHistoryChange(_ observer: @escaping () -> Void) { + memory.observeHistoryChange(observer) + } + + init( + configuration: ChatGPTConfiguration, + functionProvider: ChatFunctionProvider + ) { + memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: configuration, + functionProvider: functionProvider + ) + contextController = DynamicContextController( + memory: memory, + functionProvider: functionProvider, + contextCollectors: allContextCollectors + ) + self.functionProvider = functionProvider + } + + public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async { + await memory.mutateHistory(update) + } + + public func refresh() async { + let content = (await memory.history) + .last(where: { $0.role == .user || $0.role == .function })?.content + try? await contextController.updatePromptToMatchContent(systemPrompt: """ + \(chatService?.systemPrompt ?? "") + \(chatService?.extraSystemPrompt ?? "") + """, content: content ?? "") + await memory.refresh() + } +} + diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 7acc7a4b..6a6f5709 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -182,6 +182,8 @@ extension ChatGPTService { func sendMemory() async throws -> AsyncThrowingStream { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + + await memory.refresh() let messages = await memory.messages.map { CompletionRequestBody.Message( @@ -280,6 +282,8 @@ extension ChatGPTService { func sendMemoryAndWait() async throws -> ChatMessage? { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + + await memory.refresh() let messages = await memory.messages.map { CompletionRequestBody.Message( diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 0f22e750..b2c70ac2 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -4,8 +4,8 @@ import TokenEncoder /// A memory that automatically manages the history according to max tokens and max message count. public actor AutoManagedChatGPTMemory: ChatGPTMemory { - public var messages: [ChatMessage] { generateSendingHistory() } - public var remainingTokens: Int? { generateRemainingTokens() } + public private(set) var messages: [ChatMessage] = [] + public private(set) var remainingTokens: Int? = nil public var systemPrompt: ChatMessage public var history: [ChatMessage] = [] { @@ -44,6 +44,11 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { await setOnHistoryChangeBlock(onChange) } } + + public func refresh() async { + messages = generateSendingHistory() + remainingTokens = generateRemainingTokens() + } /// https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb func generateSendingHistory( @@ -97,12 +102,6 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { ) -> Int? { // It should be fine to just let OpenAI decide. return nil -// let tokensCount = generateSendingHistory( -// maxNumberOfMessages: maxNumberOfMessages, -// encoder: encoder -// ) -// .reduce(0) { $0 + ($1.tokensCount ?? 0) } -// return max(configuration.minimumReplyTokens, configuration.maxTokens - tokensCount) } func setOnHistoryChangeBlock(_ onChange: @escaping () -> Void) { diff --git a/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift index 076089f5..437e99f3 100644 --- a/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift @@ -7,6 +7,11 @@ public protocol ChatGPTMemory { var remainingTokens: Int? { get async } /// Update the message history. func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async + /// Refresh `messages` and `remainingTokens`. + /// Sometimes the message history needs time to generate, in such case, you + /// can use this method to refresh the memory, instead of making variable + /// `messages` and `remainingTokens` computed. + func refresh() async } public extension ChatGPTMemory { diff --git a/Tool/Sources/OpenAIService/Memory/ConversationChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/ConversationChatGPTMemory.swift index e07e2114..74b40969 100644 --- a/Tool/Sources/OpenAIService/Memory/ConversationChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/ConversationChatGPTMemory.swift @@ -11,5 +11,7 @@ public actor ConversationChatGPTMemory: ChatGPTMemory { public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { update(&messages) } + + public func refresh() async {} } diff --git a/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift index 477cce6b..a07c51e2 100644 --- a/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift @@ -9,5 +9,7 @@ public actor EmptyChatGPTMemory: ChatGPTMemory { public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { update(&messages) } + + public func refresh() async {} } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index a69f81e5..87241de8 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -291,7 +291,7 @@ public extension UserDefaultPreferenceKeys { You MUST embed every code you provide in a markdown code block. You MUST add the programming language name at the start of the markdown code block. If you are asked to help perform a task, you MUST think step-by-step, then describe each step concisely. - If you are asked to explain code, you MUST explain it step-by-step in a ordered list. + If you are asked to explain code, you MUST explain it step-by-step in a ordered list concisely. Make your answer short and structured. """, key: "DefaultChatSystemPrompt" From 90523621231df456f97e724f79e932725d54015a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 7 Aug 2023 16:40:57 +0800 Subject: [PATCH 66/94] Print token usage in DEBUG mode --- .../OpenAIService/EmbeddingService.swift | 27 ++++++++++++++++--- .../Memory/AutoManagedChatGPTMemory.swift | 19 ++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index e5daf16e..f4f0a713 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -1,4 +1,5 @@ import Foundation +import Logger public struct EmbeddingResponse: Decodable { public struct Object: Decodable { @@ -74,9 +75,19 @@ public struct EmbeddingService { .otherError(String(data: result, encoding: .utf8) ?? "Unknown Error") } - return try JSONDecoder().decode(EmbeddingResponse.self, from: result) + let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: result) + #if DEBUG + Logger.service.info(""" + Embedding usage + - number of strings: \(text.count) + - prompt tokens: \(embeddingResponse.usage.prompt_tokens) + - total tokens: \(embeddingResponse.usage.total_tokens) + + """) + #endif + return embeddingResponse } - + public func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect @@ -112,7 +123,17 @@ public struct EmbeddingService { .otherError(String(data: result, encoding: .utf8) ?? "Unknown Error") } - return try JSONDecoder().decode(EmbeddingResponse.self, from: result) + let embeddingResponse = try JSONDecoder().decode(EmbeddingResponse.self, from: result) + #if DEBUG + Logger.service.info(""" + Embedding usage + - number of strings: \(tokens.count) + - prompt tokens: \(embeddingResponse.usage.prompt_tokens) + - total tokens: \(embeddingResponse.usage.total_tokens) + + """) + #endif + return embeddingResponse } } diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index b2c70ac2..6385b565 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -1,11 +1,12 @@ import Foundation +import Logger import Preferences import TokenEncoder /// A memory that automatically manages the history according to max tokens and max message count. public actor AutoManagedChatGPTMemory: ChatGPTMemory { public private(set) var messages: [ChatMessage] = [] - public private(set) var remainingTokens: Int? = nil + public private(set) var remainingTokens: Int? public var systemPrompt: ChatMessage public var history: [ChatMessage] = [] { @@ -44,7 +45,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { await setOnHistoryChangeBlock(onChange) } } - + public func refresh() async { messages = generateSendingHistory() remainingTokens = generateRemainingTokens() @@ -74,7 +75,8 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { } partial += count } - var allTokensCount = functionTokenCount + 3 // every reply is primed with <|start|>assistant<|message|> + var allTokensCount = functionTokenCount + + 3 // every reply is primed with <|start|>assistant<|message|> allTokensCount += systemPrompt.isEmpty ? 0 : systemMessageTokenCount for (index, message) in history.enumerated().reversed() { @@ -93,6 +95,17 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { if !systemPrompt.isEmpty { all.append(systemPrompt) } + + #if DEBUG + Logger.service.info(""" + Sending tokens count + - system prompt: \(systemMessageTokenCount) + - functions: \(functionTokenCount) + - total: \(allTokensCount) + + """) + #endif + return all.reversed() } From 24824fde65deab1b75ddb491bffe18cfa56dc872 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 7 Aug 2023 16:41:13 +0800 Subject: [PATCH 67/94] Adjust functions --- .../Functions/ExpandFocusRangeFunction.swift | 16 ++++++++++------ .../Functions/MoveToCodeAroundLineFunction.swift | 12 ++++++++---- .../Functions/MoveToFocusedCodeFunction.swift | 14 +++++++++----- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift index 76806737..9ceb23f8 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift @@ -7,12 +7,16 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { struct Arguments: Codable {} struct Result: ChatGPTFunctionResult { - var text: String + var range: CursorRange var botReadableContent: String { - "User Editing Document Context is updated" + "User Editing Document Context is updated to display code at \(range)." } } + + struct E: Error, LocalizedError { + var errorDescription: String? + } var reportProgress: (String) async -> Void = { _ in } @@ -43,12 +47,12 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { await reportProgress("Finding the focused code..") contextCollector?.activeDocumentContext?.expandFocusedRangeToContextRange() guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { - let progress = "Failed to move to focused code." + let progress = "Failed to expand focused code." await reportProgress(progress) - return .init(text: progress) + throw E(errorDescription: progress) } - let progress = "Looking at \(newContext.codeRange) inside \(newContext.context)" + let progress = "Looking at \(newContext.codeRange)." await reportProgress(progress) - return .init(text: progress) + return .init(range: newContext.codeRange) } } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift index 7177a3ab..ec3a8310 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift @@ -9,12 +9,16 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { } struct Result: ChatGPTFunctionResult { - var text: String + var range: CursorRange var botReadableContent: String { - "User Editing Document Context is updated" + "User Editing Document Context is updated to display code at \(range)." } } + + struct E: Error, LocalizedError { + var errorDescription: String? + } var reportProgress: (String) async -> Void = { _ in } @@ -53,11 +57,11 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { let progress = "Failed to move to focused code." await reportProgress(progress) - return .init(text: progress) + throw E(errorDescription: progress) } let progress = "Looking at \(newContext.codeRange)" await reportProgress(progress) - return .init(text: progress) + return .init(range: newContext.codeRange) } } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift index 430a1d82..8f094d66 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift @@ -7,12 +7,16 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { struct Arguments: Codable {} struct Result: ChatGPTFunctionResult { - var text: String + var range: CursorRange var botReadableContent: String { - "User Editing Document Context is updated" + "User Editing Document Context is updated to display code at \(range)." } } + + struct E: Error, LocalizedError { + var errorDescription: String? + } var reportProgress: (String) async -> Void = { _ in } @@ -45,10 +49,10 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { let progress = "Failed to move to focused code." await reportProgress(progress) - return .init(text: progress) + throw E(errorDescription: progress) } - let progress = "Looking at \(newContext.codeRange) inside \(newContext.context)" + let progress = "Looking at \(newContext.codeRange)." await reportProgress(progress) - return .init(text: progress) + return .init(range: newContext.codeRange) } } From 3acdc2353fab5933808f1054ca84ace04d6f4d5c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 7 Aug 2023 16:41:19 +0800 Subject: [PATCH 68/94] Remove prints --- .../ActiveDocumentChatContextCollector.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 05e70f43..b5f8cab3 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -62,8 +62,6 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { functions.append(MoveToCodeAroundLineFunction(contextCollector: self)) } - print(extractSystemPrompt(context)) - return .init( systemPrompt: extractSystemPrompt(context), functions: functions From 54a9aff0d86b4098313ce00441fa9fdbae069aff Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 7 Aug 2023 21:04:40 +0800 Subject: [PATCH 69/94] Update README.md --- README.md | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 561f4eb6..50704aee 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ If you find that some of the features are no longer working, please first try re The app can provide real-time code suggestions based on the files you have opened. It's powered by GitHub Copilot and Codeium. The feature provides two presentation modes: + - Nearby Text Cursor: This mode shows suggestions based on the position of the text cursor. - Floating Widget: This mode shows suggestions next to the circular widget. @@ -186,24 +187,33 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha #### Commands -- Open Chat: Open a chat window. +- Open Chat: Open a chat tab. #### Keyboard Shortcuts | Shortcut | Description | | :------: | --------------------------------------------------------------------------------------------------- | -| `⌘W` | Close the chat. | +| `⌘W` | Close the chat tab. | | `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the circular widget. | -| `⇧↩︎` | Add new line. | +| `⇧↩︎` | Add new line. | +| `⇧⌘]` | Move to next tab | +| `⇧⌘[` | Move to previous tab | #### Chat Scope The chat panel allows for chat scope to temporarily control the context of the conversation for the latest message. To use a scope, simply prefix the message with `@scope`. -| Scope | Description | -| :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@selection` | Inject the selected code from the active editor into the conversation. This scope will be applied to any message automatically. If you don't want this to be the default behavior, you can turn off the option `Use selection scope by default in chat context.`. | -| `@file` | Inject the content of the file into the conversation. Keep in mind that you may not have enough tokens to inject large files. | +| Scope | Description | +| :-----: | ---------------------------------------------------------------------------------------- | +| `@file` | Includes the metadata of the editing document and line annotations in the system prompt. | +| `@code` | Includes the focused/selected code and everything from `@file` in the system prompt. | +| `@web` | Allow the bot to search on Bing or query from a web page | + +`@code` is on by default, if `Use @code scope by default in chat context.` is on. Otherwise, `@file` will be on by default. + +To use scopes, you can prefix a message with `@code`. + +You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`. #### Chat Plugins @@ -219,14 +229,16 @@ If you need to end a plugin, you can just type /exit ``` -| Command | Description | -| :------------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/run` | Runs the command under the project root. You can also use environment variable `PROJECT_ROOT` to get the project root and `FILE_PATH` to get the editing file path. | -| `/airun` | Creates a command with natural language. You can ask to modify the command if it is not what you want. After confirming, the command will be executed by calling the `/run` plugin. | -| `/math` | Solves a math problem in natural language | -| `/search` | Search on Bing and summarize the results. You have to setup the Bing Search API in the host app before using it. | -| `/shortcut(shortcut name)` | Run a shortcut from the Shortcuts.app, and use the following message as the input. If the message is empty, it will use the previous message as input. The output of the shortcut will be printed as a reply from the bot. | -| `/shortcutInput(shortcut name)` | Run a shortcut from the Shortcuts.app, and use the following message as the input. If the message is empty, it will use the previous message as input. The output of the shortcut will be send to the bot as a user message. | +| Command | Description | +| :--------------------: | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `/run` | Runs the command under the project root. | +| | Environment variable:
- `PROJECT_ROOT` to get the project root.
- `FILE_PATH` to get the editing file path. | +| `/math` | Solves a math problem in natural language | +| `/search` | Search on Bing and summarize the results. You have to setup the Bing Search API in the host app before using it. | +| `/shortcut(name)` | Run a shortcut from the Shortcuts.app, and use the following message as the input. | +| | If the message is empty, it will use the previous message as input. The output of the shortcut will be printed as a reply from the bot. | +| `/shortcutInput(name)` | Run a shortcut from the Shortcuts.app, and use the following message as the input. | +| | If the message is empty, it will use the previous message as input. The output of the shortcut will be send to the bot as a user message. | ### Prompt to Code From 726f9e63356d95074c43e49532a5e286486ad2b3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 7 Aug 2023 21:04:49 +0800 Subject: [PATCH 70/94] Update chat tab instruction --- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 97 +++++++++++++-------- 1 file changed, 59 insertions(+), 38 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index ddb105b5..77d43bc6 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -105,54 +105,75 @@ private struct StopRespondingButton: View { private struct Instruction: View { @AppStorage(\.useSelectionScopeByDefaultInChatContext) - var useSelectionScopeByDefaultInChatContext - @AppStorage(\.chatFontSize) var chatFontSize + var useCodeScopeByDefaultInChatContext var body: some View { Group { - if useSelectionScopeByDefaultInChatContext { - Markdown( - """ - Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + Markdown( + """ + Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + + \( + useCodeScopeByDefaultInChatContext + ? "Scope **`@code`** is enabled by default." + : "Scope **`@file`** is enabled by default." + ) + """ + ) + .modifier(InstructionModifier()) - Currently, I have the ability to read the following details from the active editor: - - The **selected code**. - - The **relative path** of the file. - - The **error and warning** labels. - - The text cursor location. + Markdown( + """ + You can use scopes to give the bot extra abilities. - If you'd like me to examine the entire file, simply add `@file` to the beginning of your message. + | Scope Name | Abilities | + | --- | --- | + | `@file` | Read the metadata of the editing file | + | `@code` | Read the code and metadata in the editing file | + | `@web` (beta) | Search on Bing or query from a web page | - To use plugins, you can start a message with `/pluginName`. - """ - ) - } else { - Markdown( - """ - Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + To use scopes, you can prefix a message with `@code`. - Currently, I have the ability to read the following details from the active editor: - - The **relative path** of the file. - - The **error and warning** labels. - - The text cursor location. + You can use shorthand to represent a scope, such as `@c`, and enable multiple scopes with `@c+web`. + """ + ) + .modifier(InstructionModifier()) + + Markdown( + """ + You can use plugins to perform various tasks. + + | Plugin Name | Description | + | --- | --- | + | `/run` | Runs a command under the project root | + | `/math` | Solves a math problem in natural language | + | `/search` | Searches on Bing and summarizes the results | + | `/shortcut(name)` | Runs a shortcut from the Shortcuts.app, with the previous message as input | + | `/shortcutInput(name)` | Runs a shortcut and uses its result as a new message | + + To use plugins, you can prefix a message with `/pluginName`. + """ + ) + .modifier(InstructionModifier()) + } + } - If you would like me to examine the selected code, please prefix your message with `@selection`. If you would like me to examine the entire file, please prefix your message with `@file`. + struct InstructionModifier: ViewModifier { + @AppStorage(\.chatFontSize) var chatFontSize - To use plugins, you can start a message with `/pluginName`. - """ - ) - } - } - .textSelection(.enabled) - .markdownTheme(.custom(fontSize: chatFontSize)) - .opacity(0.8) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + func body(content: Content) -> some View { + content + .textSelection(.enabled) + .markdownTheme(.custom(fontSize: chatFontSize)) + .opacity(0.8) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .scaleEffect(x: -1, y: -1, anchor: .center) } - .scaleEffect(x: -1, y: -1, anchor: .center) } } From 81649843872b22b7f72cb441bf7410c1bc5491f4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 15:46:57 +0800 Subject: [PATCH 71/94] Update tests --- .../SwiftASTReaderTests.swift | 256 ----------- .../SwiftFocusedCodeFinderTests.swift | 400 ++++++++++++++++++ TestPlan.xctestplan | 14 + 3 files changed, 414 insertions(+), 256 deletions(-) delete mode 100644 Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift create mode 100644 Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift deleted file mode 100644 index 89c5acbe..00000000 --- a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftASTReaderTests.swift +++ /dev/null @@ -1,256 +0,0 @@ -import Foundation -import SuggestionModel -import XCTest - -@testable import ActiveDocumentChatContextCollector - -final class SwiftASTReaderTests: XCTestCase { - func editorInformation(code: String) -> EditorInformation { - .init( - editorContent: .init( - content: code, - lines: code.components(separatedBy: "\n"), - selections: [], - cursorPosition: .outOfScope, - lineAnnotations: [] - ), - selectedContent: "", - selectedLines: [], - documentURL: URL(fileURLWithPath: ""), - projectURL: URL(fileURLWithPath: ""), - relativePath: "", - language: .builtIn(.swift) - ) - } - - func test_selecting_a_line_inside_the_function_the_scope_should_be_the_function() { - let code = """ - public struct A: B, C { - @ViewBuilder private func f(_ a: String) -> String { - let a = 1 - let b = 2 - let c = 3 - let d = 4 - let e = 5 - } - } - """ - let range = CursorRange( - start: CursorPosition(line: 4, character: 0), - end: CursorPosition(line: 4, character: 13) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "@ViewBuilder private func f(_ a: String) -> String", - range: .init(start: .init(line: 1, character: 4), end: .init(line: 7, character: 5)) - ) - ) - } - - func test_selecting_a_function_inside_a_struct_the_scope_should_be_the_struct() { - let code = """ - @MainActor - public struct A: B, C { - func f() { - let a = 1 - let b = 2 - let c = 3 - let d = 4 - let e = 5 - } - } - """ - let range = CursorRange( - start: CursorPosition(line: 2, character: 0), - end: CursorPosition(line: 7, character: 5) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "@MainActor public struct A: B, C", - range: .init(start: .init(line: 0, character: 0), end: .init(line: 9, character: 1)) - ) - ) - } - - func test_selecting_a_variable_inside_a_class_the_scope_should_be_the_class() { - let code = """ - @MainActor final public class A: P, K { - var a = 1 - var b = 2 - var c = 3 - var d = 4 - var e = 5 - } - """ - let range = CursorRange( - start: CursorPosition(line: 1, character: 0), - end: CursorPosition(line: 1, character: 9) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "@MainActor final public class A: P, K", - range: .init(start: .init(line: 0, character: 0), end: .init(line: 6, character: 1)) - ) - ) - } - - func test_selecting_a_function_inside_a_protocol_the_scope_should_be_the_protocol() { - let code = """ - public protocol A: Hashable { - func f() - func g() - func h() - func i() - func j() - } - """ - let range = CursorRange( - start: CursorPosition(line: 1, character: 0), - end: CursorPosition(line: 1, character: 9) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "public protocol A: Hashable", - range: .init(start: .init(line: 0, character: 0), end: .init(line: 6, character: 1)) - ) - ) - } - - func test_selecting_a_variable_inside_an_extension_the_scope_should_be_the_extension() { - let code = """ - private extension A: Equatable { - var a = 1 - var b = 2 - var c = 3 - var d = 4 - var e = 5 - } - """ - let range = CursorRange( - start: CursorPosition(line: 1, character: 0), - end: CursorPosition(line: 1, character: 9) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "private extension A: Equatable", - range: .init(start: .init(line: 0, character: 0), end: .init(line: 6, character: 1)) - ) - ) - } - - func test_selecting_a_static_function_from_an_actor_the_scope_should_be_the_actor() { - let code = """ - @gloablActor - public actor A { - static func f() {} - static func g() {} - static func h() {} - static func i() {} - static func j() {} - } - """ - let range = CursorRange( - start: CursorPosition(line: 2, character: 0), - end: CursorPosition(line: 2, character: 9) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "@gloablActor public actor A", - range: .init(start: .init(line: 0, character: 0), end: .init(line: 7, character: 1)) - ) - ) - } - - func test_selecting_a_case_inside_an_enum_the_scope_should_be_the_enum() { - let code = """ - @MainActor - public - indirect enum A { - case a - case b - case c - case d - case e - } - """ - let range = CursorRange( - start: CursorPosition(line: 3, character: 0), - end: CursorPosition(line: 3, character: 9) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "@MainActor public indirect enum A", - range: .init(start: .init(line: 0, character: 0), end: .init(line: 8, character: 1)) - ) - ) - } - - func test_selecting_a_line_inside_computed_variable_the_scope_should_be_the_variable() { - let code = """ - struct A { - @SomeWrapper public private(set) var a: Int { - let a = 1 - let b = 2 - let c = 3 - let d = 4 - let e = 5 - } - } - """ - let range = CursorRange( - start: CursorPosition(line: 2, character: 0), - end: CursorPosition(line: 2, character: 9) - ) - let context = SwiftASTReader().contextContainingRange( - range, - editorInformation: editorInformation(code: code) - ) - XCTAssertEqual( - context.scope, - .scope( - signature: "@SomeWrapper public private(set) var a: Int", - range: .init(start: .init(line: 1, character: 0), end: .init(line: 7, character: 1)) - ) - ) - } - - func test_selecting_a_line_in_freestanding_macro_the_scope_should_be_the_macro() { - - } -} - diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift new file mode 100644 index 00000000..08ed4ca3 --- /dev/null +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift @@ -0,0 +1,400 @@ +import Foundation +import SuggestionModel +import XCTest + +@testable import ActiveDocumentChatContextCollector + +final class SwiftFocusedCodeFinder_Selection_Tests: XCTestCase { + func context(code: String) -> ActiveDocumentContext { + .init( + filePath: "", + relativePath: "", + language: .builtIn(.swift), + fileContent: code, + lines: code.components(separatedBy: "\n").map { "\($0)\n" }, + selectedCode: "", selectionRange: .zero, + lineAnnotations: [], + imports: [] + ) + } + + func test_collecting_imports() { + let code = """ + import var Darwin.stderr + public struct A: B, C { + let a = 1 + } + import Bar + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 1) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context.imports, ["Darwin.stderr", "Bar"]) + } + + func test_selecting_a_line_inside_the_function_the_scope_should_be_the_function() { + let code = """ + public struct A: B, C { + @ViewBuilder private func f(_ a: String) -> String { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 4, character: 0), + end: CursorPosition(line: 4, character: 13) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "public struct A: B, C", + "@ViewBuilder private func f(_ a: String) -> String", + ]), + contextRange: .init(startPair: (0, 0), endPair: (8, 1)), + focusedRange: .init(startPair: (4, 0), endPair: (4, 13)), + focusedCode: """ + let c = 3 + + """, + imports: [] + )) + } + + func test_selecting_a_function_inside_a_struct_the_scope_should_be_the_struct() { + let code = """ + @MainActor + public struct A: B, C { + func f() { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 7, character: 5) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "@MainActor public struct A: B, C", + ]), + contextRange: .init(startPair: (0, 0), endPair: (9, 1)), + focusedRange: .init(startPair: (2, 0), endPair: (7, 5)), + focusedCode: """ + func f() { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + + """, + imports: [] + )) + } + + func test_selecting_a_variable_inside_a_class_the_scope_should_be_the_class() { + let code = """ + @MainActor final public class A: P, K { + var a = 1 + var b = 2 + var c = 3 + var d = 4 + var e = 5 + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "@MainActor final public class A: P, K", + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 1)), + focusedRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedCode: """ + var a = 1 + + """, + imports: [] + )) + } + + func test_selecting_a_function_inside_a_protocol_the_scope_should_be_the_protocol() { + let code = """ + public protocol A: Hashable { + func f() + func g() + func h() + func i() + func j() + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "public protocol A: Hashable", + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 1)), + focusedRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedCode: """ + func f() + + """, + imports: [] + )) + } + + func test_selecting_a_variable_inside_an_extension_the_scope_should_be_the_extension() { + let code = """ + private extension A: Equatable { + var a = 1 + var b = 2 + var c = 3 + var d = 4 + var e = 5 + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "private extension A: Equatable", + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 1)), + focusedRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedCode: """ + var a = 1 + + """, + imports: [] + )) + } + + func test_selecting_a_static_function_from_an_actor_the_scope_should_be_the_actor() { + let code = """ + @gloablActor + public actor A { + static func f() {} + static func g() {} + static func h() {} + static func i() {} + static func j() {} + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 9) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "@gloablActor public actor A", + ]), + contextRange: .init(startPair: (0, 0), endPair: (7, 1)), + focusedRange: .init(startPair: (2, 0), endPair: (2, 9)), + focusedCode: """ + static func f() {} + + """, + imports: [] + )) + } + + func test_selecting_a_case_inside_an_enum_the_scope_should_be_the_enum() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange( + start: CursorPosition(line: 3, character: 0), + end: CursorPosition(line: 3, character: 9) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "@MainActor public indirect enum A", + ]), + contextRange: .init(startPair: (0, 0), endPair: (8, 1)), + focusedRange: .init(startPair: (3, 0), endPair: (3, 9)), + focusedCode: """ + case a + + """, + imports: [] + )) + } + + func test_selecting_a_line_inside_computed_variable_the_scope_should_be_the_variable() { + let code = """ + struct A { + @SomeWrapper public private(set) var a: Int { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 9) + ) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + "struct A", + "@SomeWrapper public private(set) var a: Int", + ]), + contextRange: .init(startPair: (0, 0), endPair: (8, 1)), + focusedRange: .init(startPair: (2, 0), endPair: (2, 9)), + focusedCode: """ + let a = 1 + + """, + imports: [] + )) + } + + func test_selecting_a_line_in_freestanding_macro_the_scope_should_be_the_macro() { + // TODO: + } +} + +final class SwiftFocusedCodeFinder_FocusedCode_Tests: XCTestCase { + func context(code: String) -> ActiveDocumentContext { + .init( + filePath: "", + relativePath: "", + language: .builtIn(.swift), + fileContent: code, + lines: code.components(separatedBy: "\n").map { "\($0)\n" }, + selectedCode: "", selectionRange: .zero, + lineAnnotations: [], + imports: [] + ) + } + + func test_get_focused_code_on_top_level_should_fallback_to_unknown_language() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange(startPair: (0, 0), endPair: (0, 0)) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (6, 11)), + focusedRange: .init(startPair: (0, 0), endPair: (3, 11)), + focusedCode: """ + @MainActor + public + indirect enum A { + case a + + """, + imports: [] + )) + } + + func test_get_focused_code_inside_enum_the_whole_enum_will_be_the_focused_code() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange(startPair: (3, 0), endPair: (3, 0)) + let context = SwiftFocusedCodeFinder().findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .file, + contextRange: .init(startPair: (0, 0), endPair: (0, 0)), + focusedRange: .init(startPair: (0, 0), endPair: (8, 1)), + focusedCode: """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + + """, + imports: [] + )) + } +} diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 086b8f4b..88bbb7a9 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -105,6 +105,20 @@ "identifier" : "LicenseManagementTests", "name" : "LicenseManagementTests" } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" + } } ], "version" : 1 From 7a34ee1703ed950629ec4733d817c56005139153 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 15:47:35 +0800 Subject: [PATCH 72/94] Update names of settings keys --- ...cyActiveDocumentChatContextCollector.swift | 4 ++-- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 2 +- .../DynamicContextController.swift | 2 +- .../FeatureSettings/ChatSettingsView.swift | 24 +++++++------------ Tool/Sources/Preferences/Keys.swift | 4 ++-- 5 files changed, 15 insertions(+), 21 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index c57d336c..a54bbfba 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -30,7 +30,7 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { { let lines = content.editorContent?.lines.count ?? 0 let maxLine = UserDefaults.shared - .value(for: \.maxEmbeddableFileInChatContextLineCount) + .value(for: \.maxFocusedCodeLineCount) if lines <= maxLine { return """ File Content:```\(content.language.rawValue) @@ -48,7 +48,7 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { } } - if UserDefaults.shared.value(for: \.useSelectionScopeByDefaultInChatContext) { + if UserDefaults.shared.value(for: \.useCodeScopeByDefaultInChatContext) { return """ Selected Code \ (start from line \(selectionRange.start.line)):```\(content.language.rawValue) diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index 77d43bc6..f76f7b1e 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -104,7 +104,7 @@ private struct StopRespondingButton: View { } private struct Instruction: View { - @AppStorage(\.useSelectionScopeByDefaultInChatContext) + @AppStorage(\.useCodeScopeByDefaultInChatContext) var useCodeScopeByDefaultInChatContext var body: some View { diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index 3d5370a3..ac663313 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -35,7 +35,7 @@ final class DynamicContextController { func updatePromptToMatchContent(systemPrompt: String, content: String) async throws { var content = content var scopes = Self.parseScopes(&content) - if UserDefaults.shared.value(for: \.useSelectionScopeByDefaultInChatContext) { + if UserDefaults.shared.value(for: \.useCodeScopeByDefaultInChatContext) { scopes.insert("code") } else { scopes.insert("file") diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index e977926a..b288f736 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -10,12 +10,10 @@ struct ChatSettingsView: View { @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount @AppStorage(\.chatFontSize) var chatFontSize @AppStorage(\.chatCodeFontSize) var chatCodeFontSize - @AppStorage(\.embedFileContentInChatContextIfNoSelection) - var embedFileContentInChatContextIfNoSelection - @AppStorage(\.maxEmbeddableFileInChatContextLineCount) - var maxEmbeddableFileInChatContextLineCount - @AppStorage(\.useSelectionScopeByDefaultInChatContext) - var useSelectionScopeByDefaultInChatContext + @AppStorage(\.maxFocusedCodeLineCount) + var maxFocusedCodeLineCount + @AppStorage(\.useCodeScopeByDefaultInChatContext) + var useCodeScopeByDefaultInChatContext @AppStorage(\.chatFeatureProvider) var chatFeatureProvider @AppStorage(\.chatGPTModel) var chatGPTModel @@ -188,21 +186,17 @@ struct ChatSettingsView: View { @ViewBuilder var contextForm: some View { Form { - Toggle(isOn: $settings.useSelectionScopeByDefaultInChatContext) { - Text("Use selection scope by default in chat context.") - } - - Toggle(isOn: $settings.embedFileContentInChatContextIfNoSelection) { - Text("Embed file content in chat context if no code is selected.") + Toggle(isOn: $settings.useCodeScopeByDefaultInChatContext) { + Text("Use @code scope by default in chat context.") } HStack { TextField(text: .init(get: { - "\(Int(settings.maxEmbeddableFileInChatContextLineCount))" + "\(Int(settings.maxFocusedCodeLineCount))" }, set: { - settings.maxEmbeddableFileInChatContextLineCount = Int($0) ?? 0 + settings.maxFocusedCodeLineCount = Int($0) ?? 0 })) { - Text("Max embeddable file") + Text("Max focused code line count in chat context") } .textFieldStyle(.roundedBorder) diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 87241de8..e16070c3 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -274,11 +274,11 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: false, key: "EmbedFileContentInChatContextIfNoSelection") } - var maxEmbeddableFileInChatContextLineCount: PreferenceKey { + var maxFocusedCodeLineCount: PreferenceKey { .init(defaultValue: 100, key: "MaxEmbeddableFileInChatContextLineCount") } - var useSelectionScopeByDefaultInChatContext: PreferenceKey { + var useCodeScopeByDefaultInChatContext: PreferenceKey { .init(defaultValue: true, key: "UseSelectionScopeByDefaultInChatContext") } From 16eac0367af5e60b6a1ec3d2a568f79c29b71687 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 15:47:51 +0800 Subject: [PATCH 73/94] Support setting max focused code line count --- .../ActiveDocumentChatContextCollector.swift | 2 +- .../FocusedCodeFinder/FocusedCodeFinder.swift | 39 ++++++++++----- .../SwiftFocusedCodeFinder.swift | 48 +++++++++++++------ .../SuggestionModel/EditorInformation.swift | 2 +- 4 files changed, 64 insertions(+), 27 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index b5f8cab3..8dc96526 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -229,7 +229,7 @@ struct ActiveDocumentContext { case .builtIn(.swift): return SwiftFocusedCodeFinder() default: - return UnknownLanguageFocusedCodeFinder() + return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) } }() diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift index 168871e9..e6f32f66 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift @@ -14,10 +14,11 @@ struct CodeContext: Equatable { return [] case .top: return ["Top level of the file"] - case .scope(let signature): + case let .scope(signature): return signature } } + var scope: Scope var contextRange: CursorRange var focusedRange: CursorRange @@ -37,6 +38,12 @@ protocol FocusedCodeFinder { } struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { + let proposedSearchRange: Int + + init(proposedSearchRange: Int) { + self.proposedSearchRange = proposedSearchRange + } + func findFocusedCode( containingRange: CursorRange, activeDocumentContext: ActiveDocumentContext @@ -45,25 +52,32 @@ struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { // when user is not selecting any code. if containingRange.start == containingRange.end { - // search up and down for up to 7 lines. + // search up and down for up to `proposedSearchRange * 2 + 1` lines. let lines = activeDocumentContext.lines - var startLineIndex = max(containingRange.start.line - 3, 0) - let endLineIndex = min(containingRange.start.line + 3, lines.count - 1) - if endLineIndex - startLineIndex <= 6, startLineIndex > 0 { - startLineIndex = max(startLineIndex - (6 - (endLineIndex - startLineIndex)), 0) + let proposedLineCount = proposedSearchRange * 2 + 1 + var startLineIndex = max(containingRange.start.line - proposedSearchRange, 0) + let endLineIndex = min(containingRange.start.line + proposedSearchRange, lines.count - 1) + if endLineIndex - startLineIndex < proposedLineCount, startLineIndex > 0 { + startLineIndex = max( + startLineIndex - ((proposedSearchRange * 2) - (endLineIndex - startLineIndex)), + 0 + ) } let focusedLines = lines[startLineIndex...endLineIndex] - let contextStartLine = max(startLineIndex - 3, 0) - let contextEndLine = min(endLineIndex + 3, lines.count - 1) + let contextStartLine = max(startLineIndex - 5, 0) + let contextEndLine = min(endLineIndex + 5, lines.count - 1) return .init( scope: .top, contextRange: .init( start: .init(line: contextStartLine, character: 0), - end: .init(line: contextEndLine, character: 0) + end: .init(line: contextEndLine, character: lines[contextEndLine].count) + ), + focusedRange: .init( + start: .init(line: startLineIndex, character: 0), + end: .init(line: endLineIndex, character: lines[endLineIndex].count) ), - focusedRange: containingRange, focusedCode: focusedLines.joined(separator: "\n"), imports: [] ) @@ -82,7 +96,10 @@ struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { scope: .top, contextRange: .init( start: .init(line: contextStartLine, character: 0), - end: .init(line: contextEndLine, character: 0) + end: .init( + line: contextEndLine, + character: activeDocumentContext.lines[contextEndLine].count + ) ), focusedRange: containingRange, focusedCode: focusedLines.joined(separator: "\n"), diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index c36d54d7..afb5e2ef 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -5,6 +5,12 @@ import SwiftParser import SwiftSyntax struct SwiftFocusedCodeFinder: FocusedCodeFinder { + let maxFocusedCodeLineCount: Int + + init(maxFocusedCodeLineCount: Int = UserDefaults.shared.value(for: \.maxFocusedCodeLineCount)) { + self.maxFocusedCodeLineCount = maxFocusedCodeLineCount + } + func findFocusedCode( containingRange range: CursorRange, activeDocumentContext: ActiveDocumentContext @@ -27,8 +33,7 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { var nodes = visitor.findScopeHierarchy() - let code: String - let codeRange: CursorRange + var codeRange: CursorRange func convertRange(_ node: SyntaxProtocol) -> CursorRange { .init(sourceRange: node.sourceRange(converter: locationConverter)) @@ -52,10 +57,11 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { } } guard let focusedNode else { - var result = UnknownLanguageFocusedCodeFinder().findFocusedCode( - containingRange: range, - activeDocumentContext: activeDocumentContext - ) + var result = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + containingRange: range, + activeDocumentContext: activeDocumentContext + ) result.imports = visitor.imports return result } @@ -64,8 +70,26 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { codeRange = range } - code = EditorInformation - .code(in: activeDocumentContext.lines, inside: codeRange, ignoreColumns: true).code + let result = EditorInformation + .code(in: activeDocumentContext.lines, inside: codeRange, ignoreColumns: true) + + var code = result.code + + if range.isEmpty, result.lines.count > maxFocusedCodeLineCount { + // if the focused code is too long, truncate it to be shorter + let centerLine = range.start.line + let relativeCenterLine = centerLine - codeRange.start.line + let startLine = max(0, relativeCenterLine - maxFocusedCodeLineCount / 2) + let endLine = min(result.lines.count - 1, startLine + maxFocusedCodeLineCount) + code = result.lines[startLine...endLine].joined(separator: "\n") + codeRange = .init( + start: .init(line: startLine + codeRange.start.line, character: 0), + end: .init( + line: endLine + codeRange.start.line, + character: result.lines[endLine].count + ) + ) + } var contextRange = CursorRange.zero var signature = [String]() @@ -219,7 +243,7 @@ extension SwiftFocusedCodeFinder { let signature = node.bindings.first?.typeAnnotation?.trimmedDescription ?? "" return (.init( - signature: "\(type) \(name)\(signature.isEmpty ? "" : ": \(signature)")" + signature: "\(type) \(name)\(signature.isEmpty ? "" : "\(signature)")" .prefixedModifiers(node.modifierAndAttributeText(extractText)) .replacingOccurrences(of: "\n", with: " "), contextRange: convertRange(node) @@ -471,10 +495,6 @@ extension SwiftFocusedCodeFinder { // skip if possible - override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind { - skipChildrenIfPossible(node) - } - override func visit(_ node: MemberDeclBlockSyntax) -> SyntaxVisitorContinueKind { skipChildrenIfPossible(node) } @@ -486,7 +506,7 @@ extension SwiftFocusedCodeFinder { // capture if possible override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { - imports.append(node.trimmedDescription) + imports.append(node.path.trimmedDescription) return .skipChildren } diff --git a/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionModel/EditorInformation.swift index 2e083189..60ba65d2 100644 --- a/Tool/Sources/SuggestionModel/EditorInformation.swift +++ b/Tool/Sources/SuggestionModel/EditorInformation.swift @@ -11,7 +11,7 @@ public struct EditorInformation { public struct SourceEditorContent { /// The content of the source editor. public var content: String - /// The content of the source editor in lines. + /// The content of the source editor in lines. Every line should ends with `\n`. public var lines: [String] /// The selection ranges of the source editor. public var selections: [CursorRange] From 0f3928140cac85917a27710b80f14bdf30151e15 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 16:20:52 +0800 Subject: [PATCH 74/94] Update tests --- .../FocusedCodeFinder/FocusedCodeFinder.swift | 18 ++-- .../SwiftFocusedCodeFinder.swift | 8 +- .../SwiftFocusedCodeFinderTests.swift | 51 +++++++++- ...nknownLanguageFocusedCodeFinderTests.swift | 99 +++++++++++++++++++ 4 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift index e6f32f66..24642138 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift @@ -55,14 +55,12 @@ struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { // search up and down for up to `proposedSearchRange * 2 + 1` lines. let lines = activeDocumentContext.lines let proposedLineCount = proposedSearchRange * 2 + 1 - var startLineIndex = max(containingRange.start.line - proposedSearchRange, 0) - let endLineIndex = min(containingRange.start.line + proposedSearchRange, lines.count - 1) - if endLineIndex - startLineIndex < proposedLineCount, startLineIndex > 0 { - startLineIndex = max( - startLineIndex - ((proposedSearchRange * 2) - (endLineIndex - startLineIndex)), - 0 - ) - } + let startLineIndex = max(containingRange.start.line - proposedSearchRange, 0) + let endLineIndex = max( + startLineIndex, + min(startLineIndex + proposedLineCount - 1, lines.count - 1) + ) + let focusedLines = lines[startLineIndex...endLineIndex] let contextStartLine = max(startLineIndex - 5, 0) @@ -78,7 +76,7 @@ struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { start: .init(line: startLineIndex, character: 0), end: .init(line: endLineIndex, character: lines[endLineIndex].count) ), - focusedCode: focusedLines.joined(separator: "\n"), + focusedCode: focusedLines.joined(), imports: [] ) } @@ -102,7 +100,7 @@ struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { ) ), focusedRange: containingRange, - focusedCode: focusedLines.joined(separator: "\n"), + focusedCode: focusedLines.joined(), imports: [] ) } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index afb5e2ef..e0e52e4c 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -80,8 +80,12 @@ struct SwiftFocusedCodeFinder: FocusedCodeFinder { let centerLine = range.start.line let relativeCenterLine = centerLine - codeRange.start.line let startLine = max(0, relativeCenterLine - maxFocusedCodeLineCount / 2) - let endLine = min(result.lines.count - 1, startLine + maxFocusedCodeLineCount) - code = result.lines[startLine...endLine].joined(separator: "\n") + let endLine = max( + startLine, + min(result.lines.count - 1, startLine + maxFocusedCodeLineCount - 1) + ) + + code = result.lines[startLine...endLine].joined() codeRange = .init( start: .init(line: startLine + codeRange.start.line, character: 0), end: .init( diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift index 08ed4ca3..1c546a94 100644 --- a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift @@ -340,21 +340,33 @@ final class SwiftFocusedCodeFinder_FocusedCode_Tests: XCTestCase { case d case e } + + func hello() { + print("hello") + print("hello") + } """ let range = CursorRange(startPair: (0, 0), endPair: (0, 0)) - let context = SwiftFocusedCodeFinder().findFocusedCode( + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 1000).findFocusedCode( containingRange: range, activeDocumentContext: context(code: code) ) XCTAssertEqual(context, .init( scope: .top, - contextRange: .init(startPair: (0, 0), endPair: (6, 11)), - focusedRange: .init(startPair: (0, 0), endPair: (3, 11)), + contextRange: .init(startPair: (0, 0), endPair: (13, 2)), + focusedRange: .init(startPair: (0, 0), endPair: (10, 15)), focusedCode: """ @MainActor public indirect enum A { case a + case b + case c + case d + case e + } + + func hello() { """, imports: [] @@ -374,7 +386,7 @@ final class SwiftFocusedCodeFinder_FocusedCode_Tests: XCTestCase { } """ let range = CursorRange(startPair: (3, 0), endPair: (3, 0)) - let context = SwiftFocusedCodeFinder().findFocusedCode( + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 1000).findFocusedCode( containingRange: range, activeDocumentContext: context(code: code) ) @@ -397,4 +409,35 @@ final class SwiftFocusedCodeFinder_FocusedCode_Tests: XCTestCase { imports: [] )) } + + func test_get_focused_code_inside_enum_with_limited_max_line_count() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange(startPair: (3, 0), endPair: (3, 0)) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 3).findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .file, + contextRange: .init(startPair: (0, 0), endPair: (0, 0)), + focusedRange: .init(startPair: (2, 0), endPair: (4, 11)), + focusedCode: """ + indirect enum A { + case a + case b + + """, + imports: [] + )) + } } diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift new file mode 100644 index 00000000..90aa7dd9 --- /dev/null +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift @@ -0,0 +1,99 @@ +import XCTest +import Foundation + +@testable import ActiveDocumentChatContextCollector + +class UnknownLanguageFocusedCodeFinderTests: XCTestCase { + func context(code: String) -> ActiveDocumentContext { + .init( + filePath: "", + relativePath: "", + language: .builtIn(.swift), + fileContent: code, + lines: code.components(separatedBy: "\n").map { "\($0)\n" }, + selectedCode: "", selectionRange: .zero, + lineAnnotations: [], + imports: [] + ) + } + + func test_the_code_is_long_enough_for_the_search_range() { + let code = stride(from: 0, through: 100, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + containingRange: .init(startPair: (50, 0), endPair: (50, 0)), + activeDocumentContext: self.context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (40, 0), endPair: (60, 3)), + focusedRange: .init(startPair: (45, 0), endPair: (55, 3)), + focusedCode: stride(from: 45, through: 55, by: 1).map { "\($0)\n" }.joined(), + imports: [] + )) + } + + func test_the_upper_side_is_not_long_enough_expand_the_lower_end() { + let code = stride(from: 0, through: 100, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + containingRange: .init(startPair: (2, 0), endPair: (2, 0)), + activeDocumentContext: self.context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (15, 3)), + focusedRange: .init(startPair: (0, 0), endPair: (10, 3)), + focusedCode: stride(from: 0, through: 10, by: 1).map { "\($0)\n" }.joined(), + imports: [] + )) + } + + func test_the_lower_side_is_not_long_enough_do_not_expand_the_upper_end() { + let code = stride(from: 0, through: 100, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + containingRange: .init(startPair: (99, 0), endPair: (99, 0)), + activeDocumentContext: self.context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (89, 0), endPair: (101, 1)), + focusedRange: .init(startPair: (94, 0), endPair: (101, 1)), + focusedCode: stride(from: 94, through: 100, by: 1).map { "\($0)\n" }.joined() + "\n", + imports: [] + )) + } + + func test_both_sides_are_just_long_enough() { + let code = stride(from: 0, through: 10, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + containingRange: .init(startPair: (5, 0), endPair: (5, 0)), + activeDocumentContext: self.context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (11, 1)), + focusedRange: .init(startPair: (0, 0), endPair: (10, 3)), + focusedCode: code, + imports: [] + )) + } + + func test_both_sides_are_not_long_enough() { + let code = stride(from: 0, through: 4, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + containingRange: .init(startPair: (3, 0), endPair: (3, 0)), + activeDocumentContext: self.context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (5, 1)), + focusedRange: .init(startPair: (0, 0), endPair: (5, 1)), + focusedCode: code + "\n", + imports: [] + )) + } +} From 0d6e9a85f3fe77982738ab897cc2d12be076bd91 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 16:27:44 +0800 Subject: [PATCH 75/94] Remove summary of message of terminal chat plugin --- Core/Sources/ChatPlugin/TerminalChatPlugin.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift index cb6f2e4a..9022c788 100644 --- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift @@ -46,8 +46,7 @@ public actor TerminalChatPlugin: ChatPlugin { history.append( .init( role: .user, - content: originalMessage, - summary: "Run command: \(content)" + content: originalMessage ) ) } From fce6f2186adf5d629d57fa3156aa9b1fcfec1695 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 17:00:32 +0800 Subject: [PATCH 76/94] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index da9420c3..73b73b57 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit da9420c332fab5f4bfe94a57eb1d3c0abad2ae5e +Subproject commit 73b73b570ffe2353a72521bf64c5e3dc57875ceb From 8fba67ce32cee146a76c0d1a7cf0b23b47ab14ae Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 17:00:41 +0800 Subject: [PATCH 77/94] Update --- Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift index 23fac0ba..9b7aba60 100644 --- a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift +++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift @@ -26,7 +26,7 @@ public actor MathChatPlugin: ChatPlugin { var reply = ChatMessage(id: id, role: .assistant, content: "") await chatGPTService.memory.mutateHistory { history in - history.append(.init(role: .user, content: originalMessage, summary: content)) + history.append(.init(role: .user, content: originalMessage)) } do { From 678601e46443679a18b6feb6525068077c69b1c0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 17:16:23 +0800 Subject: [PATCH 78/94] Remove default scopes in custom chats, and reset them on prompt reset --- Core/Sources/ChatService/ChatService.swift | 13 +++++++++++++ .../ChatService/DynamicContextController.swift | 7 ++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 8b0e5c9b..91bdcebb 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -49,11 +49,22 @@ public final class ChatService: ObservableObject { functionProvider: memory.functionProvider ) ) + + resetDefaultScopes() + memory.chatService = self memory.observeHistoryChange { [weak self] in self?.objectWillChange.send() } } + + public func resetDefaultScopes() { + if UserDefaults.shared.value(for: \.useCodeScopeByDefaultInChatContext) { + memory.contextController.defaultScopes = ["code"] + } else { + memory.contextController.defaultScopes = ["file"] + } + } public func send(content: String) async throws { guard !isReceivingMessage else { throw CancellationError() } @@ -101,6 +112,7 @@ public final class ChatService: ObservableObject { public func resetPrompt() async { systemPrompt = UserDefaults.shared.value(for: \.defaultChatSystemPrompt) extraSystemPrompt = "" + resetDefaultScopes() } public func deleteMessage(id: String) async { @@ -161,6 +173,7 @@ public final class ChatService: ObservableObject { name: command.name ) case let .customChat(systemPrompt, prompt): + memory.contextController.defaultScopes = [] return .init( specifiedSystemPrompt: systemPrompt, extraSystemPrompt: "", diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index ac663313..cc53bd88 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -9,6 +9,7 @@ final class DynamicContextController { let contextCollectors: [ChatContextCollector] let memory: AutoManagedChatGPTMemory let functionProvider: ChatFunctionProvider + var defaultScopes = [] as Set convenience init( memory: AutoManagedChatGPTMemory, @@ -35,11 +36,7 @@ final class DynamicContextController { func updatePromptToMatchContent(systemPrompt: String, content: String) async throws { var content = content var scopes = Self.parseScopes(&content) - if UserDefaults.shared.value(for: \.useCodeScopeByDefaultInChatContext) { - scopes.insert("code") - } else { - scopes.insert("file") - } + scopes.formUnion(defaultScopes) functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) From baf25428c3485a39a32706bc3f0b96a36ea69d78 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 21:58:04 +0800 Subject: [PATCH 79/94] Update chat panel titlebar style --- Core/Sources/SuggestionWidget/ChatWindowView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 897cbbf4..f64072c5 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -67,13 +67,13 @@ struct ChatTitleBar: View { ) .frame(width: 10, height: 10) .overlay { - Circle().strokeBorder(.black.opacity(0.2), lineWidth: 1) + Circle().strokeBorder(.black.opacity(0.3), lineWidth: 1) } .overlay { if isHovering { Image(systemName: "minus") .resizable() - .foregroundStyle(.secondary) + .foregroundStyle(.black.opacity(0.7)) .font(Font.title.weight(.heavy)) .frame(width: 5, height: 1) } @@ -87,21 +87,22 @@ struct ChatTitleBar: View { Circle() .fill( controlActiveState == .key && viewStore.state - ? Color(nsColor: .systemIndigo) + ? Color(nsColor: .systemCyan) : Color(nsColor: .disabledControlTextColor) ) .frame(width: 10, height: 10) .overlay { - Circle().strokeBorder(.black.opacity(0.2), lineWidth: 1) + Circle().strokeBorder(.black.opacity(0.3), lineWidth: 1) } .disabled(!viewStore.state) .overlay { if isHovering { Image(systemName: "pin") .resizable() - .foregroundStyle(.secondary) + .foregroundStyle(.black.opacity(0.7)) .font(Font.title.weight(.heavy)) - .frame(width: 3, height: 5) + .frame(width: 4, height: 6) + .transformEffect(.init(translationX: 0, y: 0.5)) } } } From 6a049e89a8019fc3e26236ff69b9c8199b90167f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 22:00:14 +0800 Subject: [PATCH 80/94] Update --- Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift index 9b7aba60..913d088f 100644 --- a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift +++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift @@ -22,7 +22,6 @@ public actor MathChatPlugin: ChatPlugin { delegate?.pluginDidStartResponding(self) let id = "\(Self.command)-\(UUID().uuidString)" - async let translatedAnswer = translate(text: "Answer:") var reply = ChatMessage(id: id, role: .assistant, content: "") await chatGPTService.memory.mutateHistory { history in @@ -31,7 +30,7 @@ public actor MathChatPlugin: ChatPlugin { do { let result = try await solveMathProblem(content) - let formattedResult = "\(await translatedAnswer) \(result)" + let formattedResult = "Answer: \(result)" if !isCancelled { await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { From 25772e3f8cd218a5d32d736dbff49d390860ba61 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 23:41:52 +0800 Subject: [PATCH 81/94] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 73b73b57..5cfb287a 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 73b73b570ffe2353a72521bf64c5e3dc57875ceb +Subproject commit 5cfb287ad2e33dddf8a8650b59819e0519ffa959 From e51fa8a7029b4c179dbc8ec42262957bc5483ac5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 18:15:35 +0800 Subject: [PATCH 82/94] Bump version to 0.21.0 --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index bbe5b8e8..99817aa1 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,2 @@ -APP_VERSION = 0.20.1 -APP_BUILD = 210 +APP_VERSION = 0.21.0 +APP_BUILD = 220 From 46d4550c6e884269fe7632d1074265547d701b86 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 8 Aug 2023 21:51:41 +0800 Subject: [PATCH 83/94] Update README.md and LICENSE --- LICENSE | 2 ++ README.md | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index cc41db9f..a8b6fd3c 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,8 @@ This license is a combination of the GPLv3 and some additional agreements. +Features that requires a Plus license key are not included in this project, and are not open source. + As a contributor, you agree that your contributed code: a. may be subject to a more permissive open-source license in the future. b. can be used for commercial purposes. diff --git a/README.md b/README.md index 50704aee..e128843f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil Buy Me A Coffee +[Get a Plus License Key to unlock more features and support this project](https://intii.lemonsqueezy.com/checkout/buy/298a8d4c-11fb-4ecd-b328-049589645449) + ## Features - Code Suggestions (powered by GitHub Copilot and Codeium). @@ -28,13 +30,16 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Update](#update) - [Feature](#feature) - [Key Bindings](#key-bindings) +- [Plus Features](#plus-features) - [Limitations](#limitations) - [License](#license) -For frequently asked questions, check [FAQ](https://github.com/intitni/CopilotForXcode/issues/65). +For frequently asked questions, check [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions). For development instruction, check [Development.md](DEVELOPMENT.md). +For more information, check the [wiki](https://github.com/intitni/CopilotForXcode/wiki) + ## Prerequisites - Public network connection. @@ -296,6 +301,25 @@ Essentially using `⌥⇧` as the "access" key combination for all bindings. Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. +## Plus Features + +The pre-built binary contains a set of exclusive features that can only be accessed with a Plus license key. To obtain a license key, please visit [this link](https://intii.lemonsqueezy.com/checkout/buy/298a8d4c-11fb-4ecd-b328-049589645449). + +These features are included in another repo, and are not open sourced. + +The currently available Plus features include: + +- Browser tap in chat panel. +- Unlimited custom commands. + +Since the app needs to manage license keys, it will send network request to `https://copilotforxcode-license.intii.com`, +- when you activate the license key +- when you deactivate the license key +- when you opened the host app or the service app if a license key is available +- every 24 hours if a license key is available + +The request contains only the license key, the email address (only on activation), and an instance id. You are free to MITM the request to see what data is sent. + ## Limitations - The extension uses some dirty tricks to get the file and project/workspace paths. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though. From f69b8da85af8dcee66c3c3c69ad8c720d7dd5265 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 01:29:19 +0800 Subject: [PATCH 84/94] Fix crash when inserting text --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 5cfb287a..13884adf 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 5cfb287ad2e33dddf8a8650b59819e0519ffa959 +Subproject commit 13884adf4ed809f793ecaf383bb30e30b3acf203 From 476e46b1889d2257b7f40e18c40f4d8f56b9725d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 14:16:06 +0800 Subject: [PATCH 85/94] Add todos --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 13884adf..6e61d887 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 13884adf4ed809f793ecaf383bb30e30b3acf203 +Subproject commit 6e61d88739c7c3fadc450c04d87d46d72c2924f8 From 60d22b408254b8408e1b7a48c1e1d9b26dd65036 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 14:55:51 +0800 Subject: [PATCH 86/94] Update auto completion --- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index f76f7b1e..761ea855 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -412,8 +412,9 @@ struct ChatPanelInputArea: View { let plugins = chat.pluginIdentifiers.map { "/\($0)" } let availableFeatures = plugins + [ "/exit", - "@selection", + "@code", "@file", + "@web", ] let result: [String] = availableFeatures From 554319f885e3be4ef90b7f678036c019925da013 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 14:58:33 +0800 Subject: [PATCH 87/94] Update --- .../WebChatContextCollector/QueryWebsiteFunction.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift index 50f0db01..ed2b84c0 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -63,7 +63,7 @@ struct QueryWebsiteFunction: ChatGPTFunction { await reportProgress("Loading \(url)..") if let database = await TemporaryUSearch.view(identifier: urlString) { - await reportProgress("Generating answers..") + await reportProgress("Getting relevant information..") let qa = QAInformationRetrievalChain(vectorStore: database, embedding: embedding) return try await qa.call(.init(arguments.query)).information } @@ -82,7 +82,7 @@ struct QueryWebsiteFunction: ChatGPTFunction { let database = TemporaryUSearch(identifier: urlString) try await database.set(embeddedDocuments) // 4. generate answer - await reportProgress("Generating answers..") + await reportProgress("Getting relevant information..") let qa = QAInformationRetrievalChain(vectorStore: database, embedding: embedding) let result = try await qa.call(.init(arguments.query)) return result.information From 77ed5a86d0b4330fe2907b88003cf5bd55b2f313 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 15:48:16 +0800 Subject: [PATCH 88/94] Fix adding more than 10 custom commands --- .../HostApp/CustomCommandSettings/CustomCommand.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index da5debd1..880704c4 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import Foundation +import PlusFeatureFlag import Preferences import SwiftUI import Toast @@ -24,7 +25,9 @@ struct CustomCommandFeature: ReducerProtocol { Reduce { state, action in switch action { case .createNewCommand: - if settings.customCommands.count >= 10 { + if !isFeatureAvailable(\.unlimitedCustomCommands), + settings.customCommands.count >= 10 + { toast("Upgrade to Plus to add more commands", .info) return .none } From ae4e40c1972da806b688713bf960af2ef4fd5a92 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 15:48:24 +0800 Subject: [PATCH 89/94] Update appcast.xml --- appcast.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/appcast.xml b/appcast.xml index f7a3c1e0..fb20d4a5 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.21.0 + Wed, 09 Aug 2023 15:45:24 +0800 + 220 + 0.21.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.21.0 + + + + 0.20.1 Fri, 21 Jul 2023 16:00:42 +0800 From 447536d67e7258d9b0c688b011895b35cf754f56 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 17:12:22 +0800 Subject: [PATCH 90/94] Update close_inactive_issues.yml --- .github/workflows/close_inactive_issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close_inactive_issues.yml b/.github/workflows/close_inactive_issues.yml index fbf141c6..6be38831 100644 --- a/.github/workflows/close_inactive_issues.yml +++ b/.github/workflows/close_inactive_issues.yml @@ -15,7 +15,7 @@ jobs: days-before-issue-stale: 30 days-before-issue-close: 14 stale-issue-label: "stale" - exempt-issue-labels: "low priority, help wanted" + exempt-issue-labels: "low priority, help wanted, planned" stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." days-before-pr-stale: -1 From f807900cd7db58f1b314fc74a574d27ed30b566b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 18:55:17 +0800 Subject: [PATCH 91/94] Update bug_report.yaml --- .github/ISSUE_TEMPLATE/bug_report.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 53c159c1..ec05061a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -13,7 +13,7 @@ body: id: before-reporting attributes: label: Before Reporting - description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/issues/65) to check if it may address your issue. And search for existing issues to avoid duplication. + description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. options: - label: I have checked FAQ, and there is no solution to my issue required: true @@ -57,4 +57,4 @@ body: id: node-version attributes: label: Node version - \ No newline at end of file + From 52ef2be5760b1be043514505a16f5c26bb28adf0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 18:55:52 +0800 Subject: [PATCH 92/94] Update help_wanted.yml --- .github/ISSUE_TEMPLATE/help_wanted.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/help_wanted.yml b/.github/ISSUE_TEMPLATE/help_wanted.yml index 4a340e89..04b675c7 100644 --- a/.github/ISSUE_TEMPLATE/help_wanted.yml +++ b/.github/ISSUE_TEMPLATE/help_wanted.yml @@ -7,7 +7,7 @@ body: id: before-reporting attributes: label: Before Reporting - description: Before asking for help, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/issues/65) to check if it may address your issue. And search for existing issues to avoid duplication. + description: Before asking for help, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. options: - label: I have checked FAQ, and there is no solution to my issue required: true From 6434575e99fa6780e5cd840777117916399e4b75 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 22:05:45 +0800 Subject: [PATCH 93/94] Update README.md --- README.md | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e128843f..36b32c7c 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Install](#install) - [Enable the Extension](#enable-the-extension) - [Granting Permissions to the App](#granting-permissions-to-the-app) + - [Setting Up Key Bindings](#setting-up-key-bindings) - [Setting Up GitHub Copilot](#setting-up-github-copilot) - [Setting Up Codeium](#setting-up-codeium) - [Setting Up OpenAI API Key](#setting-up-openai-api-key) - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) - [Update](#update) - [Feature](#feature) -- [Key Bindings](#key-bindings) - [Plus Features](#plus-features) - [Limitations](#limitations) - [License](#license) @@ -97,6 +97,28 @@ Alternatively, you may manually grant the required permissions by navigating to If you encounter an alert requesting permission that you have previously granted, please remove the permission from the list and add it again to re-grant the necessary permissions. +### Setting Up Key Bindings + +The extension will work better if you use key bindings. + +It looks like there is no way to add default key bindings to commands, but you can set them up in `Xcode settings > Key Bindings`. You can filter the list by typing `copilot` in the search bar. + +A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that should cause no conflict is + +| Command | Key Binding | +| ------------------- | ----------- | +| Get Suggestions | `⌥?` | +| Accept Suggestions | `⌥}` | +| Reject Suggestion | `⌥{` | +| Next Suggestion | `⌥>` | +| Previous Suggestion | `⌥<` | +| Open Chat | `⌥"` | +| Explain Selection | `⌥\|` | + +Essentially using `⌥⇧` as the "access" key combination for all bindings. + +Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. + ### Setting Up GitHub Copilot 1. In the host app, switch to the service tab and click on GitHub Copilot to access your GitHub Copilot account settings. @@ -281,26 +303,6 @@ For Send Message, Single Round Dialog and Custom Chat commands, you can use the | `{{active_editor_file_url}}` | The URL of the active file in the editor. | | `{{active_editor_file_name}}` | The name of the active file in the editor. | -## Key Bindings - -It looks like there is no way to add default key bindings to commands, but you can set them up in `Xcode settings > Key Bindings`. You can filter the list by typing `copilot` in the search bar. - -A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that should cause no conflict is - -| Command | Key Binding | -| ------------------- | ----------- | -| Get Suggestions | `⌥?` | -| Accept Suggestions | `⌥}` | -| Reject Suggestion | `⌥{` | -| Next Suggestion | `⌥>` | -| Previous Suggestion | `⌥<` | -| Open Chat | `⌥"` | -| Explain Selection | `⌥\|` | - -Essentially using `⌥⇧` as the "access" key combination for all bindings. - -Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. - ## Plus Features The pre-built binary contains a set of exclusive features that can only be accessed with a Plus license key. To obtain a license key, please visit [this link](https://intii.lemonsqueezy.com/checkout/buy/298a8d4c-11fb-4ecd-b328-049589645449). From 129c4491835012369a484cf7b4712e539c2eb635 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 9 Aug 2023 22:06:56 +0800 Subject: [PATCH 94/94] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 36b32c7c..d2475525 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,6 @@ A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that | Command | Key Binding | | ------------------- | ----------- | -| Get Suggestions | `⌥?` | | Accept Suggestions | `⌥}` | | Reject Suggestion | `⌥{` | | Next Suggestion | `⌥>` |