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 + 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 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 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 987b3747..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 */ @@ -161,6 +173,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 = ""; }; @@ -170,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 */ @@ -249,12 +263,14 @@ C8520308293D805800460097 /* README.md */, C82E38492A1F025F00D4EADF /* LICENSE */, C83E5DED2A38CD8C0071506D /* Makefile */, + C8F103292A7A365000D28F4F /* launchAgent.plist */, C81E867D296FE4420026E908 /* Version.xcconfig */, C81458AD293A009600135263 /* Config.xcconfig */, C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, + C87903302A5D2E6400FE6F42 /* Pro */, C8189B182938972F00C9DCDA /* Copilot for Xcode */, C81458922939EFDC00135263 /* EditorExtension */, C8216B71298036EC00AD38C7 /* Helper */, @@ -352,6 +368,7 @@ C814589F2939EFDC00135263 /* Embed Foundation Extensions */, C8520306293CF0EF00460097 /* Embed XPCService */, C8C8B60829AFA32800034BEE /* Embed Service */, + C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */, ); buildRules = ( ); diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ab5e470..63235d66 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", @@ -207,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", @@ -216,6 +216,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", @@ -234,6 +243,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/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 2595833c..b0392ee9 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", @@ -46,26 +43,25 @@ 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" ), - ], + .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), + ].pro, targets: [ // MARK: - Main .target( name: "Client", dependencies: [ - "SuggestionModel", "XPCShared", "GitHubCopilotService", + .product(name: "SuggestionModel", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), ] @@ -73,28 +69,30 @@ let package = Package( .target( name: "Service", dependencies: [ - "SuggestionModel", "SuggestionService", "GitHubCopilotService", "XPCShared", "CGEventObserver", "DisplayLink", - "ActiveApplicationMonitor", - "AXNotificationStream", - "Environment", "SuggestionWidget", - "AXExtension", "ChatService", "PromptToCodeService", "ServiceUpdateMigration", "UserDefaultsObserver", - "ChatTab", + "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"), - ] + .product(name: "Dependencies", package: "swift-dependencies"), + ].pro([ + "ProChatTabs", + ]) ), .testTarget( name: "ServiceTests", @@ -104,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"), ] ), @@ -126,35 +116,31 @@ let package = Package( "Client", "GitHubCopilotService", "CodeiumService", - "SuggestionModel", "LaunchAgentManager", + "PlusFeatureFlag", + .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: ["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", @@ -171,9 +157,8 @@ 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"), ] ), @@ -186,8 +171,6 @@ let package = Package( dependencies: [ "ChatPlugin", "ChatContextCollector", - "Environment", - "XcodeInspector", // plugins "MathChatPlugin", @@ -196,7 +179,10 @@ let package = Package( // context collectors "WebChatContextCollector", + "ActiveDocumentChatContextCollector", + .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"), @@ -206,7 +192,7 @@ let package = Package( .target( name: "ChatPlugin", dependencies: [ - "Environment", + .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Terminal", package: "Tool"), ] @@ -214,47 +200,37 @@ 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"), ] ), .target( - name: "ChatTab", + 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"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), ] ), // MARK: - UI - .target( - name: "SharedUIComponents", - dependencies: [ - "Highlightr", - "Splash", - .product(name: "Preferences", package: "Tool"), - ] - ), - .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), - .target( name: "SuggestionWidget", dependencies: [ - "ChatTab", - "ActiveApplicationMonitor", - "AXNotificationStream", - "Environment", + "ChatGPTChatTab", "UserDefaultsObserver", - "XcodeInspector", - "SharedUIComponents", + .product(name: "SharedUIComponents", package: "Tool"), + .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"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), @@ -274,13 +250,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: [ @@ -288,7 +257,6 @@ let package = Package( .product(name: "Logger", package: "Tool"), ] ), - .target(name: "AXExtension"), .target( name: "ServiceUpdateMigration", dependencies: [ @@ -298,15 +266,11 @@ let package = Package( ), .target(name: "UserDefaultsObserver"), .target( - name: "XcodeInspector", + name: "PlusFeatureFlag", dependencies: [ - "AXExtension", - "SuggestionModel", - "Environment", - "AXNotificationStream", - .product(name: "Logger", package: "Tool"), - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - ] + ].pro([ + "LicenseManagement" + ]) ), // MARK: - GitHub Copilot @@ -315,8 +279,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"), @@ -334,9 +298,9 @@ let package = Package( name: "CodeiumService", dependencies: [ "LanguageClient", - "SuggestionModel", - "KeychainAccess", - "XcodeInspector", + .product(name: "Keychain", package: "Tool"), + .product(name: "SuggestionModel", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Terminal", package: "Tool"), ] @@ -388,6 +352,56 @@ 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" + ), + + .testTarget( + name: "ActiveDocumentChatContextCollectorTests", + dependencies: ["ActiveDocumentChatContextCollector"] + ), ] ) +// 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/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift new file mode 100644 index 00000000..8dc96526 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -0,0 +1,291 @@ +import ASTParser +import ChatContextCollector +import Foundation +import OpenAIService +import Preferences +import SuggestionModel +import XcodeInspector + +public final class ActiveDocumentChatContextCollector: ChatContextCollector { + public init() {} + + var activeDocumentContext: ActiveDocumentContext? + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String + ) -> ChatContext? { + 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(context), + functions: functions + ) + } + + func getActiveDocumentContext(_ info: EditorInformation) -> ActiveDocumentContext { + var activeDocumentContext = activeDocumentContext ?? .init( + filePath: "", + relativePath: "", + language: .builtIn(.swift), + fileContent: "", + lines: [], + selectedCode: "", + selectionRange: .outOfScope, + lineAnnotations: [], + imports: [] + ) + + activeDocumentContext.update(info) + return activeDocumentContext + } + + func extractSystemPrompt(_ context: ActiveDocumentContext) -> String { + let start = """ + ## 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 = 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 + )): + ```\(context.language.rawValue) + \(focusedContext.code) + ``` + """ + + let fileAnnotations = focusedContext.otherLineAnnotations.isEmpty + ? "" + : """ + 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 + ? "" + : """ + Annotations Inside Focused Range:\""" + \( + focusedContext.lineAnnotations + .map(convertAnnotationToText) + .joined(separator: "\n") + ) + \""" + """ + + return [ + start, + relativePath, + language, + codeContext, + codeRange, + code, + codeAnnotations, + fileAnnotations, + end, + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + } else { + let selectionRange = "Selection Range [line, character]: \(context.selectionRange)" + let lineAnnotations = context.lineAnnotations.isEmpty + ? "" + : """ + Line Annotations:\""" + \(context.lineAnnotations.map(convertAnnotationToText).joined(separator: "\n")) + \""" + """ + + return [ + start, + relativePath, + language, + lineAnnotations, + selectionRange, + end, + ] + .filter { !$0.isEmpty } + .joined(separator: "\n") + } + } + + func convertAnnotationToText(_ annotation: EditorInformation.LineAnnotation) -> String { + return "- Line \(annotation.line), \(annotation.type): \(annotation.message)" + } +} + +struct ActiveDocumentContext { + var filePath: String + 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(proposedSearchRange: 5) + } + }() + + 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: codeContext.scopeSignatures, + 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 + }() + + filePath = info.documentURL.path + 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..24642138 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift @@ -0,0 +1,108 @@ +import Foundation +import SuggestionModel + +struct CodeContext: Equatable { + enum Scope: Equatable { + case file + case top + case scope(signature: [String]) + } + + var scopeSignatures: [String] { + switch scope { + case .file: + return [] + case .top: + return ["Top level of the file"] + case let .scope(signature): + return signature + } + } + + 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 { + let proposedSearchRange: Int + + init(proposedSearchRange: Int) { + self.proposedSearchRange = proposedSearchRange + } + + 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 `proposedSearchRange * 2 + 1` lines. + let lines = activeDocumentContext.lines + let proposedLineCount = proposedSearchRange * 2 + 1 + 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) + 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: lines[contextEndLine].count) + ), + focusedRange: .init( + start: .init(line: startLineIndex, character: 0), + end: .init(line: endLineIndex, character: lines[endLineIndex].count) + ), + focusedCode: focusedLines.joined(), + 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: activeDocumentContext.lines[contextEndLine].count + ) + ), + focusedRange: containingRange, + focusedCode: focusedLines.joined(), + 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..e0e52e4c --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -0,0 +1,582 @@ +import ASTParser +import Foundation +import SuggestionModel +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 + ) -> CodeContext { + let source = activeDocumentContext.fileContent + #warning("TODO: cache the tree") + let tree = Parser.parse(source: source) + + let locationConverter = SourceLocationConverter( + file: activeDocumentContext.filePath, + tree: tree + ) + + let visitor = SwiftScopeHierarchySyntaxVisitor( + tree: tree, + code: source, + range: range, + locationConverter: locationConverter + ) + + var nodes = visitor.findScopeHierarchy() + + var 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(proposedSearchRange: 5) + .findFocusedCode( + containingRange: range, + activeDocumentContext: activeDocumentContext + ) + result.imports = visitor.imports + return result + } + codeRange = convertRange(focusedNode) + } else { + codeRange = range + } + + 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 = 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( + line: endLine + codeRange.start.line, + character: result.lines[endLine].count + ) + ) + } + + var contextRange = CursorRange.zero + var signature = [String]() + + while let node = nodes.first { + nodes.removeFirst() + let (context, more) = contextContainingNode( + node, + parentNodes: nodes, + tree: tree, + activeDocumentContext: activeDocumentContext, + locationConverter: locationConverter + ) + + if let context { + contextRange = context.contextRange + signature.insert(context.signature, at: 0) + } + + if !more { + break + } + } + + return .init( + scope: signature.isEmpty ? .file : .scope(signature: signature), + contextRange: contextRange, + focusedRange: codeRange, + focusedCode: code, + imports: visitor.imports + ) + } +} + +extension SwiftFocusedCodeFinder { + struct ContextInfo { + var signature: String + var contextRange: CursorRange + var canBeUsedAsCodeRange: Bool = true + } + + func contextContainingNode( + _ node: SyntaxProtocol, + parentNodes: [SyntaxProtocol], + tree: SourceFileSyntax, + activeDocumentContext: ActiveDocumentContext, + locationConverter: SourceLocationConverter + ) -> (context: ContextInfo?, more: Bool) { + func convertRange(_ node: SyntaxProtocol) -> CursorRange { + .init(sourceRange: node.sourceRange(converter: locationConverter)) + } + + 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( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), false) + + case let node as ClassDeclSyntax: + let type = node.classKeyword.text + let name = node.identifier.text + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), false) + + case let node as EnumDeclSyntax: + let type = node.enumKeyword.text + let name = node.identifier.text + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), false) + + case let node as ActorDeclSyntax: + let type = node.actorKeyword.text + let name = node.identifier.text + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: ""), + contextRange: convertRange(node) + ), false) + + case let node as MacroDeclSyntax: + let type = node.macroKeyword.text + let name = node.identifier.text + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), false) + + case let node as ProtocolDeclSyntax: + let type = node.protocolKeyword.text + let name = node.identifier.text + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), false) + + case let node as ExtensionDeclSyntax: + let type = node.extensionKeyword.text + let name = node.extendedType.trimmedDescription + return (.init( + signature: "\(type) \(name)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .suffixedInheritance(node.inheritanceClauseTexts(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), false) + + case let node as FunctionDeclSyntax: + 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)" + .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?.typeAnnotation?.trimmedDescription ?? "" + + return (.init( + signature: "\(type) \(name)\(signature.isEmpty ? "" : "\(signature)")" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), true) + + case let node as AccessorDeclSyntax: + let keyword = node.accessorSpecifier.text + let signature = keyword + + return (.init( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), true) + + 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( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), true) + + case let node as InitializerDeclSyntax: + let signature = "init" + + return (.init( + signature: "\(signature)" + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), + + contextRange: convertRange(node) + ), true) + + case let node as DeinitializerDeclSyntax: + let signature = "deinit" + + return (.init( + signature: signature + .prefixedModifiers(node.modifierAndAttributeText(extractText)) + .replacingOccurrences(of: "\n", with: " "), + + contextRange: convertRange(node) + ), true) + + case let node as ClosureExprSyntax: + let signature = "closure" + + return (.init( + signature: signature.replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), true) + + case let node as FunctionCallExprSyntax: + let signature = "function call" + + return (.init( + signature: signature.replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node), + canBeUsedAsCodeRange: false + ), true) + + case let node as SwitchCaseSyntax: + return (.init( + signature: node.trimmedDescription.replacingOccurrences(of: "\n", with: " "), + contextRange: convertRange(node) + ), true) + + default: + return (nil, true) + } + } + + 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 + let locationConverter: SourceLocationConverter + + 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, + locationConverter: SourceLocationConverter + ) { + self.tree = tree + self.code = code + self.range = range + 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: locationConverter) + let cursorRange = CursorRange(sourceRange: sourceRange) + return cursorRange.strictlyContains(range) + } + + // skip if possible + + 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.path.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..9ceb23f8 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift @@ -0,0 +1,58 @@ +import ASTParser +import Foundation +import OpenAIService +import SuggestionModel + +struct ExpandFocusRangeFunction: ChatGPTFunction { + struct Arguments: Codable {} + + struct Result: ChatGPTFunctionResult { + var range: CursorRange + + var botReadableContent: String { + "User Editing Document Context is updated to display code at \(range)." + } + } + + struct E: Error, LocalizedError { + var errorDescription: String? + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "expandFocusRange" + } + + var description: String { + "Call when User Editing Document Context provides too little context to answer a question." + } + + 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 expand focused code." + await reportProgress(progress) + throw E(errorDescription: progress) + } + let progress = "Looking at \(newContext.codeRange)." + await reportProgress(progress) + return .init(range: newContext.codeRange) + } +} diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift new file mode 100644 index 00000000..ec3a8310 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift @@ -0,0 +1,67 @@ +import ASTParser +import Foundation +import OpenAIService +import SuggestionModel + +struct MoveToCodeAroundLineFunction: ChatGPTFunction { + struct Arguments: Codable { + var line: Int + } + + struct Result: ChatGPTFunctionResult { + var range: CursorRange + + var botReadableContent: String { + "User Editing Document Context is updated to display code at \(range)." + } + } + + struct E: Error, LocalizedError { + var errorDescription: String? + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "getCodeAtLine" + } + + var description: String { + "Get the code at the given line, so you can answer the question about the code at that line." + } + + var argumentSchema: JSONSchemaValue { [ + .type: "object", + .properties: [ + "line": [ + .type: "number", + .description: "The line number in the file", + ] + ], + .required: ["line"], + ] } + + 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) + throw E(errorDescription: progress) + } + let progress = "Looking at \(newContext.codeRange)" + await reportProgress(progress) + return .init(range: newContext.codeRange) + } +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift new file mode 100644 index 00000000..8f094d66 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift @@ -0,0 +1,58 @@ +import ASTParser +import Foundation +import OpenAIService +import SuggestionModel + +struct MoveToFocusedCodeFunction: ChatGPTFunction { + struct Arguments: Codable {} + + struct Result: ChatGPTFunctionResult { + var range: CursorRange + + var botReadableContent: String { + "User Editing Document Context is updated to display code at \(range)." + } + } + + struct E: Error, LocalizedError { + var errorDescription: String? + } + + 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) + throw E(errorDescription: progress) + } + let progress = "Looking at \(newContext.codeRange)." + await reportProgress(progress) + return .init(range: newContext.codeRange) + } +} diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift new file mode 100644 index 00000000..7c88aa27 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -0,0 +1,41 @@ +import Foundation +import SuggestionModel +import XcodeInspector + +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 + let language = languageIdentifierFromFileURL(documentURL) + let relativePath = documentURL.path + .replacingOccurrences(of: projectURL.path, with: "") + + if let editorContent, let range = editorContent.selections.first { + let (selectedContent, selectedLines) = EditorInformation.code( + in: editorContent.lines, + inside: range + ) + return .init( + editorContent: editorContent, + selectedContent: selectedContent, + selectedLines: selectedLines, + documentURL: documentURL, + projectURL: projectURL, + relativePath: relativePath, + language: language + ) + } + + return .init( + editorContent: editorContent, + selectedContent: "", + selectedLines: [], + documentURL: documentURL, + projectURL: projectURL, + relativePath: relativePath, + language: language + ) +} + diff --git a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift similarity index 62% rename from Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift rename to Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index f8c33dda..a54bbfba 100644 --- a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -3,8 +3,9 @@ import OpenAIService import Preferences import SuggestionModel import XcodeInspector +import ChatContextCollector -public struct ActiveDocumentChatContextCollector: ChatContextCollector { +public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { public init() {} public func generateContext( @@ -12,12 +13,11 @@ public struct ActiveDocumentChatContextCollector: 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") { + if scopes.contains("file") || scopes.contains("f") { return """ File Content:```\(content.language.rawValue) \(content.editorContent?.content ?? "") @@ -30,7 +30,7 @@ public struct ActiveDocumentChatContextCollector: 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 ActiveDocumentChatContextCollector: 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) @@ -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) @@ -103,47 +103,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/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift new file mode 100644 index 00000000..9d3dfaa0 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ReadableCursorRange.swift @@ -0,0 +1,13 @@ +import SuggestionModel + +extension CursorPosition { + var text: String { + "[\(line), \(character)]" + } +} + +extension CursorRange { + var text: String { + "\(start.description) - \(end.description)" + } +} diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift index 611cd1c0..ed2b84c0 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -63,15 +63,9 @@ struct QueryWebsiteFunction: ChatGPTFunction { await reportProgress("Loading \(url)..") 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 - ) - } - return try await qa.call(.init(arguments.query)).answer + await reportProgress("Getting relevant information..") + 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() @@ -88,16 +82,10 @@ struct QueryWebsiteFunction: ChatGPTFunction { let database = TemporaryUSearch(identifier: urlString) 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 - ) - } + await reportProgress("Getting relevant information..") + 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/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(), 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/ChatTab/ChatGPT/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift similarity index 75% rename from Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift rename to Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 6609fcd3..a13d2b6f 100644 --- a/Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -1,23 +1,59 @@ 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 buildable: Bool { true } + 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: "New Chat", customCommand: nil)] + customCommands + } + public init(service: ChatService = .init()) { 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 82% rename from Core/Sources/ChatTab/ChatGPT/ChatPanel.swift rename to Core/Sources/ChatGPTChatTab/ChatPanel.swift index a14354d7..761ea855 100644 --- a/Core/Sources/ChatTab/ChatGPT/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -104,55 +104,76 @@ private struct StopRespondingButton: View { } private struct Instruction: View { - @AppStorage(\.useSelectionScopeByDefaultInChatContext) - var useSelectionScopeByDefaultInChatContext - @AppStorage(\.chatFontSize) var chatFontSize + @AppStorage(\.useCodeScopeByDefaultInChatContext) + 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) } } @@ -302,9 +323,6 @@ struct ChatPanelInputArea: View { } .padding(8) .background(.ultraThickMaterial) - .contextMenu { - ChatContextMenu(chat: chat) - } } var clearButton: some View { @@ -394,8 +412,9 @@ struct ChatPanelInputArea: View { let plugins = chat.pluginIdentifiers.map { "/\($0)" } let availableFeatures = plugins + [ "/exit", - "@selection", + "@code", "@file", + "@web", ] let result: [String] = availableFeatures @@ -412,72 +431,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/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/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 ) ) } diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift index 23fac0ba..913d088f 100644 --- a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift +++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift @@ -22,16 +22,15 @@ 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 - history.append(.init(role: .user, content: originalMessage, summary: content)) + history.append(.init(role: .user, content: originalMessage)) } 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 { 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 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/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 4512fc20..91bdcebb 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,66 +16,61 @@ 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 ) ) + + 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() } 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 { @@ -117,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 { @@ -177,6 +173,7 @@ public final class ChatService: ObservableObject { name: command.name ) case let .customChat(systemPrompt, prompt): + memory.contextController.defaultScopes = [] return .init( specifiedSystemPrompt: systemPrompt, extraSystemPrompt: "", @@ -231,5 +228,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/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/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index eca176dc..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, @@ -34,7 +35,9 @@ final class DynamicContextController { func updatePromptToMatchContent(systemPrompt: String, content: String) async throws { var content = content - let scopes = Self.parseScopes(&content) + var scopes = Self.parseScopes(&content) + scopes.formUnion(defaultScopes) + functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) let oldMessages = await memory.history 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/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/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() {} 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..dbfafea7 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) } } }) { @@ -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) { @@ -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 c6409ea8..880704c4 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -1,27 +1,36 @@ import ComposableArchitecture import Foundation +import PlusFeatureFlag import Preferences import SwiftUI +import Toast struct CustomCommandFeature: ReducerProtocol { struct State: Equatable { var editCustomCommand: EditCustomCommand.State? } - + let settings: CustomCommandView.Settings - let toast: (Text, ToastType) -> Void - + enum Action: Equatable { case createNewCommand case editCommand(CustomCommand) case editCustomCommand(EditCustomCommand.Action) case deleteCommand(CustomCommand) } - + + @Dependency(\.toast) var toast + var body: some ReducerProtocol { Reduce { state, action in switch action { case .createNewCommand: + if !isFeatureAvailable(\.unlimitedCustomCommands), + 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): @@ -40,10 +49,10 @@ struct CustomCommandFeature: ReducerProtocol { return .none case .editCustomCommand: return .none - } }.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..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 @@ -17,12 +18,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() ) ) @@ -77,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() @@ -247,10 +248,7 @@ struct CustomCommandView_Preview: PreviewProvider { ) ))) ), - reducer: CustomCommandFeature( - settings: settings, - toast: { _, _ in } - ) + reducer: CustomCommandFeature(settings: settings) ), settings: settings ) @@ -286,10 +284,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..03d8ddf9 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(\.toast) var toast var body: some ReducerProtocol { Scope(state: \.sendMessage, action: /Action.sendMessage) { @@ -109,7 +110,7 @@ struct EditCustomCommand: ReducerProtocol { switch action { case .saveCommand: guard !state.name.isEmpty else { - toast(Text("Command name cannot be empty."), .error) + toast("Command name cannot be empty.", .error) return .none } @@ -154,7 +155,7 @@ struct EditCustomCommand: ReducerProtocol { if state.isNewCommand { settings.customCommands.append(newCommand) state.isNewCommand = false - toast(Text("The command is created."), .info) + toast("The command is created.", .info) } else { if let index = settings.customCommands.firstIndex(where: { $0.id == newCommand.id @@ -163,7 +164,7 @@ struct EditCustomCommand: ReducerProtocol { } else { settings.customCommands.append(newCommand) } - toast(Text("The command is updated."), .info) + toast("The command is updated.", .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/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/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift new file mode 100644 index 00000000..6e1aa17e --- /dev/null +++ b/Core/Sources/HostApp/General.swift @@ -0,0 +1,78 @@ +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(\.toast) var toast + + 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 { + toast(error.localizedDescription, .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 { + toast(error.localizedDescription, .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..7ce32d54 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) - } - } } } @@ -163,7 +154,7 @@ struct LaunchAgentView: View { try await LaunchAgentManager().setupLaunchAgent() isDidSetupLaunchAgentAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -185,7 +176,7 @@ struct LaunchAgentView: View { try await LaunchAgentManager().removeLaunchAgent() isDidRemoveLaunchAgentAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -204,7 +195,7 @@ struct LaunchAgentView: View { try await LaunchAgentManager().reloadLaunchAgent() isDidRestartLaunchAgentAlertPresented = true } catch { - toast(Text(error.localizedDescription), .error) + toast(error.localizedDescription, .error) } } }) { @@ -231,6 +222,8 @@ struct GeneralSettingsView: View { var widgetColorScheme @AppStorage(\.preferWidgetToStayInsideEditorWhenWidthGreaterThan) var preferWidgetToStayInsideEditorWhenWidthGreaterThan + @AppStorage(\.hideCircularWidget) + var hideCircularWidget } @StateObject var settings = Settings() @@ -292,13 +285,84 @@ struct GeneralSettingsView: View { Text("pt") } + + Toggle(isOn: $settings.hideCircularWidget) { + Text("Hide circular widget") + } }.padding() } } +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() + GeneralView(store: .init(initialState: .init(), reducer: General())) + .frame(height: 800) } } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift new file mode 100644 index 00000000..7b96456b --- /dev/null +++ b/Core/Sources/HostApp/HostApp.swift @@ -0,0 +1,51 @@ +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: + #if canImport(LicenseManagement) + return .run { _ in + let service = try getService() + do { + try await service + .postNotification(name: Notification.Name.licenseKeyChanged.rawValue) + } catch { + toast(error.localizedDescription, .error) + } + } + #else + return .none + #endif + case .general: + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 24db9ee4..77c62a2c 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -1,52 +1,79 @@ +import ComposableArchitecture +import Dependencies import Foundation import LaunchAgentManager import SwiftUI +import Toast import UpdateChecker -enum Tab: Int, CaseIterable, Equatable { - case general - case service - case feature - case customCommand - case debug -} +#if canImport(ProHostApp) +import ProHostApp +#endif @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" + ) + #if canImport(ProHostApp) + PlusView(onLicenseKeyChanged: { + store.send(.informExtensionServiceAboutLicenseKeyChange) + }).tabBarItem( + tag: 5, + title: "Plus", + image: "plus.diamond" + ) + #endif + DebugSettingsView().tabBarItem( + tag: 4, + title: "Advanced", + image: "gearshape.2" + ) } + .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) .overlay(alignment: .bottom) { VStack(spacing: 4) { @@ -70,79 +97,48 @@ 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) } + .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 +152,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 +171,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 +255,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/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/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift new file mode 100644 index 00000000..ce45eaf8 --- /dev/null +++ b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift @@ -0,0 +1,45 @@ +import Foundation +import SwiftUI + +#if canImport(LicenseManagement) + +import LicenseManagement + +#else + +public typealias PlusFeatureFlag = Int + +@dynamicMemberLookup +public struct PlusFeatureFlags { + public subscript(dynamicMember dynamicMember: String) -> PlusFeatureFlag { return 0 } + 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 { + #if canImport(LicenseManagement) + return LicenseManagement.isFeatureAvailable(flag) + #else + return false + #endif +} + 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 - } -} - 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 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.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift similarity index 87% rename from Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift rename to Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index f7fc7196..bd4ea831 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -1,6 +1,8 @@ import AppKit +import ChatGPTChatTab import ChatTab import ComposableArchitecture +import Dependencies import Environment import Preferences import SuggestionWidget @@ -34,9 +36,15 @@ 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): + 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)) } @@ -113,13 +121,18 @@ public final class GraphicalUserInterfaceController { private init() { let suggestionDependency = SuggestionWidgetControllerDependency() - let store = StoreOf( - initialState: .init(), - reducer: GUI() - ) { dependencies in + let setupDependency: (inout DependencyValues) -> Void = { dependencies in dependencies.suggestionWidgetControllerDependency = suggestionDependency dependencies.suggestionWidgetUserDefaultsObservers = .init() + dependencies.chatTabBuilderCollection = { + ChatTabFactory.chatTabBuilderCollection + } } + let store = StoreOf( + initialState: .init(), + reducer: GUI(), + prepareDependencies: setupDependency + ) self.store = store viewStore = ViewStore(store) widgetDataSource = .init() 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/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/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/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 22b0ada4..f64072c5 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 @@ -27,13 +28,10 @@ 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() - + ChatTabBar(store: store) .frame(height: 26) @@ -42,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) @@ -68,10 +49,113 @@ struct ChatWindowView: View { } } +struct ChatTitleBar: View { + let store: StoreOf + @State var isHovering = false + @Environment(\.controlActiveState) var controlActiveState + + var body: some View { + HStack(spacing: 4) { + Button(action: { + store.send(.hideButtonClicked) + }) { + Circle() + .fill( + controlActiveState == .key + ? Color(nsColor: .systemOrange) + : Color(nsColor: .disabledControlTextColor) + ) + .frame(width: 10, height: 10) + .overlay { + Circle().strokeBorder(.black.opacity(0.3), lineWidth: 1) + } + .overlay { + if isHovering { + Image(systemName: "minus") + .resizable() + .foregroundStyle(.black.opacity(0.7)) + .font(Font.title.weight(.heavy)) + .frame(width: 5, height: 1) + } + } + } + + WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in + Button(action: { + store.send(.toggleChatPanelDetachedButtonClicked) + }) { + Circle() + .fill( + controlActiveState == .key && viewStore.state + ? Color(nsColor: .systemCyan) + : Color(nsColor: .disabledControlTextColor) + ) + .frame(width: 10, height: 10) + .overlay { + Circle().strokeBorder(.black.opacity(0.3), lineWidth: 1) + } + .disabled(!viewStore.state) + .overlay { + if isHovering { + Image(systemName: "pin") + .resizable() + .foregroundStyle(.black.opacity(0.7)) + .font(Font.title.weight(.heavy)) + .frame(width: 4, height: 6) + .transformEffect(.init(translationX: 0, y: 0.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) + .padding(.top, 1) + .frame(maxWidth: .infinity) + .frame(height: 16) + .onHover(perform: { hovering in + isHovering = hovering + }) + } +} + +private extension View { + func hideScrollIndicator() -> some View { + if #available(macOS 13.0, *) { + return scrollIndicators(.hidden) + } else { + return self + } + } +} + struct ChatTabBar: View { let store: StoreOf struct TabBarState: Equatable { + var tabs: [BaseChatTab] var tabInfo: [ChatTabInfo] var selectedTabId: String } @@ -80,33 +164,100 @@ struct ChatTabBar: View { WithViewStore( store, observe: { TabBarState( + tabs: $0.chatTapGroup.tabs, tabInfo: $0.chatTapGroup.tabInfo, selectedTabId: $0.chatTapGroup.selectedTabId ?? $0.chatTapGroup.tabInfo.first?.id ?? "" ) } ) { 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 - ) + 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) } } } Divider() - Button(action: { - store.send(.createNewTapButtonClicked(type: "")) - }) { - Image(systemName: "plus") - .foregroundColor(.secondary) - .padding(8) - }.buttonStyle(.plain) + 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 + var createButton: some View { + Menu { + WithViewStore(store, observe: { $0.chatTapGroup.tabCollection }) { viewStore in + ForEach(0.. 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) + } +} + +class FakeChatTab: ChatTab { + static var name: String { "Fake" } + static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { [Builder()] } + + struct Builder: ChatTabBuilder { + var title: String = "Title" + var buildable: Bool { true } - override init(id: String, title: String) { - super.init(id: id, title: 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( @@ -219,6 +400,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" ), @@ -227,6 +413,7 @@ struct ChatWindowView_Previews: PreviewProvider { reducer: ChatPanelFeature() ) ) + .xcodeStyleFrame() .padding() } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 8570067c..1267b902 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,16 +66,20 @@ 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) + case switchToNextTab + case switchToPreviousTab } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @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 +90,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 @@ -127,6 +149,10 @@ public struct ChatPanelFeature: ReducerProtocol { } return .none + case .createNewTapButtonHovered: + state.chatTapGroup.tabCollection = chatTabBuilderCollection() + return .none + case .createNewTapButtonClicked: return .none // handled elsewhere @@ -144,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 } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 830feabc..9b117e23 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() } } @@ -497,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 @@ -521,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 { 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/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/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/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/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() }) { 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) } diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift new file mode 100644 index 00000000..1c546a94 --- /dev/null +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift @@ -0,0 +1,443 @@ +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 + } + + func hello() { + print("hello") + print("hello") + } + """ + let range = CursorRange(startPair: (0, 0), endPair: (0, 0)) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 1000).findFocusedCode( + containingRange: range, + activeDocumentContext: context(code: code) + ) + XCTAssertEqual(context, .init( + scope: .top, + 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: [] + )) + } + + 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(maxFocusedCodeLineCount: 1000).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: [] + )) + } + + 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: [] + )) + } +} 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 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/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..a50a91ab --- /dev/null +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift @@ -0,0 +1,45 @@ +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 { + 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 + } + } + } + } + + Section("Result") { + Text(result) + .fontDesign(.monospaced) + .textSelection(.enabled) + } + } + .formStyle(.grouped) + .frame(width: 600, height: 800) + } +} + +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 new file mode 100644 index 00000000..9d435df4 --- /dev/null +++ b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift index 18fe2fb5..55c4465c 100644 --- a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift @@ -1,55 +1,91 @@ import AppKit +import Foundation import LangChain import OpenAIService import PlaygroundSupport import SwiftUI +import TokenEncoder struct QAForm: View { - @State var intermediateAnswers = [String]() + @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" 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("All Relevant Information (\(tokenCount) words)")) { + Text(answer) + } + Section(header: Text("Relevant Information")) { + ForEach(0.. - - - - diff --git a/Pro b/Pro new file mode 160000 index 00000000..6e61d887 --- /dev/null +++ b/Pro @@ -0,0 +1 @@ +Subproject commit 6e61d88739c7c3fadc450c04d87d46d72c2924f8 diff --git a/README.md b/README.md index 561f4eb6..d2475525 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). @@ -21,20 +23,23 @@ 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) -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. @@ -92,6 +97,27 @@ 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 | +| ------------------- | ----------- | +| 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. @@ -148,6 +174,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 +213,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 +255,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 @@ -264,25 +302,24 @@ 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 +## Plus Features -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. +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). -A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that should cause no conflict is +These features are included in another repo, and are not open sourced. -| Command | Key Binding | -| ------------------- | ----------- | -| Get Suggestions | `⌥?` | -| Accept Suggestions | `⌥}` | -| Reject Suggestion | `⌥{` | -| Next Suggestion | `⌥>` | -| Previous Suggestion | `⌥<` | -| Open Chat | `⌥"` | -| Explain Selection | `⌥\|` | +The currently available Plus features include: -Essentially using `⌥⇧` as the "access" key combination for all bindings. +- Browser tap in chat panel. +- Unlimited custom commands. -Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. +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 diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 6b9a6a3a..88bbb7a9 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", @@ -87,16 +80,44 @@ }, { "target" : { - "containerPath" : "container:Core", + "containerPath" : "container:Tool", + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionModelTests", + "name" : "SuggestionModelTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", "identifier" : "SharedUIComponentsTests", "name" : "SharedUIComponentsTests" } }, + { + "target" : { + "containerPath" : "container:Pro", + "identifier" : "LicenseManagementTests", + "name" : "LicenseManagementTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" + } + }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "TokenEncoderTests", - "name" : "TokenEncoderTests" + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" } } ], diff --git a/Tool/Package.swift b/Tool/Package.swift index 2de22106..2c819037 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -13,15 +13,46 @@ let package = Package( .library(name: "Preferences", targets: ["Preferences", "Configs"]), .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: "ASTParser", targets: ["ASTParser"]), + .library(name: "Toast", targets: ["Toast"]), + .library(name: "Keychain", targets: ["Keychain"]), + .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), + .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"), .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" + ), + + // 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 @@ -36,6 +67,30 @@ let package = Package( .target(name: "ObjectiveCExceptionHandling"), + .target( + name: "Keychain", + dependencies: ["Configs"] + ), + + .target( + name: "Toast", + dependencies: [.product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + )] + ), + + .target( + name: "Environment", + dependencies: [ + "ActiveApplicationMonitor", + "AXExtension", + "Preferences", + ] + ), + + .target(name: "ActiveApplicationMonitor"), + .target(name: "USearchIndex", dependencies: [ "ObjectiveCExceptionHandling", .product(name: "USearch", package: "usearch"), @@ -55,6 +110,59 @@ let package = Package( dependencies: ["TokenEncoder"] ), + .target( + name: "SuggestionModel", + dependencies: [ + "LanguageClient", + .product(name: "Parsing", package: "swift-parsing"), + ] + ), + + .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"), + ] + ), + + .target( + name: "SharedUIComponents", + dependencies: [ + "Highlightr", + "Splash", + "Preferences", + ] + ), + .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"), + ]), + + .testTarget(name: "ASTParserTests", dependencies: ["ASTParser"]), + // MARK: - Services .target( @@ -87,6 +195,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/Tool/Sources/ASTParser/ASTParser.swift b/Tool/Sources/ASTParser/ASTParser.swift new file mode 100644 index 00000000..257eb704 --- /dev/null +++ b/Tool/Sources/ASTParser/ASTParser.swift @@ -0,0 +1,122 @@ +import SuggestionModel +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 typealias ASTNode = Node + +public typealias ASTPoint = Point + +public struct ASTTree { + public let tree: Tree? + + 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 + } +} + +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) + let endPoint = Point(row: end.line, column: end.character * bytePerCharacter) + guard endPoint > startPoint else { + 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) + } +} + diff --git a/Tool/Sources/ASTParser/DumpSyntaxTree.swift b/Tool/Sources/ASTParser/DumpSyntaxTree.swift new file mode 100644 index 00000000..2cebc92d --- /dev/null +++ b/Tool/Sources/ASTParser/DumpSyntaxTree.swift @@ -0,0 +1,76 @@ +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 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") [\(lowerBoundL), \(lowerBoundC)] - [\(upperBoundL), \(upperBoundC)]" + result += line + "\n" + } + + guard let node = root.descendant(in: root.byteRange) else { return result } + + appendNode(0, node) + + let cursor = node.treeCursor + let level = 0 + + 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) + } +} + diff --git a/Tool/Sources/ASTParser/TreeCursor.swift b/Tool/Sources/ASTParser/TreeCursor.swift new file mode 100644 index 00000000..7cade565 --- /dev/null +++ b/Tool/Sources/ASTParser/TreeCursor.swift @@ -0,0 +1,71 @@ +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 goToNextSibling() -> Bool { + gotoNextSibling() + } + + 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/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/Core/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift similarity index 50% rename from Core/Sources/ChatTab/ChatTab.swift rename to Tool/Sources/ChatTab/ChatTab.swift index b41c4319..f44617ce 100644 --- a/Core/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,19 +63,26 @@ open class BaseChatTab: Equatable { info = InfoObservable(id: id, title: title) } + /// The view for this chat tab. @ViewBuilder public var body: some View { - let id = "BaseChatTab\(info.id)" - if let tab = self as? ChatTabType { + let id = "ChatTabBody\(info.id)" + 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 { - EmptyView() + let id = "ChatTabMenu\(info.id)" + if let tab = self as? (any ChatTabType) { + ContentView(info: info, buildView: tab.buildMenu).id(id) + } else { + EmptyView().id(id) + } } public static func == (lhs: BaseChatTab, rhs: BaseChatTab) -> Bool { @@ -80,12 +90,67 @@ open class BaseChatTab: Equatable { } } +/// A factory of a chat tab. +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 + /// 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 + var buildable: Bool { true } + func build() -> any ChatTab { + EmptyChatTab() + } + } + + public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { + [Builder(title: "Empty")] + } + public func buildView() -> any View { VStack { Text("Empty-\(id)") @@ -93,6 +158,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") } 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/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 + } +} diff --git a/Tool/Sources/LangChain/Agent.swift b/Tool/Sources/LangChain/Agent.swift index b5da456a..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 { @@ -104,7 +116,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 +140,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/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/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/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 023681a0..9cdcbd4b 100644 --- a/Tool/Sources/LangChain/Chains/RetrievalQA.swift +++ b/Tool/Sources/LangChain/Chains/RetrievalQA.swift @@ -1,23 +1,21 @@ import Foundation +import OpenAIService -public final class RetrievalQAChain: Chain { +public final class QAInformationRetrievalChain: Chain { let vectorStore: VectorStore let embedding: Embeddings - let chatModelFactory: () -> ChatModel public struct Output { - public var answer: String + public var information: String public var sourceDocuments: [Document] } public init( vectorStore: VectorStore, - embedding: Embeddings, - chatModelFactory: @escaping () -> ChatModel + embedding: Embeddings ) { self.vectorStore = vectorStore self.embedding = embedding - self.chatModelFactory = chatModelFactory } public func callLogic( @@ -28,114 +26,33 @@ public final class RetrievalQAChain: Chain { let documents = try await vectorStore.searchWithDistance( embeddings: embeddedQuestion, count: 5 - ) - let refinementChain = RefineDocumentChain(chatModelFactory: chatModelFactory) - let answer = try await refinementChain.run( + ).filter { item in + item.distance < 0.31 + } + + callbackManagers.send(CallbackEvents.RetrievalQADidExtractRelevantContent(info: documents)) + + 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(information: relevantInformation, sourceDocuments: documents.map(\.document)) } public func parseOutput(_ output: Output) -> String { - return output.answer + return output.information } } public extension CallbackEvents { - struct RetrievalQADidGenerateIntermediateAnswer: CallbackEvent { - public let info: String - } -} - -public final class RefineDocumentChain: Chain { - public struct Input { - var question: String - var documents: [(document: Document, distance: Float)] - } - - struct InitialInput { - var question: String - var document: String - var distance: Float - } - - struct RefinementInput { - var question: String - var previousAnswer: String - var document: String - var distance: Float - } - - let initialChatModel: ChatModelChain - let refinementChatModel: ChatModelChain - - public init(chatModelFactory: () -> ChatModel) { - initialChatModel = .init( - chatModel: chatModelFactory(), - 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: chatModelFactory(), - 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: .user, content: input.question), - ] } - ) - } - - public func callLogic( - _ input: Input, - callbackManagers: [CallbackManager] - ) async throws -> String { - guard let firstDocument = input.documents.first else { - return "" - } - var output = try await initialChatModel.call( - .init( - question: input.question, - document: firstDocument.document.pageContent, - distance: firstDocument.distance - ), - callbackManagers: callbackManagers - ) - callbackManagers.send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: output)) - for document in input.documents.dropFirst(1) { - output = try await refinementChatModel.call( - .init( - question: input.question, - previousAnswer: output, - document: document.document.pageContent, - distance: document.distance - ), - callbackManagers: callbackManagers - ) - callbackManagers - .send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: output)) - } - return output + struct RetrievalQADidExtractRelevantContent: CallbackEvent { + public let info: [(document: Document, distance: Float)] } - public func parseOutput(_ output: String) -> String { - return output + var retrievalQADidExtractRelevantContent: RetrievalQADidExtractRelevantContent.Type { + RetrievalQADidExtractRelevantContent.self } } diff --git a/Tool/Sources/LangChain/ChatModel/ChatModel.swift b/Tool/Sources/LangChain/ChatModel/ChatModel.swift index b58bafc5..75ba0233 100644 --- a/Tool/Sources/LangChain/ChatModel/ChatModel.swift +++ b/Tool/Sources/LangChain/ChatModel/ChatModel.swift @@ -1,31 +1,22 @@ 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 } + + var llmDidProduceNewToken: LLMDidProduceNewToken.Type { + LLMDidProduceNewToken.self + } } diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index 51bb023d..bb9c7752 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 } @@ -17,25 +23,14 @@ public struct OpenAIChat: ChatModel { prompt: [ChatMessage], stops: [String], callbackManagers: [CallbackManager] - ) async throws -> String { - let memory = AutoManagedChatGPTMemory( - systemPrompt: "", + ) async throws -> ChatMessage { + 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 { - 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 { @@ -43,12 +38,12 @@ 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 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/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 diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index ffdd05a4..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( @@ -205,7 +207,7 @@ extension ChatGPTService { model: configuration.model, remainingTokens: remainingTokens ), - function_call: nil, + function_call: functionProvider.functionCallStrategy, functions: functionProvider.functions.map { ChatGPTFunctionSchema( name: $0.name, @@ -227,6 +229,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 +245,7 @@ extension ChatGPTService { } await memory.streamMessage( - id: trunk.id, + id: trunk.id ?? proposedId, role: delta.role, content: delta.content, functionCall: functionCall @@ -279,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( @@ -302,7 +307,7 @@ extension ChatGPTService { model: configuration.model, remainingTokens: remainingTokens ), - function_call: nil, + function_call: functionProvider.functionCallStrategy, functions: functionProvider.functions.map { ChatGPTFunctionSchema( name: $0.name, @@ -318,11 +323,12 @@ extension ChatGPTService { url, requestBody ) + let response = try await api() 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, @@ -424,5 +430,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 22c2c379..e092411f 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 @@ -24,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] @@ -89,7 +106,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() + } } } diff --git a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift index f9c06acc..3be74435 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 @@ -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] 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..13c4ab21 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -18,8 +18,8 @@ public extension EmbeddingConfiguration { return "\(baseURL)/v1/embeddings" case .azureOpenAI: let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) - let deployment = UserDefaults.shared.value(for: \.azureChatGPTDeployment) - let version = "2023-05-15" + 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)" } 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/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() {} } diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 0f22e750..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 var messages: [ChatMessage] { generateSendingHistory() } - public var remainingTokens: Int? { generateRemainingTokens() } + public private(set) var messages: [ChatMessage] = [] + public private(set) var remainingTokens: Int? public var systemPrompt: ChatMessage public var history: [ChatMessage] = [] { @@ -45,6 +46,11 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { } } + 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( maxNumberOfMessages: Int = UserDefaults.shared.value(for: \.chatGPTMaxMessageCount), @@ -69,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() { @@ -88,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() } @@ -97,12 +115,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 new file mode 100644 index 00000000..a07c51e2 --- /dev/null +++ b/Tool/Sources/OpenAIService/Memory/EmptyChatGPTMemory.swift @@ -0,0 +1,15 @@ +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) + } + + public func refresh() async {} +} + diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 367a5fbe..e16070c3 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 { @@ -60,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 @@ -160,7 +177,7 @@ public extension UserDefaultPreferenceKeys { var runNodeWith: PreferenceKey { .init(defaultValue: .env, key: "RunNodeWith") } - + var gitHubCopilotIgnoreTrailingNewLines: PreferenceKey { .init(defaultValue: false, key: "GitHubCopilotIgnoreTrailingNewLines") } @@ -236,7 +253,7 @@ public extension UserDefaultPreferenceKeys { var chatFeatureProvider: PreferenceKey { .init(defaultValue: .openAI, key: "ChatFeatureProvider") } - + var embeddingFeatureProvider: PreferenceKey { .init(defaultValue: .openAI, key: "EmbeddingFeatureProvider") } @@ -257,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") } @@ -274,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" 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/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/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionModel/EditorInformation.swift new file mode 100644 index 00000000..60ba65d2 --- /dev/null +++ b/Tool/Sources/SuggestionModel/EditorInformation.swift @@ -0,0 +1,137 @@ +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 + /// 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] + /// The cursor position of the source editor. + public var cursorPosition: CursorPosition + /// Line annotations of the source editor. + public var lineAnnotations: [LineAnnotation] + + 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.map(EditorInformation.parseLineAnnotation) + } + } + + 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: editorContent?.lines ?? [], 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, + 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( + 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) + } + } +} + diff --git a/Core/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift similarity index 50% rename from Core/Sources/SuggestionModel/ExportedFromLSP.swift rename to Tool/Sources/SuggestionModel/ExportedFromLSP.swift index 2239c839..ac49e250 100644 --- a/Core/Sources/SuggestionModel/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift @@ -5,10 +5,14 @@ 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 { - static let zero = CursorRange(start: .zero, end: .zero) +public struct CursorRange: Codable, Hashable, Sendable, Equatable, CustomStringConvertible { + public static let zero = CursorRange(start: .zero, end: .zero) public var start: CursorPosition public var end: CursorPosition @@ -19,12 +23,20 @@ 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: CursorPosition) -> Bool { + return position >= start && position <= end } - public func contains(_ position: Position) -> Bool { - 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 } public func intersects(_ other: LSPRange) -> Bool { @@ -34,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 { @@ -42,3 +62,4 @@ public extension CursorRange { return .init(start: position, end: position) } } + 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/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/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 81% rename from Core/Sources/XcodeInspector/SourceEditor.swift rename to Tool/Sources/XcodeInspector/SourceEditor.swift index 3557a6ad..7937582e 100644 --- a/Core/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 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/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]) + } +} 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..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, @@ -360,6 +360,8 @@ extension ChatGPTStreamTests { } struct FunctionProvider: ChatGPTFunctionProvider { + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil } + var functions: [any ChatGPTFunction] { [EmptyFunction()] } } } 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 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") + } +} 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 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 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 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 + + + +