diff --git a/.gitignore b/.gitignore index 43fb9ab3..c915adee 100644 --- a/.gitignore +++ b/.gitignore @@ -123,4 +123,12 @@ iOSInjectionProject/ # End of https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager + Secrets.xcconfig +Python/Python.xcframework +Python/python-stdlib +Python/site-packages/* +!Python/site-packages/requirements.txt +!Python/site-packages/install.sh + +Python/VERSIONS diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 40f6c2d0..b83f3f4d 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ C87B03AC293B2CF300C77EAE /* XcodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; }; C87B03AD293B2CF300C77EAE /* XcodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; }; + C8A3AE592A2885A70046E809 /* InitializePython.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A3AE582A2885A70046E809 /* InitializePython.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 */; }; /* End PBXBuildFile section */ @@ -143,6 +144,7 @@ C8189B202938973000C9DCDA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; C8189B222938973000C9DCDA /* Copilot_for_Xcode.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Copilot_for_Xcode.entitlements; sourceTree = ""; }; C8189B282938979000C9DCDA /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; + C81D181E2A1B509B006C1B70 /* Tool */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Tool; sourceTree = ""; }; C81E867D296FE4420026E908 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; C8216B70298036EC00AD38C7 /* Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = Helper; sourceTree = BUILT_PRODUCTS_DIR; }; C8216B72298036EC00AD38C7 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; @@ -164,6 +166,10 @@ C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextSuggestionCommand.swift; sourceTree = ""; }; C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; }; C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; + C8A3AE512A2883430046E809 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Python.xcframework; sourceTree = ""; }; + C8A3AE582A2885A70046E809 /* InitializePython.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitializePython.swift; sourceTree = ""; }; + C8A3AE5A2A288AF90046E809 /* site-packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "site-packages"; sourceTree = ""; }; + C8A3B1762A288FA90046E809 /* python-stdlib */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "python-stdlib"; sourceTree = ""; }; C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWithSelection.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -248,11 +254,13 @@ C81458AD293A009600135263 /* Config.xcconfig */, C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, + C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, C8189B182938972F00C9DCDA /* Copilot for Xcode */, C81458922939EFDC00135263 /* EditorExtension */, C8216B71298036EC00AD38C7 /* Helper */, C861E60F2994F6070056CB02 /* ExtensionService */, + C81BBF5A2A2CA0B8000B4F61 /* Python */, C814588D2939EFDC00135263 /* Frameworks */, C8189B172938972F00C9DCDA /* Products */, ); @@ -289,6 +297,16 @@ path = "Preview Content"; sourceTree = ""; }; + C81BBF5A2A2CA0B8000B4F61 /* Python */ = { + isa = PBXGroup; + children = ( + C8A3AE512A2883430046E809 /* Python.xcframework */, + C8A3B1762A288FA90046E809 /* python-stdlib */, + C8A3AE5A2A288AF90046E809 /* site-packages */, + ); + path = Python; + sourceTree = ""; + }; C8216B71298036EC00AD38C7 /* Helper */ = { isa = PBXGroup; children = ( @@ -304,6 +322,7 @@ C81291D92994FE7900196E12 /* Info.plist */, C861E61F2994F6390056CB02 /* ServiceDelegate.swift */, C861E6102994F6070056CB02 /* AppDelegate.swift */, + C8A3AE582A2885A70046E809 /* InitializePython.swift */, C81291D52994FE6900196E12 /* Main.storyboard */, C861E6142994F6080056CB02 /* Assets.xcassets */, C861E6192994F6080056CB02 /* ExtensionService.entitlements */, @@ -388,6 +407,8 @@ C861E60A2994F6070056CB02 /* Sources */, C861E60B2994F6070056CB02 /* Frameworks */, C861E60C2994F6070056CB02 /* Resources */, + C8A3AE572A28852D0046E809 /* Sign Python STD */, + C8A3B1782A2894E10046E809 /* Sign Python Site Packages */, ); buildRules = ( ); @@ -477,6 +498,47 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + C8A3AE572A28852D0046E809 /* Sign Python STD */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Sign Python STD"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#set -e\n#echo \"Signing as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)\"\n#find \"$CODESIGNING_FOLDER_PATH/Contents/Resources/python-stdlib/lib-dynload\" -name \"*.so\" -exec /usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der {} \\;\n"; + }; + C8A3B1782A2894E10046E809 /* Sign Python Site Packages */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Sign Python Site Packages"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#set -e\n#echo \"Signing as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)\"\n#find \"$CODESIGNING_FOLDER_PATH/Contents/Resources/site-packages\" -type f \\( -name \"*.so\" -o -name \"*.dylib\" \\) -exec /usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der {} \\;\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ C81458882939EFDC00135263 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -520,6 +582,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C8A3AE592A2885A70046E809 /* InitializePython.swift in Sources */, C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */, C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */, ); diff --git a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 084a8217..8afaa8b0 100644 --- a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -90,6 +90,15 @@ "version" : "0.3.1" } }, + { + "identity" : "pythonkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pvieito/PythonKit.git", + "state" : { + "branch" : "master", + "revision" : "060e1c8b0d14e4d241b3623fdbe83d0e3c81a993" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -126,6 +135,15 @@ "version" : "0.1.0" } }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", + "version" : "0.14.1" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -143,6 +161,24 @@ "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9", "version" : "2.1.0" } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "27c941bbd22a4bbc53005a15a0440443fd892f70", + "version" : "0.12.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "4af50b38daf0037cfbab15514a241224c3f62f98", + "version" : "0.8.5" + } } ], "version" : 2 diff --git a/Core/Package.swift b/Core/Package.swift index ec943396..fcfe44af 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -15,7 +15,6 @@ let package = Package( "FileChangeChecker", "LaunchAgentManager", "UpdateChecker", - "Logger", "UserDefaultsObserver", "XcodeInspector", ] @@ -26,8 +25,6 @@ let package = Package( "SuggestionModel", "Client", "XPCShared", - "Preferences", - "Logger", ] ), .library( @@ -38,23 +35,21 @@ let package = Package( "GitHubCopilotService", "Client", "XPCShared", - "Preferences", "LaunchAgentManager", - "Logger", "UpdateChecker", - "OpenAIService", ] ), ], dependencies: [ + .package(path: "../Tool"), .package(url: "https://github.com/ChimeHQ/LanguageClient", from: "0.3.1"), .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/alfianlosari/GPTEncoder", from: "1.0.4"), .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.2"), + .package(url: "https://github.com/pvieito/PythonKit.git", branch: "master"), ], targets: [ // MARK: - Main @@ -63,10 +58,10 @@ let package = Package( name: "Client", dependencies: [ "SuggestionModel", - "Preferences", "XPCShared", - "Logger", "GitHubCopilotService", + .product(name: "Logger", package: "Tool"), + .product(name: "Preferences", package: "Tool"), ] ), .target( @@ -75,8 +70,6 @@ let package = Package( "SuggestionModel", "SuggestionService", "GitHubCopilotService", - "OpenAIService", - "Preferences", "XPCShared", "CGEventObserver", "DisplayLink", @@ -85,12 +78,15 @@ let package = Package( "Environment", "SuggestionWidget", "AXExtension", - "Logger", "ChatService", + .product(name: "Logger", package: "Tool"), "PromptToCodeService", "ServiceUpdateMigration", "UserDefaultsObserver", + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "PythonKit", package: "PythonKit"), ] ), .testTarget( @@ -100,9 +96,9 @@ let package = Package( "Client", "GitHubCopilotService", "SuggestionInjector", - "Preferences", "XPCShared", "Environment", + .product(name: "Preferences", package: "Tool"), ] ), .target( @@ -113,19 +109,19 @@ let package = Package( "SuggestionService", ] ), - .target(name: "Preferences", dependencies: ["Configs"]), // MARK: - Host App .target( name: "HostApp", dependencies: [ - "Preferences", "Client", "GitHubCopilotService", "CodeiumService", "SuggestionModel", "LaunchAgentManager", + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), ] ), @@ -164,8 +160,12 @@ let package = Package( .target( name: "PromptToCodeService", - dependencies: ["OpenAIService", "Environment", "GitHubCopilotService", - "SuggestionModel"] + dependencies: [ + "Environment", + "GitHubCopilotService", + "SuggestionModel", + .product(name: "OpenAIService", package: "Tool"), + ] ), .testTarget(name: "PromptToCodeServiceTests", dependencies: ["PromptToCodeService"]), @@ -174,26 +174,36 @@ let package = Package( .target( name: "ChatService", dependencies: [ - "ChatPlugins", + "ChatPlugin", "ChatContextCollector", - "OpenAIService", "Environment", "XcodeInspector", - "Preferences", + + // plugins + "MathChatPlugin", + "SearchChatPlugin", + + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), ] ), .target( - name: "ChatPlugins", - dependencies: ["OpenAIService", "Environment", "Terminal"] + name: "ChatPlugin", + dependencies: [ + "Environment", + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + .product(name: "PythonKit", package: "PythonKit"), + ] ), .target( name: "ChatContextCollector", dependencies: [ - "OpenAIService", "Environment", - "Preferences", "SuggestionModel", "XcodeInspector", + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), ] ), @@ -208,8 +218,8 @@ let package = Package( "Highlightr", "Splash", "UserDefaultsObserver", - "Logger", "XcodeInspector", + .product(name: "Logger", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), ] @@ -218,26 +228,26 @@ let package = Package( // MARK: - Helpers - .target(name: "Configs"), .target(name: "CGEventObserver"), - .target(name: "Logger"), .target(name: "FileChangeChecker"), .target(name: "LaunchAgentManager"), .target(name: "DisplayLink"), .target(name: "ActiveApplicationMonitor"), .target(name: "AXNotificationStream"), - .target(name: "Terminal"), .target( name: "UpdateChecker", dependencies: [ - "Logger", - "Sparkle" + "Sparkle", + .product(name: "Logger", package: "Tool"), ] ), .target(name: "AXExtension"), .target( name: "ServiceUpdateMigration", - dependencies: ["Preferences", "GitHubCopilotService"] + dependencies: [ + "GitHubCopilotService", + .product(name: "Preferences", package: "Tool"), + ] ), .target(name: "UserDefaultsObserver"), .target( @@ -245,8 +255,8 @@ let package = Package( dependencies: [ "AXExtension", "Environment", - "Logger", "AXNotificationStream", + .product(name: "Logger", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), @@ -259,8 +269,9 @@ let package = Package( "LanguageClient", "SuggestionModel", "XPCShared", - "Preferences", - "Terminal", + .product(name: "Logger", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "Terminal", package: "Tool"), ] ), .testTarget( @@ -268,22 +279,6 @@ let package = Package( dependencies: ["GitHubCopilotService"] ), - // MARK: - OpenAI - - .target( - name: "OpenAIService", - dependencies: [ - "Logger", - "Preferences", - "GPTEncoder", - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - ] - ), - .testTarget( - name: "OpenAIServiceTests", - dependencies: ["OpenAIService"] - ), - // MARK: - Codeium .target( @@ -291,12 +286,35 @@ let package = Package( dependencies: [ "LanguageClient", "SuggestionModel", - "Preferences", "KeychainAccess", - "Terminal", - "Configs", + .product(name: "Preferences", package: "Tool"), + .product(name: "Terminal", package: "Tool"), ] ), + + // MARK: - Chat Plugins + + .target( + name: "MathChatPlugin", + dependencies: [ + "ChatPlugin", + .product(name: "OpenAIService", package: "Tool"), + .product(name: "LangChain", package: "Tool"), + .product(name: "PythonKit", package: "PythonKit"), + ], + path: "Sources/ChatPlugins/MathChatPlugin" + ), + + .target( + name: "SearchChatPlugin", + dependencies: [ + "ChatPlugin", + .product(name: "OpenAIService", package: "Tool"), + .product(name: "LangChain", package: "Tool"), + .product(name: "PythonKit", package: "PythonKit"), + ], + path: "Sources/ChatPlugins/SearchChatPlugin" + ), ] ) diff --git a/Core/Sources/ChatPlugins/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift similarity index 100% rename from Core/Sources/ChatPlugins/AITerminalChatPlugin.swift rename to Core/Sources/ChatPlugin/AITerminalChatPlugin.swift diff --git a/Core/Sources/ChatPlugin/AskChatGPT.swift b/Core/Sources/ChatPlugin/AskChatGPT.swift new file mode 100644 index 00000000..f157c21e --- /dev/null +++ b/Core/Sources/ChatPlugin/AskChatGPT.swift @@ -0,0 +1,12 @@ +import Foundation +import OpenAIService + +/// Quickly ask a question to ChatGPT. +public func askChatGPT( + systemPrompt: String, + question: String, + temperature: Double? = nil +) async throws -> String? { + let service = ChatGPTService(systemPrompt: systemPrompt, temperature: temperature) + return try await service.sendAndWait(content: question) +} diff --git a/Core/Sources/ChatPlugins/CallAIFunction.swift b/Core/Sources/ChatPlugin/CallAIFunction.swift similarity index 100% rename from Core/Sources/ChatPlugins/CallAIFunction.swift rename to Core/Sources/ChatPlugin/CallAIFunction.swift diff --git a/Core/Sources/ChatPlugins/ChatPlugin.swift b/Core/Sources/ChatPlugin/ChatPlugin.swift similarity index 100% rename from Core/Sources/ChatPlugins/ChatPlugin.swift rename to Core/Sources/ChatPlugin/ChatPlugin.swift diff --git a/Core/Sources/ChatPlugins/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift similarity index 100% rename from Core/Sources/ChatPlugins/TerminalChatPlugin.swift rename to Core/Sources/ChatPlugin/TerminalChatPlugin.swift diff --git a/Core/Sources/ChatPlugin/Translate.swift b/Core/Sources/ChatPlugin/Translate.swift new file mode 100644 index 00000000..f3441a9f --- /dev/null +++ b/Core/Sources/ChatPlugin/Translate.swift @@ -0,0 +1,34 @@ +import Foundation +import Preferences + +@MainActor +var translationCache = [String: String]() + +public func translate(text: String, cache: Bool = true) async -> String { + let language = UserDefaults.shared.value(for: \.chatGPTLanguage) + if language.isEmpty { return text } + + let key = "\(language)-\(text)" + if cache, let cached = await translationCache[key] { + return cached + } + + if let translated = try? await askChatGPT( + systemPrompt: """ + You are a translator. Your job is to translate the message into \(language). The reply should only contain the translated content. + User: ###${{some text}}### + Assistant: ${{translated text}} + """, + question: "###\(text)###" + ) { + if cache { + let storeTask = Task { @MainActor in + translationCache[key] = translated + } + _ = await storeTask.result + } + return translated + } + return text +} + diff --git a/Core/Sources/ChatPlugins/AskChatGPT.swift b/Core/Sources/ChatPlugins/AskChatGPT.swift deleted file mode 100644 index 7184eb50..00000000 --- a/Core/Sources/ChatPlugins/AskChatGPT.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation -import OpenAIService - -/// Quickly ask a question to ChatGPT. -func askChatGPT(systemPrompt: String, question: String) async throws -> String? { - let service = ChatGPTService(systemPrompt: systemPrompt) - return try await service.sendAndWait(content: question) -} diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift new file mode 100644 index 00000000..296f564a --- /dev/null +++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift @@ -0,0 +1,68 @@ +import ChatPlugin +import Environment +import Foundation +import OpenAIService + +/// Use Python to solve math problems. +public actor MathChatPlugin: ChatPlugin { + public static var command: String { "math" } + public nonisolated var name: String { "Math" } + + let chatGPTService: any ChatGPTServiceType + var isCancelled = false + weak var delegate: ChatPluginDelegate? + + public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + self.chatGPTService = chatGPTService + self.delegate = delegate + } + + public func send(content: String, originalMessage: String) async { + delegate?.pluginDidStart(self) + 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.mutateHistory { history in + history.append(.init(role: .user, content: originalMessage, summary: content)) + } + + do { + let result = try await solveMathProblem(content) + let formattedResult = "\(await translatedAnswer) \(result)" + if !isCancelled { + await chatGPTService.mutateHistory { history in + if history.last?.id == id { + history.removeLast() + } + reply.content = formattedResult + history.append(reply) + } + } + } catch { + if !isCancelled { + await chatGPTService.mutateHistory { history in + if history.last?.id == id { + history.removeLast() + } + reply.content = error.localizedDescription + history.append(reply) + } + } + } + + delegate?.pluginDidEndResponding(self) + delegate?.pluginDidEnd(self) + } + + public func cancel() async { + isCancelled = true + } + + public func stopResponding() async { + isCancelled = true + } +} + diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/SolveMathProblem.swift b/Core/Sources/ChatPlugins/MathChatPlugin/SolveMathProblem.swift new file mode 100644 index 00000000..b3a3c994 --- /dev/null +++ b/Core/Sources/ChatPlugins/MathChatPlugin/SolveMathProblem.swift @@ -0,0 +1,100 @@ +import ChatPlugin +import Foundation +import LangChain +import Logger +import OpenAIService + +let systemPrompt = """ +Translate a math problem into a expression that can be executed using Python's numexpr library. +Use the output of running this code to answer the question. + +Question: ${{Question with math problem.}} +```text +${{single line mathematical expression that solves the problem}} +``` +...numexpr.evaluate(text)... +```output +${{Output of running the code}} +``` +Answer: ${{Answer}} + +Begin. + +Question: What is 37593 * 67? +```text +37593 * 67 +``` +...numexpr.evaluate("37593 * 67")... +```output +2518731 +``` +Answer: 2518731 + +Question: 37593^(1/5) +```text +37593**(1/5) +``` +...numexpr.evaluate("37593**(1/5)")... +```output +8.222831614237718 +``` +Answer: 8.222831614237718 +""" + +/// Extract the math problem with ChatGPT, and pass it to python to get the result. +/// +/// [llm_math in +/// LangChain](https://github.com/hwchase17/langchain/blob/master/langchain/chains/llm_math/base.py) +/// +/// The logic is basically the same as the LLMMathChain provided in LangChain. +func solveMathProblem(_ question: String) async throws -> String { + guard let reply = try await askChatGPT( + systemPrompt: systemPrompt, + question: "Question: \(question)", + temperature: 0 + ) else { return "No answer." } + + // parse inside text code block + let codeBlockRegex = try NSRegularExpression(pattern: "```text\n(.*?)\n```", options: []) + let codeBlockMatches = codeBlockRegex.matches( + in: reply, + options: [], + range: NSRange(reply.startIndex.. String? { + let mathExpression = NSExpression(format: expression) + let value = mathExpression.expressionValue(with: nil, context: nil) + Logger.service.debug(String(describing: value)) + return (value as? Int).flatMap(String.init) +} + diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift new file mode 100644 index 00000000..602b94ae --- /dev/null +++ b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift @@ -0,0 +1,101 @@ +import ChatPlugin +import Environment +import Foundation +import OpenAIService + +public actor SearchChatPlugin: ChatPlugin { + public static var command: String { "search" } + public nonisolated var name: String { "Search" } + + let chatGPTService: any ChatGPTServiceType + var isCancelled = false + weak var delegate: ChatPluginDelegate? + + public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + self.chatGPTService = chatGPTService + self.delegate = delegate + } + + public func send(content: String, originalMessage: String) async { + delegate?.pluginDidStart(self) + delegate?.pluginDidStartResponding(self) + + let id = "\(Self.command)-\(UUID().uuidString)" + var reply = ChatMessage(id: id, role: .assistant, content: "") + + await chatGPTService.mutateHistory { history in + history.append(.init(role: .user, content: originalMessage, summary: content)) + } + + do { + let (eventStream, cancelAgent) = try await search(content) + + var actions = [String]() + var finishedActions = Set() + var message = "" + + for try await event in eventStream { + guard !isCancelled else { + await cancelAgent() + break + } + switch event { + case let .startAction(content): + actions.append(content) + case let .endAction(content): + finishedActions.insert(content) + case let .answerToken(token): + message.append(token) + case let .finishAnswer(answer, links): + message = """ + \(answer) + + \(links.map { "- [\($0.title)](\($0.link))" }.joined(separator: "\n")) + """ + } + + await chatGPTService.mutateHistory { history in + if history.last?.id == id { + history.removeLast() + } + + let actionString = actions.map { + "> \(finishedActions.contains($0) ? "✅" : "🔍") \($0)" + }.joined(separator: "\n>\n") + + if message.isEmpty { + reply.content = actionString + } else { + reply.content = """ + \(actionString) + + \(message) + """ + } + history.append(reply) + } + } + + } catch { + await chatGPTService.mutateHistory { history in + if history.last?.id == id { + history.removeLast() + } + reply.content = error.localizedDescription + history.append(reply) + } + } + + delegate?.pluginDidEndResponding(self) + delegate?.pluginDidEnd(self) + } + + public func cancel() async { + isCancelled = true + } + + public func stopResponding() async { + isCancelled = true + } +} + diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift new file mode 100644 index 00000000..118b1473 --- /dev/null +++ b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift @@ -0,0 +1,121 @@ +import BingSearchService +import Foundation +import LangChain + +enum SearchEvent { + case startAction(String) + case endAction(String) + case answerToken(String) + case finishAnswer(String, [(title: String, link: String)]) +} + +func search(_ query: String) async throws + -> (stream: AsyncThrowingStream, cancel: () async -> Void) +{ + let bingSearch = BingSearchService( + subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey), + searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) + ) + + final class LinkStorage { + var links = [(title: String, link: String)]() + } + + let linkStorage = LinkStorage() + + let tools = [ + SimpleAgentTool( + name: "Search", + description: "useful for when you need to answer questions about current events. Don't search for the same thing twice", + run: { + linkStorage.links = [] + let result = try await bingSearch.search(query: $0, numberOfResult: 5) + let websites = result.webPages.value + + var string = "" + for (index, website) in websites.enumerated() { + string.append("[\(index)]:###\(website.snippet)###\n") + linkStorage.links.append((website.name, website.url)) + } + return string + } + ), + ] + + let chatModel = OpenAIChat(temperature: 0, stream: true) + + let agentExecutor = AgentExecutor( + agent: ChatAgent(chatModel: chatModel, tools: tools), + tools: tools, + maxIteration: UserDefaults.shared.value(for: \.chatSearchPluginMaxIterations), + earlyStopHandleType: .generate + ) + + class ResultCallbackManager: ChainCallbackManager { + var accumulation: String = "" + var isGeneratingFinalAnswer = false + var onFinalAnswerToken: (String) -> Void + var onAgentActionStart: (String) -> Void + var onAgentActionEnd: (String) -> Void + + init( + onFinalAnswerToken: @escaping (String) -> Void, + onAgentActionStart: @escaping (String) -> Void, + onAgentActionEnd: @escaping (String) -> Void + ) { + self.onFinalAnswerToken = onFinalAnswerToken + self.onAgentActionStart = onAgentActionStart + self.onAgentActionEnd = onAgentActionEnd + } + + func onChainStart(type: T.Type, input: T.Input) where T: LangChain.Chain {} + + func onAgentFinish(output: LangChain.AgentFinish) {} + + func onAgentActionStart(action: LangChain.AgentAction) { + onAgentActionStart("\(action.toolName): \(action.toolInput)") + } + + func onAgentActionEnd(action: LangChain.AgentAction) { + onAgentActionEnd("\(action.toolName): \(action.toolInput)") + } + + func onLLMNewToken(token: String) { + if isGeneratingFinalAnswer { + onFinalAnswerToken(token) + return + } + accumulation.append(token) + if accumulation.hasSuffix("Final Answer: ") { + isGeneratingFinalAnswer = true + accumulation = "" + } + } + } + + return (AsyncThrowingStream { continuation in + let callback = ResultCallbackManager( + onFinalAnswerToken: { + continuation.yield(.answerToken($0)) + }, + onAgentActionStart: { + continuation.yield(.startAction($0)) + }, + onAgentActionEnd: { + continuation.yield(.endAction($0)) + } + ) + Task { + do { + let finalAnswer = try await agentExecutor.run(query, callbackManagers: [callback]) + continuation.yield(.finishAnswer(finalAnswer, linkStorage.links)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + }, { + await agentExecutor.cancel() + }) +} + diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift new file mode 100644 index 00000000..949bd4da --- /dev/null +++ b/Core/Sources/ChatService/AllPlugins.swift @@ -0,0 +1,10 @@ +import ChatPlugin +import MathChatPlugin +import SearchChatPlugin + +let allPlugins: [ChatPlugin.Type] = [ + TerminalChatPlugin.self, + AITerminalChatPlugin.self, + MathChatPlugin.self, + SearchChatPlugin.self, +] diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift index e90c11d1..de65d4e9 100644 --- a/Core/Sources/ChatService/ChatPluginController.swift +++ b/Core/Sources/ChatService/ChatPluginController.swift @@ -1,4 +1,4 @@ -import ChatPlugins +import ChatPlugin import Combine import Foundation import OpenAIService @@ -7,16 +7,20 @@ final class ChatPluginController { let chatGPTService: any ChatGPTServiceType let plugins: [String: ChatPlugin.Type] var runningPlugin: ChatPlugin? - - init(chatGPTService: any ChatGPTServiceType, plugins: ChatPlugin.Type...) { + + init(chatGPTService: any ChatGPTServiceType, plugins: [ChatPlugin.Type]) { self.chatGPTService = chatGPTService var all = [String: ChatPlugin.Type]() for plugin in plugins { - all[plugin.command] = plugin + all[plugin.command.lowercased()] = plugin } self.plugins = all } + convenience init(chatGPTService: any ChatGPTServiceType, plugins: ChatPlugin.Type...) { + self.init(chatGPTService: chatGPTService, plugins: plugins) + } + /// Handle the message in a plugin if required. Return false if no plugin handles the message. func handleContent(_ content: String) async throws -> Bool { // look for the prefix of content, see if there is something like /command. @@ -25,7 +29,7 @@ final class ChatPluginController { let regex = try NSRegularExpression(pattern: #"^\/([a-zA-Z0-9]+)"#) let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) if let match = matches.first { - let command = String(content[Range(match.range(at: 1), in: content)!]) + let command = String(content[Range(match.range(at: 1), in: content)!]).lowercased() // handle exit plugin if command == "exit" { if let plugin = runningPlugin { @@ -102,13 +106,13 @@ final class ChatPluginController { // MARK: - ChatPluginDelegate extension ChatPluginController: ChatPluginDelegate { - public func pluginDidStartResponding(_: ChatPlugins.ChatPlugin) { + public func pluginDidStartResponding(_: ChatPlugin) { Task { await chatGPTService.markReceivingMessage(true) } } - public func pluginDidEndResponding(_: ChatPlugins.ChatPlugin) { + public func pluginDidEndResponding(_: ChatPlugin) { Task { await chatGPTService.markReceivingMessage(false) } diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 86ba8fb9..048c459b 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -1,36 +1,21 @@ import ChatContextCollector -import ChatPlugins +import ChatPlugin import Combine import Foundation import OpenAIService -let defaultSystemPrompt = """ -You are an AI programming assistant. -Your reply should be concise, clear, informative and logical. -You MUST reply in the format of markdown. -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. -Make your answer short and structured. -""" - public final class ChatService: ObservableObject { public let chatGPTService: any ChatGPTServiceType let pluginController: ChatPluginController let contextController: DynamicContextController var cancellable = Set() - @Published public internal(set) var systemPrompt = defaultSystemPrompt + @Published public internal(set) var systemPrompt = UserDefaults.shared + .value(for: \.defaultChatSystemPrompt) @Published public internal(set) var extraSystemPrompt = "" public init(chatGPTService: T) { self.chatGPTService = chatGPTService - pluginController = ChatPluginController( - chatGPTService: chatGPTService, - plugins: - TerminalChatPlugin.self, - AITerminalChatPlugin.self - ) + pluginController = ChatPluginController(chatGPTService: chatGPTService, plugins: allPlugins) contextController = DynamicContextController( chatGPTService: chatGPTService, contextCollectors: ActiveDocumentChatContextCollector() @@ -63,7 +48,7 @@ public final class ChatService: ObservableObject { } public func resetPrompt() async { - systemPrompt = defaultSystemPrompt + systemPrompt = UserDefaults.shared.value(for: \.defaultChatSystemPrompt) extraSystemPrompt = "" } @@ -94,7 +79,7 @@ public final class ChatService: ObservableObject { /// Setting it to `nil` to reset the system prompt public func mutateSystemPrompt(_ newPrompt: String?) { - systemPrompt = newPrompt ?? defaultSystemPrompt + systemPrompt = newPrompt ?? UserDefaults.shared.value(for: \.defaultChatSystemPrompt) } public func mutateExtraSystemPrompt(_ newPrompt: String) { diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift index 32d38b37..6bbd37f7 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift @@ -127,7 +127,7 @@ public class GitHubCopilotBaseService { }() } let localServer = CopilotLocalProcessServer(executionParameters: executionParams) - + localServer.logMessages = UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) localServer.notificationHandler = { _, respond in respond(.timeout) @@ -162,9 +162,9 @@ public class GitHubCopilotBaseService { return (server, localServer) }() - + self.server = server - self.localProcessServer = localServer + localProcessServer = localServer } public static func createFoldersIfNeeded() throws -> ( @@ -242,6 +242,12 @@ public final class GitHubCopilotAuthService: GitHubCopilotBaseService, } } +@globalActor public enum GitHubCopilotSuggestionActor { + public actor TheActor {} + public static let shared = TheActor() +} + +@GitHubCopilotSuggestionActor public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, GitHubCopilotSuggestionServiceType { @@ -313,7 +319,7 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, return try await task.value } - + public func cancelRequest() async { await localProcessServer?.cancelOngoingTasks() } @@ -380,7 +386,7 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, // Logger.service.debug("Close \(uri)") try await server.sendNotification(.didCloseTextDocument(.init(uri: uri))) } - + public func terminate() async { // automatically handled } diff --git a/Core/Sources/HostApp/AccountSettings/BingSearchView.swift b/Core/Sources/HostApp/AccountSettings/BingSearchView.swift new file mode 100644 index 00000000..408dbd55 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/BingSearchView.swift @@ -0,0 +1,51 @@ +import AppKit +import Client +import OpenAIService +import Preferences +import SuggestionModel +import SwiftUI + +final class BingSearchViewSettings: ObservableObject { + @AppStorage(\.bingSearchSubscriptionKey) var bingSearchSubscriptionKey: String + @AppStorage(\.bingSearchEndpoint) var bingSearchEndpoint: String + init() {} +} + +struct BingSearchView: View { + @Environment(\.openURL) var openURL + @StateObject var settings = BingSearchViewSettings() + + var body: some View { + Form { + Button(action: { + let url = URL(string: "https://www.microsoft.com/bing/apis/bing-web-search-api")! + openURL(url) + }) { + Text("Apply for Subscription Key for Free") + } + + SecureField(text: $settings.bingSearchSubscriptionKey, prompt: Text("")) { + Text("Bing Search Subscription Key") + } + .textFieldStyle(.roundedBorder) + + TextField( + text: $settings.bingSearchEndpoint, + prompt: Text("https://api.bing.microsoft.com/***") + ) { + Text("Bing Search Endpoint") + }.textFieldStyle(.roundedBorder) + } + } +} + +struct BingSearchView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading, spacing: 8) { + BingSearchView() + } + .frame(height: 800) + .padding(.all, 8) + } +} + diff --git a/Core/Sources/HostApp/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandView.swift index 79cecf98..46f0c5eb 100644 --- a/Core/Sources/HostApp/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandView.swift @@ -330,34 +330,6 @@ struct EditCustomCommandView: View { } .padding(.bottom) .background(.regularMaterial) - .sheet(isPresented: .init(get: { editingContentInFullScreen != nil }, set: { - if $0 == false { - editingContentInFullScreen = nil - } - }), content: { - VStack { - if let editingContentInFullScreen { - TextEditor(text: editingContentInFullScreen) - .font(Font.system(.body, design: .monospaced)) - .padding(4) - .frame(minHeight: 120) - .multilineTextAlignment(.leading) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - } - - Button(action: { - editingContentInFullScreen = nil - }) { - Text("Done") - } - } - .padding() - .frame(width: 600, height: 500) - .background(Color(nsColor: .windowBackgroundColor)) - }) } } @@ -365,7 +337,7 @@ struct EditCustomCommandView: View { var promptTextField: some View { VStack(alignment: .leading, spacing: 4) { Text("Prompt") - editableText($prompt) + EditableText(text: $prompt) } .padding(.vertical, 4) } @@ -378,7 +350,7 @@ struct EditCustomCommandView: View { } else { Text(title ?? "System Prompt") } - editableText($systemPrompt) + EditableText(text: $systemPrompt) } .padding(.vertical, 4) } @@ -390,38 +362,6 @@ struct EditCustomCommandView: View { var generateDescriptionToggle: some View { Toggle("Generate Description", isOn: $generatingPromptToCodeDescription) } - - func editableText(_ binding: Binding) -> some View { - Button(action: { - editingContentInFullScreen = binding - }) { - HStack(alignment: .top) { - Text(binding.wrappedValue) - .font(Font.system(.body, design: .monospaced)) - .padding(4) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 4) - .fill(Color(nsColor: .textBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) - } - Image(systemName: "square.and.pencil") - .resizable() - .scaledToFit() - .frame(width: 14) - .padding(4) - .background( - Color.primary.opacity(0.1), - in: RoundedRectangle(cornerRadius: 4) - ) - } - } - .buttonStyle(.plain) - } } // MARK: - Previews diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index 1c56d883..617ddd4d 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -19,6 +19,8 @@ struct ChatSettingsView: View { @AppStorage(\.chatFeatureProvider) var chatFeatureProvider @AppStorage(\.chatGPTModel) var chatGPTModel + @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt + @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations init() {} } @@ -35,6 +37,8 @@ struct ChatSettingsView: View { uiForm Divider() contextForm + Divider() + pluginForm } } @@ -122,6 +126,13 @@ struct ChatSettingsView: View { Text("9 Messages").tag(9) Text("11 Messages").tag(11) } + + VStack(alignment: .leading, spacing: 4) { + Text("Default System Prompt") + EditableText(text: $settings.defaultChatSystemPrompt) + .lineLimit(6) + } + .padding(.vertical, 4) }.onAppear { checkMaxToken() }.onChange(of: settings.chatFeatureProvider) { _ in @@ -170,7 +181,7 @@ struct ChatSettingsView: View { 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.") } @@ -190,6 +201,20 @@ struct ChatSettingsView: View { } } + @ViewBuilder + var pluginForm: some View { + Form { + TextField(text: .init(get: { + "\(Int(settings.chatSearchPluginMaxIterations))" + }, set: { + settings.chatSearchPluginMaxIterations = Int($0) ?? 0 + })) { + Text("Search Plugin Max Iterations") + } + .textFieldStyle(.roundedBorder) + } + } + var languagePicker: some View { Menu { if !settings.chatGPTLanguage.isEmpty, diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 3cb35ab3..5960e849 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -39,6 +39,15 @@ struct ServiceView: View { subtitle: "Chat, Prompt to Code", image: "globe" ) + + ScrollView { + BingSearchView().padding() + }.sidebarItem( + tag: 4, + title: "Bing Search", + subtitle: "Search Chat Plugin", + image: "globe" + ) } } } diff --git a/Core/Sources/HostApp/SharedComponents/EditableText.swift b/Core/Sources/HostApp/SharedComponents/EditableText.swift new file mode 100644 index 00000000..b5186ea0 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/EditableText.swift @@ -0,0 +1,62 @@ +import Foundation +import SwiftUI + +struct EditableText: View { + var text: Binding + @State var isEditing: Bool = false + + var body: some View { + Button(action: { + isEditing = true + }) { + HStack(alignment: .top) { + Text(text.wrappedValue) + .font(Font.system(.body, design: .monospaced)) + .padding(4) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 4) + .fill(Color(nsColor: .textBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) + } + Image(systemName: "square.and.pencil") + .resizable() + .scaledToFit() + .frame(width: 14) + .padding(4) + .background( + Color.primary.opacity(0.1), + in: RoundedRectangle(cornerRadius: 4) + ) + } + } + .buttonStyle(.plain) + .sheet(isPresented: $isEditing) { + VStack { + TextEditor(text: text) + .font(Font.system(.body, design: .monospaced)) + .padding(4) + .frame(minHeight: 120) + .multilineTextAlignment(.leading) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + + Button(action: { + isEditing = false + }) { + Text("Done") + } + } + .padding() + .frame(width: 600, height: 500) + .background(Color(nsColor: .windowBackgroundColor)) + } + } +} + diff --git a/Core/Sources/Service/CustomCommandTemplateProcessor.swift b/Core/Sources/Service/CustomCommandTemplateProcessor.swift new file mode 100644 index 00000000..5f611b01 --- /dev/null +++ b/Core/Sources/Service/CustomCommandTemplateProcessor.swift @@ -0,0 +1,46 @@ +import Foundation +import SuggestionModel +import XcodeInspector + +struct CustomCommandTemplateProcessor { + func process(_ text: String) -> String { + let info = getEditorInformation() + let editorContent = info.editorContent + let updatedText = text + .replacingOccurrences(of: "{{selected_code}}", with: """ + \(editorContent?.selectedContent.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") + """) + .replacingOccurrences( + of: "{{active_editor_language}}", + with: info.language.rawValue + ) + .replacingOccurrences( + of: "{{active_editor_file_url}}", + with: info.documentURL?.path ?? "" + ) + .replacingOccurrences( + of: "{{active_editor_file_name}}", + with: info.documentURL?.lastPathComponent ?? "" + ) + return updatedText + } + + struct EditorInformation { + let editorContent: SourceEditor.Content? + let language: CodeLanguage + let documentURL: URL? + } + + func getEditorInformation() -> EditorInformation { + let editorContent = XcodeInspector.shared.focusedEditor?.content + let documentURL = XcodeInspector.shared.activeDocumentURL + let language = languageIdentifierFromFileURL(documentURL) + + return .init( + editorContent: editorContent, + language: language, + documentURL: documentURL + ) + } +} + diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index a899c840..6761d5cd 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -1,12 +1,12 @@ import ChatService -import SuggestionModel -import GitHubCopilotService import Environment import Foundation +import GitHubCopilotService import LanguageServerProtocol import Logger import OpenAIService import SuggestionInjector +import SuggestionModel import SuggestionWidget import XPCShared @@ -53,7 +53,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { forFileAt: fileURL, editor: editor ) - + try Task.checkCancellation() if filespace.presentingSuggestion != nil { @@ -388,7 +388,7 @@ extension WindowBaseCommandHandler { presenter.presentPromptToCode(fileURL: fileURL) } - + private func startChat( specifiedSystemPrompt: String?, extraSystemPrompt: String?, @@ -402,15 +402,16 @@ extension WindowBaseCommandHandler { let chat = WidgetDataSource.shared.createChatIfNeeded(for: focusedElementURI) - chat.mutateSystemPrompt(specifiedSystemPrompt) - chat.mutateExtraSystemPrompt(extraSystemPrompt ?? "") + let templateProcessor = CustomCommandTemplateProcessor() + chat.mutateSystemPrompt(specifiedSystemPrompt.map(templateProcessor.process)) + chat.mutateExtraSystemPrompt(extraSystemPrompt.map(templateProcessor.process) ?? "") Task { let customCommandPrefix = { if let name { return "[\(name)] " } return "" }() - + if specifiedSystemPrompt != nil || extraSystemPrompt != nil { await chat.chatGPTService.mutateHistory { history in history.append(.init( @@ -422,10 +423,12 @@ extension WindowBaseCommandHandler { } if let sendingMessageImmediately, !sendingMessageImmediately.isEmpty { - try await chat.send(content: sendingMessageImmediately) + try await chat + .send(content: templateProcessor.process(sendingMessageImmediately)) } } presenter.presentChatRoom(fileURL: focusedElementURI) } } + diff --git a/Core/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift b/Core/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift index 75708204..56d91704 100644 --- a/Core/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift +++ b/Core/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift @@ -16,7 +16,7 @@ public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable { return language } } - + public var hashValue: Int { rawValue.hashValue } @@ -30,7 +30,7 @@ public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable { self = .other(rawValue) } } - + public static var allCases: [CodeLanguage] { var all = LanguageIdentifier.allCases.map(CodeLanguage.builtIn) all.append(.plaintext) @@ -38,7 +38,7 @@ public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable { } } -extension LanguageIdentifier { +public extension LanguageIdentifier { /// Copied from https://github.com/github/linguist/blob/master/lib/linguist/languages.yml [MIT] var fileExtensions: [String] { switch self { @@ -263,3 +263,4 @@ public func languageIdentifierFromFileURL(_ fileURL: URL) -> CodeLanguage { } return .init(rawValue: fileExtension) ?? .plaintext } + diff --git a/Core/Sources/SuggestionWidget/CustomTextEditor.swift b/Core/Sources/SuggestionWidget/CustomTextEditor.swift index de750c04..00251edc 100644 --- a/Core/Sources/SuggestionWidget/CustomTextEditor.swift +++ b/Core/Sources/SuggestionWidget/CustomTextEditor.swift @@ -8,19 +8,25 @@ struct CustomTextEditor: NSViewRepresentable { @Binding var text: String let font: NSFont let onSubmit: () -> Void + var completions: (_ text: String, _ words: [String], _ range: NSRange) + -> [String] = { _, _, _ in + [] + } func makeNSView(context: Context) -> NSScrollView { + context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) textView.delegate = context.coordinator textView.string = text textView.font = font textView.allowsUndo = true textView.drawsBackground = false - + return context.coordinator.theTextView } func updateNSView(_ nsView: NSScrollView, context: Context) { + context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) guard textView.string != text else { return } textView.string = text @@ -32,6 +38,7 @@ extension CustomTextEditor { var view: CustomTextEditor var theTextView = NSTextView.scrollableTextView() var affectedCharRange: NSRange? + var completions: (String, [String], _ range: NSRange) -> [String] = { _, _, _ in [] } init(_ view: CustomTextEditor) { self.view = view @@ -43,13 +50,14 @@ extension CustomTextEditor { } view.text = textView.string + textView.complete(nil) } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSTextView.insertNewline(_:)) { if let event = NSApplication.shared.currentEvent, !event.modifierFlags.contains(.shift), - event.keyCode == 36 + event.keyCode == 36 // enter { view.onSubmit() return true @@ -66,6 +74,16 @@ extension CustomTextEditor { ) -> Bool { return true } + + func textView( + _ textView: NSTextView, + completions words: [String], + forPartialWordRange charRange: NSRange, + indexOfSelectedItem index: UnsafeMutablePointer? + ) -> [String] { + index?.pointee = -1 + return completions(textView.textStorage?.string ?? "", words, charRange) + } } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift index 6a035138..5683c2e1 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift @@ -73,15 +73,6 @@ struct ChatPanelMessages: View { .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) } - if chat.history.isEmpty { - Text("New Chat") - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical) - .scaleEffect(x: -1, y: -1, anchor: .center) - .foregroundStyle(.secondary) - .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) - } - ForEach(chat.history.reversed(), id: \.id) { message in let text = message.text.isEmpty && !message.isUser ? "..." : message .text @@ -98,6 +89,8 @@ struct ChatPanelMessages: View { } .listItemTint(.clear) + Instruction() + Spacer() } .scaleEffect(x: -1, y: 1, anchor: .center) @@ -135,6 +128,59 @@ private struct StopRespondingButton: View { } } +private struct Instruction: View { + @AppStorage(\.useSelectionScopeByDefaultInChatContext) + var useSelectionScopeByDefaultInChatContext + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + Group { + if useSelectionScopeByDefaultInChatContext { + Markdown( + """ + Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + + 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. + + If you'd like me to examine the entire file, simply add `@file` to the beginning of your message. + + 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. + + 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. + + 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`. + + 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) + } + .scaleEffect(x: -1, y: -1, anchor: .center) + } +} + private struct UserMessage: View { let id: String let text: String @@ -177,7 +223,7 @@ private struct UserMessage: View { Button("Send Again") { chat.resendMessage(id: id) } - + Button("Set as Extra System Prompt") { chat.setAsExtraPrompt(id: id) } @@ -234,7 +280,7 @@ private struct BotMessage: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } - + Button("Set as Extra System Prompt") { chat.setAsExtraPrompt(id: id) } @@ -308,7 +354,29 @@ struct ChatPanelInputArea: View { CustomTextEditor( text: $typedMessage, font: .systemFont(ofSize: 14), - onSubmit: { submitText() } + onSubmit: { submitText() }, + completions: { text, _, range in + if text.isEmpty { return [] } + let availableFeatures = [ + "/run", + "/airun", + "/math", + "/search", + "/exit", + "@selection", + "@file", + ] + return availableFeatures + .filter { $0.hasPrefix(text) && $0 != text } + .compactMap { + guard let index = $0.index( + $0.startIndex, + offsetBy: range.location, + limitedBy: $0.endIndex + ) else { return nil } + return String($0[index...]) + } + } ) .padding(.top, 1) .padding(.bottom, -1) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlock.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlock.swift index d53d9fce..77eae975 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlock.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlock.swift @@ -10,8 +10,6 @@ struct CodeBlock: View { let firstLinePrecedingSpaceCount: Int let fontSize: Double - @AppStorage(\.disableLazyVStack) var disableLazyVStack - init( code: String, language: String, @@ -39,21 +37,8 @@ struct CodeBlock: View { highlightedCode = result.code } - @ViewBuilder - func vstack(@ViewBuilder content: () -> some View) -> some View { - if disableLazyVStack { - VStack(spacing: 2) { - content() - } - } else { - LazyVStack(spacing: 2) { - content() - } - } - } - var body: some View { - vstack { + VStack(spacing: 2) { ForEach(0..(_ r: E) async throws -> E.Response where E: GitHubCopilotRequestType { return GitHubCopilotRequest.GetCompletionsCycling.Response(completions: [ .init( diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e6c21976..0cb346be 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,9 +27,7 @@ Most of the logics are implemented inside the package `Core`. ## Building and Archiving the App -This project includes a Git submodule, `copilot.vim`, so you will need to either initialize the submodule or download it from the [copilot.vim](https://github.com/github/copilot.vim) repository. - -Finally, archive the Copilot for Xcode target. +1. Build or archive the Copilot for Xcode target. ## Testing Extension diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 501ff6a8..2e9780a2 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -43,6 +43,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() DependencyUpdater().update() + initializePython() Task { do { try await ServiceUpdateMigrator().migrate() diff --git a/ExtensionService/InitializePython.swift b/ExtensionService/InitializePython.swift new file mode 100644 index 00000000..7f438781 --- /dev/null +++ b/ExtensionService/InitializePython.swift @@ -0,0 +1,38 @@ +import Foundation +//import Python +import PythonHelper +import PythonKit +import Logger + +@MainActor +func initializePython() { +// guard let sitePackagePath = Bundle.main.path(forResource: "site-packages", ofType: nil), +// let stdLibPath = Bundle.main.path(forResource: "python-stdlib", ofType: nil), +// let libDynloadPath = Bundle.main.path( +// forResource: "python-stdlib/lib-dynload", +// ofType: nil +// ) +// else { +// Logger.service.info("Python is not installed!") +// return +// } +// +// PythonHelper.initializePython( +// sitePackagePath: sitePackagePath, +// stdLibPath: stdLibPath, +// libDynloadPath: libDynloadPath, +// Py_Initialize: Py_Initialize, +// PyEval_SaveThread: PyEval_SaveThread, +// PyGILState_Ensure: PyGILState_Ensure, +// PyGILState_Release: PyGILState_Release +// ) +// +// Task { +// // All future task should run inside runPython. +// try runPython { +// let sys = Python.import("sys") +// Logger.service.info("Python Version: \(sys.version_info.major).\(sys.version_info.minor)") +// } +// } +} + diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..d985bfb3 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +setup: setup-langchain + +setup-langchain: + cd Python; \ + curl -L https://github.com/beeware/Python-Apple-support/releases/download/3.11-b1/Python-3.11-macOS-support.b1.tar.gz -o Python-3.11-macOS-support.b1.tar.gz; \ + tar -xzvf Python-3.11-macOS-support.b1.tar.gz; \ + rm Python-3.11-macOS-support.b1.tar.gz; \ + cp module.modulemap Python.xcframework/macos-arm64_x86_64/Headers/module.modulemap + cd Python/site-packages; \ + sh ./install.sh + +.PHONY: setup setup-langchain \ No newline at end of file diff --git a/Python/module.modulemap b/Python/module.modulemap new file mode 100644 index 00000000..d89495b6 --- /dev/null +++ b/Python/module.modulemap @@ -0,0 +1,5 @@ +module Python { + umbrella header "Python.h" + export * + link "Python" +} \ No newline at end of file diff --git a/Python/site-packages/install.sh b/Python/site-packages/install.sh new file mode 100755 index 00000000..97ce7cda --- /dev/null +++ b/Python/site-packages/install.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +shopt -s extglob + +rm -rfv !("requirements.txt"|"install.sh") +python3.11 -m pip install -r requirements.txt -t . + +rm pip/__init__.py +cp ../pip_init.py pip/__init__.py +find . -name "__pycache__" -exec rm -rf {} \; || true +find "*.so" -delete || true diff --git a/Python/site-packages/requirements.txt b/Python/site-packages/requirements.txt new file mode 100644 index 00000000..214e842d --- /dev/null +++ b/Python/site-packages/requirements.txt @@ -0,0 +1,4 @@ +openai +langchain +readability-lxml +tiktoken diff --git a/README.md b/README.md index ab2bc089..aa742258 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Screenshot](/Screenshot.png) -Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copilot, Codeium and ChatGPT support for Xcode. +Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copilot, Codeium and ChatGPT support for Xcode. Buy Me A Coffee @@ -20,10 +20,10 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Installation and Setup](#installation-and-setup) - [Install](#install) - [Enable the Extension](#enable-the-extension) + - [Granting Permissions to the App](#granting-permissions-to-the-app) - [Setting Up GitHub Copilot](#setting-up-github-copilot) - [Setting Up Codeium](#setting-up-codeium) - [Setting Up OpenAI API Key](#setting-up-openai-api-key) - - [Granting Permissions to the App](#granting-permissions-to-the-app) - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) - [Update](#update) - [Feature](#feature) @@ -41,14 +41,16 @@ For development instruction, check [Development.md](DEVELOPMENT.md). - Public network connection. For suggestion features: -- For GitHub Copilot users: + +- For GitHub Copilot users: - [Node](https://nodejs.org/) installed to run the Copilot LSP. - Active GitHub Copilot subscription. - For Codeium users: - Active Codeium account. For chat and prompt to code features: -- Valid OpenAI API key. + +- Valid OpenAI API key. ## Permissions Required @@ -73,17 +75,29 @@ Open the app, the app will create a launch agent to setup a background running S ### Enable the Extension -Enable the extension in `System Settings.app`. +Enable the extension in `System Settings.app`. From the Apple menu located in the top-left corner of your screen click `System Settings`. Navigate to `Privacy & Security` then toward the bottom click `Extensions`. Click `Xcode Source Editor` and tick `Copilot`. - + If you are using macOS Monterey, enter the `Extensions` menu in `System Preferences.app` with its dedicated icon. +### Granting Permissions to the App + +The first time the app is open and command run, the extension will ask for the necessary permissions. + +Alternatively, you may manually grant the required permissions by navigating to the `Privacy & Security` tab in the `System Settings.app`. + +- To grant permissions for the Accessibility API, click `Accessibility`, and drag `CopilotForXcodeExtensionService.app` to the list. You can locate the extension app by clicking `Reveal Extension App in Finder` in the host app. + +Accessibility API + +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 GitHub Copilot - + 1. In the host app, switch to the service tab and click on GitHub Copilot to access your GitHub Copilot account settings. 2. Click "Install" to install the language server. -3. Optionally setup the path to Node. The default value is just `node`, Copilot for Xcode.app will try to find the Node from the PATH available in a login shell. If your Node is installed somewhere else, you can run `which node` from terminal to get the path. +3. Optionally setup the path to Node. The default value is just `node`, Copilot for Xcode.app will try to find the Node from the PATH available in a login shell. If your Node is installed somewhere else, you can run `which node` from terminal to get the path. 4. Click "Sign In", and you will be directed to a verification website provided by GitHub, and a user code will be pasted into your clipboard. 5. After signing in, go back to the app and click "Confirm Sign-in" to finish. 6. Go to "Feature - Suggestion" and update the feature provider to "GitHub Copilot". @@ -106,25 +120,13 @@ The installed language server is located at `~/Library/Application Support/com.i 1. In the host app, click OpenAI to enter the OpenAI account settings. 2. Enter your api key to the text field. -### Granting Permissions to the App - -The first time the app is open and command run, the extension will ask for the necessary permissions. - -Alternatively, you may manually grant the required permissions by navigating to the `Privacy & Security` tab in the `System Settings.app`. - -- To grant permissions for the Accessibility API, click `Accessibility`, and drag `CopilotForXcodeExtensionService.app` to the list. You can locate the extension app by clicking `Reveal Extension App in Finder` in the host app. - -Accessibility API - -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. - ### Managing `CopilotForXcodeExtensionService.app` This app runs whenever you open `Copilot for Xcode.app` or `Xcode.app`. You can quit it with its menu bar item that looks like a steering wheel. You can also set it to quit automatically when the above 2 apps are closed. -## Update +## Update If the app was installed via Homebrew, you can update it by running: @@ -132,7 +134,7 @@ If the app was installed via Homebrew, you can update it by running: brew upgrade --cask copilot-for-xcode ``` -Alternatively, You can use the in-app updater or download the latest version manually from the latest [release](https://github.com/intitni/CopilotForXcode/releases). +Alternatively, You can use the in-app updater or download the latest version manually from the latest [release](https://github.com/intitni/CopilotForXcode/releases). After updating, please restart Xcode to allow the extension to reload. @@ -144,13 +146,13 @@ If you find that some of the features are no longer working, please first try re ### Suggestion -The app can provide real-time code suggestions based on the files you have opened. It's powered by GitHub Copilot and Codeium. +The app can provide real-time code suggestions based on the files you have opened. It's powered by GitHub Copilot and Codeium. -If you're working on a company project and don't want the suggestion feature to be triggered, you can globally disable it and choose to enable it only for specific projects. +If you're working on a company project and don't want the suggestion feature to be triggered, you can globally disable it and choose to enable it only for specific projects. Whenever your code is updated, the app will automatically fetch suggestions for you, you can cancel this by pressing **Escape**. -*: If a file is already open before the helper app launches, you will need to switch to those files in order to send the open file notification. +\*: If a file is already open before the helper app launches, you will need to switch to those files in order to send the open file notification. #### Commands @@ -167,6 +169,7 @@ Whenever your code is updated, the app will automatically fetch suggestions for This feature is powered by ChatGPT. Please ensure that you have set up your OpenAI account before using it. The chat knows the following information: + - The selected code in the active editor. - The relative path of the file. - The error and warning labels in the active editor. @@ -182,43 +185,48 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha #### Keyboard Shortcuts -| Shortcut | Description | -|:---:|---| -| `⌘W` | Close the chat. | -| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the circular widget. | -| `⇧↩︎` | Add new line. | +| Shortcut | Description | +| :------: | --------------------------------------------------------------------------------------------------- | +| `⌘W` | Close the chat. | +| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the circular widget. | +| `⇧↩︎` | Add new line. | #### 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 | -|:---:|---| +| 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. | +| `@file` | Inject the content of the file into the conversation. Keep in mind that you may not have enough tokens to inject large files. | #### Chat Plugins -The chat panel supports chat plugins that may not require an OpenAI API key. For example, if you need to use the `/run` plugin, you just type +The chat panel supports chat plugins that may not require an OpenAI API key. For example, if you need to use the `/run` plugin, you just type + ``` /run echo hello ``` -If you need to end a plugin, you can just type +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` | Create 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. | +| 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. | ### Prompt to Code Refactor existing code or write new code using natural language. This feature is recommended when you need to update a specific piece of code. Some example use cases include: + - Improving the code's readability. - Correcting bugs in the code. - Adding documentation to the code. @@ -239,21 +247,30 @@ You can create custom commands that run Chat and Prompt to Code with personalize - Open Chat: Open the chat window and immediately send a message, if provided. You can provide more information through the extra system prompt field. - Custom Chat: Open the chat window and immediately send a message, if provided. You can overwrite the entire system prompt through the system prompt field. +For Open Chat and Custom Chat commands, you can use the following template arguments: + +| Argument | Description | +| ----------------------------- | ---------------------------------------------- | +| `{{selected_code}}` | The currently selected code in the editor. | +| `{{active_editor_language}}` | The programming language of the active editor. | +| `{{active_editor_file_url}}` | The URL of the active file in the editor. | +| `{{active_editor_file_name}}` | The name of the active file in the editor. | + ## Key Bindings It looks like there is no way to add default key bindings to commands, but you can set them up in `Xcode settings > Key Bindings`. You can filter the list by typing `copilot` in the search bar. A [recommended setup](https://github.com/intitni/CopilotForXcode/issues/14) that should cause no conflict is -| Command | Key Binding | -| --- | --- | -| Get Suggestions | `⌥?` | -| Accept Suggestions | `⌥}` | -| Reject Suggestion | `⌥{` | -| Next Suggestion | `⌥>` | -| Previous Suggestion | `⌥<` | -| Open Chat | `⌥"` | -| Explain Selection | `⌥\|` | +| Command | Key Binding | +| ------------------- | ----------- | +| Get Suggestions | `⌥?` | +| Accept Suggestions | `⌥}` | +| Reject Suggestion | `⌥{` | +| Next Suggestion | `⌥>` | +| Previous Suggestion | `⌥<` | +| Open Chat | `⌥"` | +| Explain Selection | `⌥\|` | Essentially using `⌥⇧` as the "access" key combination for all bindings. @@ -279,6 +296,7 @@ fi - The extension uses some dirty tricks to get the file and project/workspace paths. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though. - The suggestions are presented as C-style comments in comment mode, they may break your code if you are editing a JSON file or something. -## License +## License Please check [LICENSE](LICENSE) for details. + diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index f5104832..7bb78eff 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -25,50 +25,57 @@ { "target" : { "containerPath" : "container:Core", - "identifier" : "CopilotModelTests", - "name" : "CopilotModelTests" + "identifier" : "ServiceTests", + "name" : "ServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "CopilotServiceTests", - "name" : "CopilotServiceTests" + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ServiceTests", - "name" : "ServiceTests" + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "SuggestionInjectorTests", - "name" : "SuggestionInjectorTests" + "identifier" : "PromptToCodeServiceTests", + "name" : "PromptToCodeServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "OpenAIServiceTests", - "name" : "OpenAIServiceTests" + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "SuggestionWidgetTests", - "name" : "SuggestionWidgetTests" + "identifier" : "SuggestionModelTests", + "name" : "SuggestionModelTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "PromptToCodeServiceTests", - "name" : "PromptToCodeServiceTests" + "containerPath" : "container:Tool", + "identifier" : "LangChainTests", + "name" : "LangChainTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "OpenAIServiceTests", + "name" : "OpenAIServiceTests" } } ], diff --git a/Tool/.gitignore b/Tool/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Tool/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Tool/Package.swift b/Tool/Package.swift new file mode 100644 index 00000000..1088767f --- /dev/null +++ b/Tool/Package.swift @@ -0,0 +1,78 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Tool", + platforms: [.macOS(.v12)], + products: [ + .library(name: "Terminal", targets: ["Terminal"]), + .library(name: "LangChain", targets: ["LangChain", "PythonHelper", "BingSearchService"]), + .library(name: "Preferences", targets: ["Preferences", "Configs"]), + .library(name: "Logger", targets: ["Logger"]), + .library(name: "OpenAIService", targets: ["OpenAIService"]), + ], + dependencies: [ + .package(url: "https://github.com/pvieito/PythonKit.git", branch: "master"), + .package(url: "https://github.com/alfianlosari/GPTEncoder", from: "1.0.4"), + .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") + ], + targets: [ + // MARK: - Helpers + + .target(name: "Configs"), + + .target(name: "Preferences", dependencies: ["Configs"]), + + .target(name: "Terminal"), + + .target(name: "Logger"), + + // MARK: - Services + + .target( + name: "LangChain", + dependencies: [ + "PythonHelper", + "OpenAIService", + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "PythonKit", package: "PythonKit"), + ] + ), + + .target(name: "BingSearchService"), + + .target( + name: "PythonHelper", + dependencies: [ + .product(name: "PythonKit", package: "PythonKit"), + ] + ), + + // MARK: - OpenAI + + .target( + name: "OpenAIService", + dependencies: [ + "GPTEncoder", + "Logger", + "Preferences", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ] + ), + .testTarget( + name: "OpenAIServiceTests", + dependencies: ["OpenAIService"] + ), + + // MARK: - Tests + + .testTarget( + name: "LangChainTests", + dependencies: ["LangChain"] + ), + ] +) + diff --git a/Tool/README.md b/Tool/README.md new file mode 100644 index 00000000..1d22e107 --- /dev/null +++ b/Tool/README.md @@ -0,0 +1,3 @@ +# Tool + +A description of this package. diff --git a/Tool/Sources/BingSearchService/BingSearchService.swift b/Tool/Sources/BingSearchService/BingSearchService.swift new file mode 100644 index 00000000..a2d3f847 --- /dev/null +++ b/Tool/Sources/BingSearchService/BingSearchService.swift @@ -0,0 +1,74 @@ +import Foundation + +public struct BingSearchResult: Codable { + public var webPages: WebPages + + public struct WebPages: Codable { + public var webSearchUrl: String + public var totalEstimatedMatches: Int + public var value: [WebPageValue] + + public struct WebPageValue: Codable { + public var id: String + public var name: String + public var url: String + public var displayUrl: String + public var snippet: String + } + } +} + +struct BingSearchResponseError: Codable, Error, LocalizedError { + struct E: Codable { + var code: String? + var message: String? + } + + var error: E + var errorDescription: String? { error.message } +} + +enum BingSearchError: Error, LocalizedError { + case searchURLFormatIncorrect(String) + + var errorDescription: String? { + switch self { + case let .searchURLFormatIncorrect(url): + return "The search URL format is incorrect: \(url)" + } + } +} + +public struct BingSearchService { + public var subscriptionKey: String + public var searchURL: String + + public init(subscriptionKey: String, searchURL: String) { + self.subscriptionKey = subscriptionKey + self.searchURL = searchURL + } + + public func search(query: String, numberOfResult: Int) async throws -> BingSearchResult { + guard let url = URL(string: searchURL) + else { throw BingSearchError.searchURLFormatIncorrect(searchURL) } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) + components?.queryItems = [ + .init(name: "q", value: query), + .init(name: "count", value: String(numberOfResult)), + ] + var request = URLRequest(url: components?.url ?? url) + request.httpMethod = "GET" + request.addValue(subscriptionKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key") + + let (data, _) = try await URLSession.shared.data(for: request) + do { + let result = try JSONDecoder().decode(BingSearchResult.self, from: data) + return result + } catch { + let e = try JSONDecoder().decode(BingSearchResponseError.self, from: data) + throw e + } + } +} + diff --git a/Core/Sources/Configs/Configurations.swift b/Tool/Sources/Configs/Configurations.swift similarity index 100% rename from Core/Sources/Configs/Configurations.swift rename to Tool/Sources/Configs/Configurations.swift diff --git a/Tool/Sources/LangChain/Agent.swift b/Tool/Sources/LangChain/Agent.swift new file mode 100644 index 00000000..559c80fc --- /dev/null +++ b/Tool/Sources/LangChain/Agent.swift @@ -0,0 +1,134 @@ +import Foundation + +public struct AgentAction: Equatable { + public var toolName: String + public var toolInput: String + public var log: String + public var observation: String? + + public init(toolName: String, toolInput: String, log: String, observation: String? = nil) { + self.toolName = toolName + self.toolInput = toolInput + self.log = log + self.observation = observation + } + + public func observationAvailable(_ observation: String) -> AgentAction { + var newAction = self + newAction.observation = observation + return newAction + } +} + +public struct AgentFinish: Equatable { + public var returnValue: String + public var log: String + + public init(returnValue: String, log: String) { + self.returnValue = returnValue + self.log = log + } +} + +public enum AgentNextStep: Equatable { + case actions([AgentAction]) + case finish(AgentFinish) +} + +public enum AgentScratchPad: Equatable { + case text(String) + case messages([String]) + + var isEmpty: Bool { + switch self { + case .text(let text): + return text.isEmpty + case .messages(let messages): + return messages.isEmpty + } + } +} + +public struct AgentInput { + var input: T + var thoughts: AgentScratchPad + + public init(input: T, thoughts: AgentScratchPad) { + self.input = input + self.thoughts = thoughts + } +} + +extension AgentInput: Equatable where T: Equatable {} + +public enum AgentEarlyStopHandleType: Equatable { + case force + case generate +} + +public protocol Agent { + associatedtype Input + var chatModelChain: ChatModelChain> { get } + var observationPrefix: String { get } + var llmPrefix: String { get } + + func validateTools(tools: [AgentTool]) throws + func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad + func parseOutput(_ output: String) -> AgentNextStep +} + +public extension Agent { + func getFullInputs(input: Input, intermediateSteps: [AgentAction]) -> AgentInput { + let thoughts = constructScratchpad(intermediateSteps: intermediateSteps) + return AgentInput(input: input, thoughts: thoughts) + } + + func plan( + input: Input, + intermediateSteps: [AgentAction], + callbackManagers: [ChainCallbackManager] + ) async throws -> AgentNextStep { + let input = getFullInputs(input: input, intermediateSteps: intermediateSteps) + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + return parseOutput(output) + } + + func returnStoppedResponse( + input: Input, + earlyStoppedHandleType: AgentEarlyStopHandleType, + intermediateSteps: [AgentAction], + callbackManagers: [ChainCallbackManager] + ) async throws -> AgentFinish { + switch earlyStoppedHandleType { + case .force: + return AgentFinish( + returnValue: "Agent stopped due to iteration limit or time limit.", + log: "" + ) + case .generate: + var thoughts = constructBaseScratchpad(intermediateSteps: intermediateSteps) + thoughts += "\n\n\(llmPrefix)I now need to return a final answer based on the previous steps:" + let input = AgentInput(input: input, thoughts: .text(thoughts)) + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + let nextAction = parseOutput(output) + switch nextAction { + case let .finish(finish): + return finish + case .actions: + return AgentFinish(returnValue: output, log: output) + } + } + } + + func constructBaseScratchpad(intermediateSteps: [AgentAction]) -> String { + var thoughts = "" + for step in intermediateSteps { + thoughts += """ + \(step.log) + \(observationPrefix)\(step.observation ?? "") + """ + } + return thoughts + } +} + diff --git a/Tool/Sources/LangChain/AgentExecutor.swift b/Tool/Sources/LangChain/AgentExecutor.swift new file mode 100644 index 00000000..02d2dc33 --- /dev/null +++ b/Tool/Sources/LangChain/AgentExecutor.swift @@ -0,0 +1,172 @@ +import Foundation + +public protocol ChainCallbackManager { + func onChainStart(type: T.Type, input: T.Input) + func onAgentFinish(output: AgentFinish) + func onAgentActionStart(action: AgentAction) + func onAgentActionEnd(action: AgentAction) + func onLLMNewToken(token: String) +} + +public actor AgentExecutor: Chain where InnerAgent.Input == String { + public typealias Input = String + public struct Output { + let finalOutput: String + let intermediateSteps: [AgentAction] + } + + let agent: InnerAgent + let tools: [String: AgentTool] + let maxIteration: Int? + let maxExecutionTime: Double? + var earlyStopHandleType: AgentEarlyStopHandleType + var now: () -> Date = { Date() } + var isCancelled = false + + public init( + agent: InnerAgent, + tools: [AgentTool], + maxIteration: Int? = 10, + maxExecutionTime: Double? = nil, + earlyStopHandleType: AgentEarlyStopHandleType = .force + ) { + self.agent = agent + self.tools = tools.reduce(into: [:]) { $0[$1.name] = $1 } + self.maxIteration = maxIteration + self.maxExecutionTime = maxExecutionTime + self.earlyStopHandleType = earlyStopHandleType + } + + public func callLogic( + _ input: Input, + callbackManagers: [ChainCallbackManager] + ) async throws -> Output { + try agent.validateTools(tools: Array(tools.values)) + + let startTime = now().timeIntervalSince1970 + var iterations = 0 + var intermediateSteps: [AgentAction] = [] + + func shouldContinue() -> Bool { + if isCancelled { return false } + if let maxIteration = maxIteration, iterations >= maxIteration { + return false + } + if let maxExecutionTime = maxExecutionTime, + now().timeIntervalSince1970 - startTime > maxExecutionTime + { + return false + } + return true + } + + while shouldContinue() { + let nextStepOutput = try await takeNextStep( + input: input, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + + switch nextStepOutput { + case let .finish(finish): + return end( + output: finish, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + case let .actions(actions): + intermediateSteps.append(contentsOf: actions) + if actions.count == 1, + let action = actions.first, + let toolFinish = getToolFinish(action: action) + { + return end( + output: toolFinish, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + } + } + iterations += 1 + } + + let output = try await agent.returnStoppedResponse( + input: input, + earlyStoppedHandleType: earlyStopHandleType, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + return end( + output: output, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + } + + public nonisolated func parseOutput(_ output: Output) -> String { + output.finalOutput + } + + public func cancel() { + isCancelled = true + earlyStopHandleType = .force + } +} + +struct InvalidToolError: Error {} + +extension AgentExecutor { + func end( + output: AgentFinish, + intermediateSteps: [AgentAction], + callbackManagers: [ChainCallbackManager] + ) -> Output { + for callbackManager in callbackManagers { + callbackManager.onAgentFinish(output: output) + } + let finalOutput = output.returnValue + return .init(finalOutput: finalOutput, intermediateSteps: intermediateSteps) + } + + func takeNextStep( + input: Input, + intermediateSteps: [AgentAction], + callbackManagers: [ChainCallbackManager] + ) async throws -> AgentNextStep { + let output = try await agent.plan( + input: input, + intermediateSteps: intermediateSteps, + callbackManagers: callbackManagers + ) + switch output { + case .finish: return output + case let .actions(actions): + let completedActions = try await withThrowingTaskGroup(of: AgentAction.self) { + taskGroup in + for action in actions { + callbackManagers.forEach { $0.onAgentActionStart(action: action) } + guard let tool = tools[action.toolName] else { throw InvalidToolError() } + taskGroup.addTask { + let observation = try await tool.run(input: action.toolInput) + return action.observationAvailable(observation) + } + } + var completedActions = [AgentAction]() + for try await action in taskGroup { + completedActions.append(action) + callbackManagers.forEach { $0.onAgentActionEnd(action: action) } + } + return completedActions + } + + return .actions(completedActions) + } + } + + func getToolFinish(action: AgentAction) -> AgentFinish? { + guard let tool = tools[action.toolName] else { return nil } + guard tool.returnDirectly else { return nil } + return .init(returnValue: action.observation ?? "", log: "") + } +} + diff --git a/Tool/Sources/LangChain/AgentTool.swift b/Tool/Sources/LangChain/AgentTool.swift new file mode 100644 index 00000000..170cd9ca --- /dev/null +++ b/Tool/Sources/LangChain/AgentTool.swift @@ -0,0 +1,32 @@ +import Foundation + +public protocol AgentTool { + var name: String { get } + var description: String { get } + var returnDirectly: Bool { get } + func run(input: String) async throws -> String +} + +public struct SimpleAgentTool: AgentTool { + public let name: String + public let description: String + public let returnDirectly: Bool + public let run: (String) async throws -> String + + public init( + name: String, + description: String, + returnDirectly: Bool = false, + run: @escaping (String) async throws -> String + ) { + self.name = name + self.description = description + self.returnDirectly = returnDirectly + self.run = run + } + + public func run(input: String) async throws -> String { + try await run(input) + } +} + diff --git a/Tool/Sources/LangChain/Agents/ChatAgent.swift b/Tool/Sources/LangChain/Agents/ChatAgent.swift new file mode 100644 index 00000000..48a04869 --- /dev/null +++ b/Tool/Sources/LangChain/Agents/ChatAgent.swift @@ -0,0 +1,170 @@ +import Foundation +import Logger +import Parsing + +private func formatInstruction(toolsNames: String) -> String { + """ + The way you use the tools is by specifying a json blob. + Specifically, this json should have a `action` key (with the name of the tool to use) and a `action_input` key (with the input to the tool going here). + + The only values that should be in the "action" field are: \(toolsNames) + + The $JSON_BLOB should only contain a SINGLE action, do NOT return a list of multiple actions. Here is an example of a valid $JSON_BLOB: + + ``` + { + "action": $TOOL_NAME, + "action_input": $INPUT + } + ``` + + ALWAYS use the following format: + + Question: the input question you must answer + Thought: you should always think about what to do + Action: + ``` + $JSON_BLOB + ``` + Observation: the result of the action + ... (this Thought/Action/Observation can repeat N times) + Thought: I now know the final answer + Final Answer: the final answer to the original input question + """ +} + +public class ChatAgent: Agent { + public typealias Input = String + public var observationPrefix: String { "Observation: " } + public var llmPrefix: String { "Thought: " } + public let chatModelChain: ChatModelChain> + let tools: [AgentTool] + + public init(chatModel: ChatModel, tools: [AgentTool]) { + self.tools = tools + chatModelChain = .init( + chatModel: chatModel, + stops: ["Observation:"], + promptTemplate: { agentInput in + [ + .init( + role: .system, + content: """ + Respond to the human as helpfully and accurately as possible. \ + Wrap any code block in thought in . \ + Format final answer to be more readable, in a ordered list if possible. \ + You have access to the following tools: + + \(tools.map { "\($0.name): \($0.description)" }.joined(separator: "\n")) + + \(formatInstruction(toolsNames: tools.map(\.name).joined(separator: ","))) + + Begin! Reminder to always use the exact characters `Final Answer` when responding. + """ + ), + agentInput.thoughts.isEmpty + ? .init(role: .user, content: agentInput.input) + : .init( + role: .user, + content: """ + \(agentInput.input) + + \({ + switch agentInput.thoughts { + case let .text(text): + return text + case let .messages(messages): + return messages.map { message in + """ + \(message) + """ + }.joined(separator: "\n") + } + }()) + """ + ), + ] + } + ) + } + + public func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad { + let baseScratchpad = constructBaseScratchpad(intermediateSteps: intermediateSteps) + if baseScratchpad.isEmpty { return .text("") } + return .text(""" + This was your previous work (but I haven't seen any of it! I only see what you return as final answer): + \(baseScratchpad) + (Please continue with `Thought:`) + """) + } + + public func validateTools(tools: [AgentTool]) throws { + // no validation + } + + public func parseOutput(_ text: String) -> AgentNextStep { + func parseFinalAnswerIfPossible() -> AgentNextStep? { + let throughAnswerParser = PrefixThrough("Final Answer:") + var parsableContent = text[...] + do { + _ = try throughAnswerParser.parse(&parsableContent) + let answer = String(parsableContent) + let output = answer.trimmingCharacters(in: .whitespacesAndNewlines) + return .finish(AgentFinish(returnValue: output, log: text)) + } catch { + Logger.langchain.info("Could not parse LLM output final answer: \(error)") + return nil + } + } + + func parseNextActionIfPossible() -> AgentNextStep? { + let throughActionBlockParser = PrefixThrough(""" + Action: + ``` + """) + let throughActionBlockSimplifiedParser = PrefixThrough("```") + let jsonBlobParser = PrefixUpTo("```") + var parsableContent = text[...] + do { + let actionBlockPrefix = try? throughActionBlockParser.parse(&parsableContent) + if actionBlockPrefix == nil { + _ = try throughActionBlockSimplifiedParser.parse(&parsableContent) + } + let jsonBlob = try jsonBlobParser.parse(&parsableContent) + + struct Action: Codable { + let action: String + let action_input: String + } + let response = try JSONDecoder() + .decode(Action.self, from: jsonBlob.data(using: .utf8) ?? Data()) + return .actions([ + AgentAction( + toolName: response.action, + toolInput: response.action_input, + log: text + ), + ]) + } catch { + Logger.langchain.info("Could not parse LLM output next action: \(error)") + return nil + } + } + + if let step = parseFinalAnswerIfPossible() { return step } + if let step = parseNextActionIfPossible() { return step } + + let forceParser = PrefixUpTo("Action:") + var parsableContent = text[...] + let finalAnswer = try? forceParser.parse(&parsableContent) + .trimmingCharacters(in: .whitespacesAndNewlines) + + var answer = finalAnswer ?? text + if answer.isEmpty { + answer = "Sorry, I don't know." + } + + return .finish(AgentFinish(returnValue: String(answer), log: text)) + } +} + diff --git a/Tool/Sources/LangChain/Chain.swift b/Tool/Sources/LangChain/Chain.swift new file mode 100644 index 00000000..1db6ee75 --- /dev/null +++ b/Tool/Sources/LangChain/Chain.swift @@ -0,0 +1,123 @@ +import Foundation + +public protocol Chain { + associatedtype Input + associatedtype Output + func callLogic(_ input: Input, callbackManagers: [ChainCallbackManager]) async throws -> Output + func parseOutput(_ output: Output) -> String +} + +public extension Chain { + func run(_ input: Input, callbackManagers: [ChainCallbackManager] = []) async throws -> String { + let output = try await call(input, callbackManagers: callbackManagers) + return parseOutput(output) + } + + func call(_ input: Input, callbackManagers: [ChainCallbackManager]) async throws -> Output { + for callbackManager in callbackManagers { + callbackManager.onChainStart(type: Self.self, input: input) + } + return try await callLogic(input, callbackManagers: callbackManagers) + } +} + +public struct SimpleChain: Chain { + let block: (Input) async throws -> Output + let parseOutputBlock: (Output) -> String + + public init( + block: @escaping (Input) async throws -> Output, + parseOutput: @escaping (Output) -> String = { String(describing: $0) } + ) { + self.block = block + parseOutputBlock = parseOutput + } + + public func callLogic( + _ input: Input, + callbackManagers: [ChainCallbackManager] + ) async throws -> Output { + return try await block(input) + } + + public func parseOutput(_ output: Output) -> String { + return parseOutputBlock(output) + } +} + +public struct ConnectedChain: Chain where B.Input == A.Output { + public typealias Input = A.Input + public typealias Output = (B.Output, A.Output) + + public let chainA: A + public let chainB: B + + public func callLogic( + _ input: Input, + callbackManagers: [ChainCallbackManager] = [] + ) async throws -> Output { + let a = try await chainA.call(input, callbackManagers: callbackManagers) + let b = try await chainB.call(a, callbackManagers: callbackManagers) + return (b, a) + } + + public func parseOutput(_ output: Output) -> String { + chainB.parseOutput(output.0) + } +} + +public struct PairedChain: Chain { + public typealias Input = (A.Input, B.Input) + public typealias Output = (A.Output, B.Output) + + public let chainA: A + public let chainB: B + + public func callLogic( + _ input: Input, + callbackManagers: [ChainCallbackManager] = [] + ) async throws -> Output { + async let a = chainA.call(input.0, callbackManagers: callbackManagers) + async let b = chainB.call(input.1, callbackManagers: callbackManagers) + return try await (a, b) + } + + public func parseOutput(_ output: (A.Output, B.Output)) -> String { + [chainA.parseOutput(output.0), chainB.parseOutput(output.1)].joined(separator: "\n") + } +} + +public struct MappedChain: Chain { + public typealias Input = A.Input + public typealias Output = NewOutput + + public let chain: A + public let map: (A.Output) -> NewOutput + + public func callLogic( + _ input: Input, + callbackManagers: [ChainCallbackManager] + ) async throws -> Output { + let output = try await chain.call(input, callbackManagers: callbackManagers) + return map(output) + } + + public func parseOutput(_ output: Output) -> String { + String(describing: output) + } +} + +public extension Chain { + func pair(with another: C) -> PairedChain { + PairedChain(chainA: self, chainB: another) + } + + func chain(to another: C) -> ConnectedChain { + ConnectedChain(chainA: self, chainB: another) + } + + func map(_ map: @escaping (Output) -> NewOutput) -> MappedChain { + MappedChain(chain: self, map: map) + } +} + diff --git a/Tool/Sources/LangChain/Chains/LLMChain.swift b/Tool/Sources/LangChain/Chains/LLMChain.swift new file mode 100644 index 00000000..bb81f18c --- /dev/null +++ b/Tool/Sources/LangChain/Chains/LLMChain.swift @@ -0,0 +1,37 @@ +import Foundation + +public class ChatModelChain: Chain { + public typealias Output = String + + var chatModel: ChatModel + var promptTemplate: (Input) -> [ChatMessage] + var stops: [String] + + public init( + chatModel: ChatModel, + stops: [String] = [], + promptTemplate: @escaping (Input) -> [ChatMessage] + ) { + self.chatModel = chatModel + self.promptTemplate = promptTemplate + self.stops = stops + } + + public func callLogic( + _ input: Input, + callbackManagers: [ChainCallbackManager] + ) async throws -> Output { + let prompt = promptTemplate(input) + let output = try await chatModel.generate( + prompt: prompt, + stops: stops, + callbackManagers: callbackManagers + ) + return output + } + + public func parseOutput(_ output: Output) -> String { + output + } +} + diff --git a/Tool/Sources/LangChain/ChatModel/ChatModel.swift b/Tool/Sources/LangChain/ChatModel/ChatModel.swift new file mode 100644 index 00000000..283bfcfc --- /dev/null +++ b/Tool/Sources/LangChain/ChatModel/ChatModel.swift @@ -0,0 +1,25 @@ +import Foundation + +public protocol ChatModel { + func generate( + prompt: [ChatMessage], + stops: [String], + callbackManagers: [ChainCallbackManager] + ) 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 + } +} diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift new file mode 100644 index 00000000..c619d093 --- /dev/null +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -0,0 +1,50 @@ +import Foundation +import OpenAIService + +public struct OpenAIChat: ChatModel { + public var temperature: Double + public var stream: Bool + + public init( + temperature: Double = 0.7, + stream: Bool = false + ) { + self.temperature = temperature + self.stream = stream + } + + public func generate( + prompt: [ChatMessage], + stops: [String], + callbackManagers: [ChainCallbackManager] + ) async throws -> String { + let service = ChatGPTService(temperature: temperature, stop: stops) + await service.mutateHistory { history in + for message in prompt { + let role: OpenAIService.ChatMessage.Role = { + switch message.role { + case .system: + return .system + case .user: + return .user + case .assistant: + return .assistant + } + }() + history.append(.init(role: role, content: message.content)) + } + } + if stream { + let stream = try await service.send(content: "") + var message = "" + for try await trunk in stream { + message.append(trunk) + callbackManagers.forEach { $0.onLLMNewToken(token: trunk) } + } + return message + } else { + return try await service.sendAndWait(content: "") ?? "" + } + } +} + diff --git a/Core/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift similarity index 96% rename from Core/Sources/Logger/Logger.swift rename to Tool/Sources/Logger/Logger.swift index 92344e4d..e78bf3a0 100644 --- a/Core/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -17,6 +17,7 @@ public final class Logger { public static let updateChecker = Logger(category: "UpdateChecker") public static let gitHubCopilot = Logger(category: "GitHubCopilot") public static let codeium = Logger(category: "Codeium") + public static let langchain = Logger(category: "LangChain") #if DEBUG public static let temp = Logger(category: "Temp") #endif diff --git a/Core/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift similarity index 92% rename from Core/Sources/OpenAIService/ChatGPTService.swift rename to Tool/Sources/OpenAIService/ChatGPTService.swift index f970dd2a..909fe3ab 100644 --- a/Core/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -109,6 +109,7 @@ public actor ChatGPTService: ChatGPTServiceType { didSet { objectWillChange.send() } } + var stop: [String] var uuidGenerator: () -> String = { UUID().uuidString } var cancelTask: Cancellable? var buildCompletionStreamAPI: CompletionStreamAPIBuilder = OpenAICompletionStreamAPI.init @@ -117,10 +118,12 @@ public actor ChatGPTService: ChatGPTServiceType { public init( systemPrompt: String = "", temperature: Double? = nil, + stop: [String] = [], designatedProvider: ChatFeatureProvider? = nil ) { self.systemPrompt = systemPrompt self.temperature = temperature + self.stop = stop self.designatedProvider = designatedProvider } @@ -130,13 +133,16 @@ public actor ChatGPTService: ChatGPTServiceType { ) async throws -> AsyncThrowingStream { guard !isReceivingMessage else { throw CancellationError() } guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } - let newMessage = ChatMessage( - id: uuidGenerator(), - role: .user, - content: content, - summary: summary - ) - history.append(newMessage) + + if !content.isEmpty || summary != nil { + let newMessage = ChatMessage( + id: uuidGenerator(), + role: .user, + content: content, + summary: summary + ) + history.append(newMessage) + } let (messages, remainingTokens) = combineHistoryWithSystemPrompt() @@ -145,6 +151,7 @@ public actor ChatGPTService: ChatGPTServiceType { messages: messages, temperature: temperature ?? defaultTemperature, stream: true, + stop: stop.isEmpty ? nil : stop, max_tokens: maxTokenForReply(model: model, remainingTokens: remainingTokens) ) @@ -217,13 +224,16 @@ public actor ChatGPTService: ChatGPTServiceType { ) async throws -> String? { guard !isReceivingMessage else { throw CancellationError() } guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } - let newMessage = ChatMessage( - id: uuidGenerator(), - role: .user, - content: content, - summary: summary - ) - history.append(newMessage) + + if !content.isEmpty || summary != nil { + let newMessage = ChatMessage( + id: uuidGenerator(), + role: .user, + content: content, + summary: summary + ) + history.append(newMessage) + } let (messages, remainingTokens) = combineHistoryWithSystemPrompt() @@ -232,6 +242,7 @@ public actor ChatGPTService: ChatGPTServiceType { messages: messages, temperature: temperature ?? defaultTemperature, stream: true, + stop: stop.isEmpty ? nil : stop, max_tokens: maxTokenForReply(model: model, remainingTokens: remainingTokens) ) @@ -314,7 +325,9 @@ extension ChatGPTService { all.append(.init(role: message.role, content: message.content)) } - all.append(.init(role: .system, content: systemPrompt)) + if !systemPrompt.isEmpty { + all.append(.init(role: .system, content: systemPrompt)) + } return (all.reversed(), max(minimumReplyTokens, maxTokens - allTokensCount)) } } diff --git a/Core/Sources/OpenAIService/CompletionAPI.swift b/Tool/Sources/OpenAIService/CompletionAPI.swift similarity index 100% rename from Core/Sources/OpenAIService/CompletionAPI.swift rename to Tool/Sources/OpenAIService/CompletionAPI.swift diff --git a/Core/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift similarity index 100% rename from Core/Sources/OpenAIService/CompletionStreamAPI.swift rename to Tool/Sources/OpenAIService/CompletionStreamAPI.swift diff --git a/Core/Sources/OpenAIService/Models.swift b/Tool/Sources/OpenAIService/Models.swift similarity index 100% rename from Core/Sources/OpenAIService/Models.swift rename to Tool/Sources/OpenAIService/Models.swift diff --git a/Core/Sources/Preferences/AppStorage.swift b/Tool/Sources/Preferences/AppStorage.swift similarity index 100% rename from Core/Sources/Preferences/AppStorage.swift rename to Tool/Sources/Preferences/AppStorage.swift diff --git a/Core/Sources/Preferences/ChatFeatureProvider.swift b/Tool/Sources/Preferences/ChatFeatureProvider.swift similarity index 100% rename from Core/Sources/Preferences/ChatFeatureProvider.swift rename to Tool/Sources/Preferences/ChatFeatureProvider.swift diff --git a/Core/Sources/Preferences/ChatGPTModel.swift b/Tool/Sources/Preferences/ChatGPTModel.swift similarity index 100% rename from Core/Sources/Preferences/ChatGPTModel.swift rename to Tool/Sources/Preferences/ChatGPTModel.swift diff --git a/Core/Sources/Preferences/CustomCommand.swift b/Tool/Sources/Preferences/CustomCommand.swift similarity index 100% rename from Core/Sources/Preferences/CustomCommand.swift rename to Tool/Sources/Preferences/CustomCommand.swift diff --git a/Core/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift similarity index 83% rename from Core/Sources/Preferences/Keys.swift rename to Tool/Sources/Preferences/Keys.swift index d0a916ac..1fc39649 100644 --- a/Core/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -105,11 +105,11 @@ public extension UserDefaultPreferenceKeys { var azureOpenAIAPIKey: PreferenceKey { .init(defaultValue: "", key: "AzureOpenAIAPIKey") } - + var azureOpenAIBaseURL: PreferenceKey { .init(defaultValue: "", key: "AzureOpenAIBaseURL") } - + var azureChatGPTDeployment: PreferenceKey { .init(defaultValue: "", key: "AzureChatGPTDeployment") } @@ -205,7 +205,7 @@ public extension UserDefaultPreferenceKeys { var chatFeatureProvider: PreferenceKey { .init(defaultValue: .openAI, key: "ChatFeatureProvider") } - + var chatFontSize: PreferenceKey { .init(defaultValue: 12, key: "ChatFontSize") } @@ -225,10 +225,42 @@ public extension UserDefaultPreferenceKeys { var maxEmbeddableFileInChatContextLineCount: PreferenceKey { .init(defaultValue: 100, key: "MaxEmbeddableFileInChatContextLineCount") } - + var useSelectionScopeByDefaultInChatContext: PreferenceKey { .init(defaultValue: true, key: "UseSelectionScopeByDefaultInChatContext") } + + var defaultChatSystemPrompt: PreferenceKey { + .init( + defaultValue: """ + You are an AI programming assistant. + Your reply should be concise, clear, informative and logical. + You MUST reply in the format of markdown. + 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. + Make your answer short and structured. + """, + key: "DefaultChatSystemPrompt" + ) + } + + var chatSearchPluginMaxIterations: PreferenceKey { + .init(defaultValue: 3, key: "ChatSearchPluginMaxIterations") + } +} + +// MARK: - Bing Search + +public extension UserDefaultPreferenceKeys { + var bingSearchSubscriptionKey: PreferenceKey { + .init(defaultValue: "", key: "BingSearchSubscriptionKey") + } + + var bingSearchEndpoint: PreferenceKey { + .init(defaultValue: "https://api.bing.microsoft.com/v7.0/search/", key: "BingSearchEndpoint") + } } // MARK: - Custom Commands @@ -255,6 +287,19 @@ public extension UserDefaultPreferenceKeys { generateDescription: true ) ), + .init( + commandId: "BuiltInCustomCommandSendCodeToChat", + name: "Send Selected Code to Chat", + feature: .chatWithSelection( + extraSystemPrompt: "", + prompt: """ + ```{{active_editor_language}} + {{selected_code}} + ``` + """, + useExtraSystemPrompt: true + ) + ), ], key: "CustomCommands") } } diff --git a/Core/Sources/Preferences/Locale.swift b/Tool/Sources/Preferences/Locale.swift similarity index 100% rename from Core/Sources/Preferences/Locale.swift rename to Tool/Sources/Preferences/Locale.swift diff --git a/Core/Sources/Preferences/NodeRunner.swift b/Tool/Sources/Preferences/NodeRunner.swift similarity index 100% rename from Core/Sources/Preferences/NodeRunner.swift rename to Tool/Sources/Preferences/NodeRunner.swift diff --git a/Core/Sources/Preferences/PresentationMode.swift b/Tool/Sources/Preferences/PresentationMode.swift similarity index 100% rename from Core/Sources/Preferences/PresentationMode.swift rename to Tool/Sources/Preferences/PresentationMode.swift diff --git a/Core/Sources/Preferences/PromptToCodeFeatureProvider.swift b/Tool/Sources/Preferences/PromptToCodeFeatureProvider.swift similarity index 100% rename from Core/Sources/Preferences/PromptToCodeFeatureProvider.swift rename to Tool/Sources/Preferences/PromptToCodeFeatureProvider.swift diff --git a/Core/Sources/Preferences/SuggestionFeatureProvider.swift b/Tool/Sources/Preferences/SuggestionFeatureProvider.swift similarity index 100% rename from Core/Sources/Preferences/SuggestionFeatureProvider.swift rename to Tool/Sources/Preferences/SuggestionFeatureProvider.swift diff --git a/Core/Sources/Preferences/SuggestionWidgetPositionMode.swift b/Tool/Sources/Preferences/SuggestionWidgetPositionMode.swift similarity index 100% rename from Core/Sources/Preferences/SuggestionWidgetPositionMode.swift rename to Tool/Sources/Preferences/SuggestionWidgetPositionMode.swift diff --git a/Core/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift similarity index 100% rename from Core/Sources/Preferences/UserDefaults.swift rename to Tool/Sources/Preferences/UserDefaults.swift diff --git a/Core/Sources/Preferences/WidgetColorScheme.swift b/Tool/Sources/Preferences/WidgetColorScheme.swift similarity index 100% rename from Core/Sources/Preferences/WidgetColorScheme.swift rename to Tool/Sources/Preferences/WidgetColorScheme.swift diff --git a/Tool/Sources/PythonHelper/PythonThread.swift b/Tool/Sources/PythonHelper/PythonThread.swift new file mode 100644 index 00000000..fe9ea677 --- /dev/null +++ b/Tool/Sources/PythonHelper/PythonThread.swift @@ -0,0 +1,90 @@ +import Foundation +import PythonKit + +final class PythonThread: Thread { + static let shared = { + let thread = PythonThread( + target: PythonThread.self, + selector: #selector(PythonThread.setup), + object: nil + ) + thread.name = "Python Thread" + thread.stackSize = 1_048_576 // so that langchain can be correctly imported. + return thread + }() + + @objc static func setup() { + CFRunLoopRun() + } + + @objc static func runPythonJob(_ job: PythonJob) { + job.run() + } + + func runPython(_ closure: @escaping () -> Void) { + if !isExecuting { + start() + } + + if Thread.current.isEqual(self) { + closure() + } else { + PythonThread.perform( + #selector(PythonThread.runPythonJob), + on: self, + with: PythonJob(closure: closure), + waitUntilDone: false + ) + } + } + + func runPythonAndWait(_ closure: @escaping () throws -> T) throws -> T { + if !isExecuting { + start() + } + + if Thread.current.isEqual(self) { + return try closure() + } else { + let job = PythonJob(closure: closure) + PythonThread.perform( + #selector(PythonThread.runPythonJob), + on: self, + with: job, + waitUntilDone: true + ) + guard let result = job.result else { + throw FailedToGetPythonJobResultError() + } + switch result { + case let .success(value): + if let value = value as? T { + return value + } else { + throw FailedToGetPythonJobResultError() + } + case let .failure(error): + throw error + } + } + } +} + +struct FailedToGetPythonJobResultError: Error, LocalizedError { + var errorDescription: String? { + "Failed to get PythonJob result." + } +} + +final class PythonJob: NSObject { + let closure: () throws -> Any + var result: Result? + init(closure: @escaping () throws -> Any) { + self.closure = closure + } + + func run() { + result = Result(catching: closure) + } +} + diff --git a/Tool/Sources/PythonHelper/RunPython.swift b/Tool/Sources/PythonHelper/RunPython.swift new file mode 100644 index 00000000..35b62680 --- /dev/null +++ b/Tool/Sources/PythonHelper/RunPython.swift @@ -0,0 +1,88 @@ +import Foundation +import PythonKit + +var gilStateEnsure: (() -> Any)! +var gilStateRelease: ((Any) -> Void)! +func gilStateGuard(_ closure: @escaping () throws -> T) throws -> T { + let state = gilStateEnsure() + defer { gilStateRelease(state) } + do { + let result = try closure() + return result + } catch { + throw error + } +} + +@MainActor +var isPythonInitialized = false +@MainActor +public func initializePython( + sitePackagePath: String, + stdLibPath: String, + libDynloadPath: String, + Py_Initialize: () -> Void, + PyEval_SaveThread: () -> ThreadState, + PyGILState_Ensure: @escaping () -> GilState, + PyGILState_Release: @escaping (GilState) -> Void +) { + guard !isPythonInitialized else { return } + setenv("PYTHONHOME", stdLibPath, 1) + setenv("PYTHONPATH", "\(stdLibPath):\(libDynloadPath):\(sitePackagePath)", 1) + setenv("PYTHONIOENCODING", "utf-8", 1) + isPythonInitialized = true + // Initialize python + Py_Initialize() +// // Immediately release the thread, so that we can ensure the GIL state later. +// // We may not recover the thread because all future tasks will be done in the other threads. +// _ = PyEval_SaveThread() +// // Setup GIL state guard. +// gilStateEnsure = { PyGILState_Ensure() } +// gilStateRelease = { gilState in PyGILState_Release(gilState as! GilState) } +} + +public func runPython( + usePythonThread: Bool = false, + _ closure: @escaping () throws -> T +) throws -> T { + if usePythonThread { + return try PythonThread.shared.runPythonAndWait { +// return try gilStateGuard { + return try closure() +// } + } + } else { +// return try gilStateGuard { + return try closure() +// } + } +} + +public extension PythonInterface { + func attemptImportOnPythonThread(_ name: String) throws -> PythonObject { + try PythonThread.shared.runPythonAndWait { + let module = try Python.attemptImport(name) + return module + } + } +} + +public struct ReadablePythonError: Error, LocalizedError { + public var error: PythonError + + public init(_ error: PythonError) { + self.error = error + } + + public var errorDescription: String? { + switch error { + case let .exception(object, _): + return "\(object)" + case let .invalidCall(object): + return "Invalid call: \(object)" + case let .invalidModule(module): + return "Invalid module: \(module)" + } + } +} + diff --git a/Core/Sources/Terminal/Terminal.swift b/Tool/Sources/Terminal/Terminal.swift similarity index 100% rename from Core/Sources/Terminal/Terminal.swift rename to Tool/Sources/Terminal/Terminal.swift diff --git a/Tool/Tests/LangChainTests/ChatAgentTests.swift b/Tool/Tests/LangChainTests/ChatAgentTests.swift new file mode 100644 index 00000000..88c8d22e --- /dev/null +++ b/Tool/Tests/LangChainTests/ChatAgentTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import LangChain + +private struct FakeChatModel: ChatModel { + func generate( + prompt: [LangChain.ChatMessage], + stops: [String], + callbackManagers: [LangChain.ChainCallbackManager] + ) async throws -> String { + return "New Message" + } +} + +final class ChatAgentParseOutputTests: XCTestCase { + func test_parsing_well_formatted_final_answer() throws { + let finalAnswer = """ + Final Answer: The answer is 42. + Because 42 is the answer to everything. + """ + + let agent = ChatAgent(chatModel: FakeChatModel(), tools: []) + let result = agent.parseOutput(finalAnswer) + XCTAssertEqual(result, .finish(.init( + returnValue: """ + The answer is 42. + Because 42 is the answer to everything. + """, + log: finalAnswer + ))) + } + + func test_parsing_final_answer_with_random_prefix() throws { + let finalAnswer = """ + Now I have the final answer. + Final Answer: The answer is 42. + Because 42 is the answer to everything. + """ + + let agent = ChatAgent(chatModel: FakeChatModel(), tools: []) + let result = agent.parseOutput(finalAnswer) + XCTAssertEqual(result, .finish(.init( + returnValue: """ + The answer is 42. + Because 42 is the answer to everything. + """, + log: finalAnswer + ))) + } + + func test_parsing_action() throws { + let reply = """ + Question: How to setup langchain python? + Thought: I am not familiar with langchain python, I should use the Search tool to find more information on how to set it up. + Action: + ``` + { + "action": "Search", + "action_input": "how to setup langchain python" + } + ``` + """ + + let agent = ChatAgent(chatModel: FakeChatModel(), tools: []) + let result = agent.parseOutput(reply) + XCTAssertEqual(result, .actions([ + .init( + toolName: "Search", + toolInput: "how to setup langchain python", + log: reply + ) + ])) + } + + func test_parsing_broken_action_and_return_everything_ahead_of_it() { + let reply = """ + Question: How to setup langchain python? + Thought: I am not familiar with langchain python, I should use the Search tool to find more information on how to set it up. + Action: + ``` + lkjaskdjalksjdlkasjdklj + ``` + """ + + let agent = ChatAgent(chatModel: FakeChatModel(), tools: []) + let result = agent.parseOutput(reply) + XCTAssertEqual(result, .finish(.init( + returnValue: """ + Question: How to setup langchain python? + Thought: I am not familiar with langchain python, I should use the Search tool to find more information on how to set it up. + """, + log: reply + ))) + } + + func test_parsing_simple_reply_that_does_not_follow_the_format() { + let reply = """ + The answer is 42. + Because 42 is the answer to everything. + """ + + let agent = ChatAgent(chatModel: FakeChatModel(), tools: []) + let result = agent.parseOutput(reply) + XCTAssertEqual(result, .finish(.init( + returnValue: reply, + log: reply + ))) + } +} + diff --git a/Core/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift similarity index 100% rename from Core/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift rename to Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift diff --git a/Core/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift similarity index 96% rename from Core/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift rename to Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift index b976290a..210f6cea 100644 --- a/Core/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift @@ -45,6 +45,7 @@ final class ChatGPTServiceTests: XCTestCase { requestBody = _requestBody return MockCompletionStreamAPI_Success() } + await service.mutateSystemPrompt("system") let stream = try await service.send(content: "Hello") var all = [String]() for try await text in stream { @@ -58,7 +59,7 @@ final class ChatGPTServiceTests: XCTestCase { } XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: ""), + .init(role: .system, content: "system"), .init(role: .user, content: "Hello"), ], "System prompt is included") XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is correct") diff --git a/Core/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift similarity index 100% rename from Core/Tests/OpenAIServiceTests/LimitMessagesTests.swift rename to Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift diff --git a/VERSIONS b/VERSIONS new file mode 100644 index 00000000..505965bf --- /dev/null +++ b/VERSIONS @@ -0,0 +1,8 @@ +Python version: 3.11.0 +Build: b1 +Min macOS version: 10.15 +--------------------- +libFFI: macOS native +BZip2: 1.0.8 +OpenSSL: 3.0.5 +XZ: 5.2.6 diff --git a/Version.xcconfig b/Version.xcconfig index f45a2716..bfae9a50 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,2 @@ -APP_VERSION = 0.17.1 -APP_BUILD = 171 +APP_VERSION = 0.18.0 +APP_BUILD = 180 diff --git a/appcast.xml b/appcast.xml index 41bb3495..fb6c7c74 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.18.0 + Thu, 08 Jun 2023 14:34:14 +0800 + 180 + 0.18.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.18.0 + + + + 0.17.1 Wed, 31 May 2023 12:30:21 +0800