diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 3d0f22db..4430a70a 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -39,7 +39,6 @@ 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 */; }; C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */; }; - 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 */ @@ -151,6 +150,8 @@ C8216B72298036EC00AD38C7 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadLaunchAgent.swift; sourceTree = ""; }; C82E38492A1F025F00D4EADF /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; + C83E3F3E2A38C66D0071506D /* Python */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Python; sourceTree = ""; }; + C83E5DED2A38CD8C0071506D /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; sourceTree = ""; }; C8520300293C4D9000460097 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; C8520308293D805800460097 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptToCodeCommand.swift; sourceTree = ""; }; @@ -168,10 +169,6 @@ 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 = ""; }; C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; 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 */ @@ -252,17 +249,18 @@ C887BC832965D96000931567 /* DEVELOPMENT.md */, C8520308293D805800460097 /* README.md */, C82E38492A1F025F00D4EADF /* LICENSE */, + C83E5DED2A38CD8C0071506D /* Makefile */, C81E867D296FE4420026E908 /* Version.xcconfig */, C81458AD293A009600135263 /* Config.xcconfig */, C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, + C83E3F3E2A38C66D0071506D /* Python */, C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, C8189B182938972F00C9DCDA /* Copilot for Xcode */, C81458922939EFDC00135263 /* EditorExtension */, C8216B71298036EC00AD38C7 /* Helper */, C861E60F2994F6070056CB02 /* ExtensionService */, - C81BBF5A2A2CA0B8000B4F61 /* Python */, C814588D2939EFDC00135263 /* Frameworks */, C8189B172938972F00C9DCDA /* Products */, ); @@ -299,16 +297,6 @@ 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 = ( @@ -325,7 +313,6 @@ C861E61F2994F6390056CB02 /* ServiceDelegate.swift */, C861E6102994F6070056CB02 /* AppDelegate.swift */, C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */, - C8A3AE582A2885A70046E809 /* InitializePython.swift */, C81291D52994FE6900196E12 /* Main.storyboard */, C861E6142994F6080056CB02 /* Assets.xcassets */, C861E6192994F6080056CB02 /* ExtensionService.entitlements */, @@ -460,6 +447,7 @@ mainGroup = C8189B0D2938972F00C9DCDA; packageReferences = ( C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */, + C80C91742A588DD800B5EADA /* XCRemoteSwiftPackageReference "usearch" */, ); productRefGroup = C8189B172938972F00C9DCDA /* Products */; projectDirPath = ""; @@ -505,7 +493,7 @@ C8A3AE572A28852D0046E809 /* Sign Python STD */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; - buildActionMask = 2147483647; + buildActionMask = 8; files = ( ); inputFileListPaths = ( @@ -517,14 +505,14 @@ ); outputPaths = ( ); - runOnlyForDeploymentPostprocessing = 0; + runOnlyForDeploymentPostprocessing = 1; 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; + buildActionMask = 8; files = ( ); inputFileListPaths = ( @@ -536,7 +524,7 @@ ); outputPaths = ( ); - runOnlyForDeploymentPostprocessing = 0; + runOnlyForDeploymentPostprocessing = 1; 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"; }; @@ -585,7 +573,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C8A3AE592A2885A70046E809 /* InitializePython.swift in Sources */, C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */, C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */, C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */, @@ -986,6 +973,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + C80C91742A588DD800B5EADA /* XCRemoteSwiftPackageReference "usearch" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/unum-cloud/usearch"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.19.1; + }; + }; C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-argument-parser.git"; 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 9121e5f3..6cfaff01 100644 --- a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c", + "version" : "0.10.0" + } + }, { "identity" : "fseventswrapper", "kind" : "remoteSourceControl", @@ -18,15 +27,6 @@ "version" : "1.0.5" } }, - { - "identity" : "gptencoder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/alfianlosari/GPTEncoder", - "state" : { - "revision" : "a86968867ab4380e36b904a14c42215f71efe8b4", - "version" : "1.0.4" - } - }, { "identity" : "highlightr", "kind" : "remoteSourceControl", @@ -90,15 +90,6 @@ "version" : "0.3.1" } }, - { - "identity" : "pythonkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pvieito/PythonKit.git", - "state" : { - "branch" : "master", - "revision" : "060e1c8b0d14e4d241b3623fdbe83d0e3c81a993" - } - }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -144,6 +135,15 @@ "version" : "0.14.1" } }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d", + "version" : "0.3.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -153,6 +153,42 @@ "version" : "1.0.4" } }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb", + "version" : "0.55.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc", + "version" : "0.11.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb", + "version" : "0.5.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", + "version" : "0.8.0" + } + }, { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", @@ -171,6 +207,42 @@ "version" : "0.12.1" } }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "0e96a20ffd37a515c5c963952d4335c89bed50a6", + "version" : "2.6.0" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12", + "version" : "0.8.0" + } + }, + { + "identity" : "tiktoken", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/Tiktoken", + "state" : { + "branch" : "main", + "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea" + } + }, + { + "identity" : "usearch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/unum-cloud/usearch", + "state" : { + "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449", + "version" : "0.19.3" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme index d0b8dc83..d9c4e45f 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme @@ -20,6 +20,20 @@ ReferencedContainer = "container:Copilot for Xcode.xcodeproj"> + + + + + + + + + + diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..1ab5e470 --- /dev/null +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,257 @@ +{ + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c", + "version" : "0.10.0" + } + }, + { + "identity" : "fseventswrapper", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Frizlab/FSEventsWrapper", + "state" : { + "revision" : "e0c59a2ce2775e5f6642da6d19207445f10112d0", + "version" : "1.0.2" + } + }, + { + "identity" : "glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Bouke/Glob", + "state" : { + "revision" : "deda6e163d2ff2a8d7e138e2c3326dbd71157faf", + "version" : "1.0.5" + } + }, + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr", + "state" : { + "revision" : "93199b9e434f04bda956a613af8f571933f9f037", + "version" : "2.1.2" + } + }, + { + "identity" : "jsonrpc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/JSONRPC", + "state" : { + "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3", + "version" : "0.6.0" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "languageclient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageClient", + "state" : { + "revision" : "f0198ee0a102d266078f7d9c28f086f2989f988a", + "version" : "0.3.1" + } + }, + { + "identity" : "languageserverprotocol", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", + "state" : { + "revision" : "6e97f943dc024307c5524a80bd33cdbd1cc621de", + "version" : "0.8.0" + } + }, + { + "identity" : "operationplus", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/OperationPlus", + "state" : { + "revision" : "1340f95dce3e93d742497d88db18f8676f4badf4", + "version" : "1.6.0" + } + }, + { + "identity" : "processenv", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/ProcessEnv", + "state" : { + "revision" : "29487b6581bb785c372c611c943541ef4309d051", + "version" : "0.3.1" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "87e4fcbac39912f9cdb9a9acf205cad60e1ca3bc", + "version" : "2.4.2" + } + }, + { + "identity" : "splash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Splash", + "state" : { + "branch" : "master", + "revision" : "2e3f17c2d09689c8bf175c4a84ff7f2ad3353301" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.2" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a", + "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-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d", + "version" : "0.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb", + "version" : "0.55.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc", + "version" : "0.11.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb", + "version" : "0.5.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "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" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", + "version" : "2.6.1" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12", + "version" : "0.8.0" + } + }, + { + "identity" : "tiktoken", + "kind" : "remoteSourceControl", + "location" : "https://github.com/intitni/Tiktoken", + "state" : { + "branch" : "main", + "revision" : "6a2ac324c6daec167ca95268d5a487e6de6a1cea" + } + }, + { + "identity" : "usearch", + "kind" : "remoteSourceControl", + "location" : "https://github.com/unum-cloud/usearch", + "state" : { + "revision" : "f2ab884d50902c3ad63f07a3a20bc34008a17449", + "version" : "0.19.3" + } + }, + { + "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/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme new file mode 100644 index 00000000..b5513aeb --- /dev/null +++ b/Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Core/Package.swift b/Core/Package.swift index 21320904..2595833c 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -42,15 +42,20 @@ let package = Package( ], dependencies: [ .package(path: "../Tool"), - .package(url: "https://github.com/ChimeHQ/LanguageClient", from: "0.3.1"), + // TODO: Update LanguageClient some day. + .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"), + .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.8.0"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), .package(url: "https://github.com/raspu/Highlightr", from: "2.1.0"), .package(url: "https://github.com/JohnSundell/Splash", branch: "master"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.2"), - .package(url: "https://github.com/pvieito/PythonKit.git", branch: "master"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture", + from: "0.55.0" + ), ], targets: [ // MARK: - Main @@ -80,14 +85,15 @@ let package = Package( "SuggestionWidget", "AXExtension", "ChatService", - .product(name: "Logger", package: "Tool"), "PromptToCodeService", "ServiceUpdateMigration", "UserDefaultsObserver", + "ChatTab", + .product(name: "Logger", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - .product(name: "PythonKit", package: "PythonKit"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget( @@ -99,6 +105,7 @@ let package = Package( "SuggestionInjector", "XPCShared", "Environment", + "SuggestionModel", .product(name: "Preferences", package: "Tool"), ] ), @@ -121,8 +128,10 @@ let package = Package( "CodeiumService", "SuggestionModel", "LaunchAgentManager", + .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), @@ -185,17 +194,21 @@ let package = Package( "SearchChatPlugin", "ShortcutChatPlugin", + // context collectors + "WebChatContextCollector", + + .product(name: "Parsing", package: "swift-parsing"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), ] ), + .testTarget(name: "ChatServiceTests", dependencies: ["ChatService"]), .target( name: "ChatPlugin", dependencies: [ "Environment", .product(name: "OpenAIService", package: "Tool"), .product(name: "Terminal", package: "Tool"), - .product(name: "PythonKit", package: "PythonKit"), ] ), .target( @@ -209,33 +222,65 @@ let package = Package( ] ), + .target( + name: "ChatTab", + dependencies: [ + "SharedUIComponents", + "ChatService", + .product(name: "OpenAIService", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + ] + ), + // MARK: - UI + .target( + name: "SharedUIComponents", + dependencies: [ + "Highlightr", + "Splash", + .product(name: "Preferences", package: "Tool"), + ] + ), + .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), + .target( name: "SuggestionWidget", dependencies: [ + "ChatTab", "ActiveApplicationMonitor", "AXNotificationStream", "Environment", - "Highlightr", - "Splash", "UserDefaultsObserver", "XcodeInspector", + "SharedUIComponents", .product(name: "Logger", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget(name: "SuggestionWidgetTests", dependencies: ["SuggestionWidget"]), // MARK: - Helpers - .target(name: "CGEventObserver"), + .target( + name: "CGEventObserver", + dependencies: [ + .product(name: "Logger", package: "Tool"), + ] + ), .target(name: "FileChangeChecker"), .target(name: "LaunchAgentManager"), .target(name: "DisplayLink"), .target(name: "ActiveApplicationMonitor"), - .target(name: "AXNotificationStream"), + .target( + name: "AXNotificationStream", + dependencies: [ + .product(name: "Logger", package: "Tool"), + ] + ), .target( name: "UpdateChecker", dependencies: [ @@ -275,6 +320,7 @@ let package = Package( .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Terminal", package: "Tool"), + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), ] ), .testTarget( @@ -304,7 +350,6 @@ let package = Package( "ChatPlugin", .product(name: "OpenAIService", package: "Tool"), .product(name: "LangChain", package: "Tool"), - .product(name: "PythonKit", package: "PythonKit"), ], path: "Sources/ChatPlugins/MathChatPlugin" ), @@ -315,7 +360,7 @@ let package = Package( "ChatPlugin", .product(name: "OpenAIService", package: "Tool"), .product(name: "LangChain", package: "Tool"), - .product(name: "PythonKit", package: "PythonKit"), + .product(name: "ExternalServices", package: "Tool"), ], path: "Sources/ChatPlugins/SearchChatPlugin" ), @@ -329,6 +374,20 @@ let package = Package( ], path: "Sources/ChatPlugins/ShortcutChatPlugin" ), + + // MAKR: - Chat Context Collector + + .target( + name: "WebChatContextCollector", + dependencies: [ + "ChatContextCollector", + .product(name: "LangChain", package: "Tool"), + .product(name: "OpenAIService", package: "Tool"), + .product(name: "ExternalServices", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + ], + path: "Sources/ChatContextCollectors/WebChatContextCollector" + ), ] ) diff --git a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift index 5a3cf855..f8c33dda 100644 --- a/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -1,4 +1,5 @@ import Foundation +import OpenAIService import Preferences import SuggestionModel import XcodeInspector @@ -6,13 +7,17 @@ import XcodeInspector public struct ActiveDocumentChatContextCollector: ChatContextCollector { public init() {} - public func generateSystemPrompt(history: [String], content prompt: String) -> String { + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String + ) -> ChatContext? { let content = getEditorInformation() let relativePath = content.documentURL.path .replacingOccurrences(of: content.projectURL.path, with: "") let selectionRange = content.editorContent?.selections.first ?? .outOfScope let editorContent = { - if prompt.hasPrefix("@file") { + if scopes.contains("file") { return """ File Content:```\(content.language.rawValue) \(content.editorContent?.content ?? "") @@ -52,7 +57,7 @@ public struct ActiveDocumentChatContextCollector: ChatContextCollector { """ } - if prompt.hasPrefix("@selection") { + if scopes.contains("selection") { return """ Selected Code \ (start from line \(selectionRange.start.line)):```\(content.language.rawValue) @@ -71,27 +76,30 @@ public struct ActiveDocumentChatContextCollector: ChatContextCollector { """ }() - return """ - Active Document Context:### - Document Relative Path: \(relativePath) - Selection Range Start: \ - Line \(selectionRange.start.line) \ - Character \(selectionRange.start.character) - Selection Range End: \ - Line \(selectionRange.end.line) \ - Character \(selectionRange.end.character) - Cursor Position: \ - Line \(selectionRange.end.line) \ - Character \(selectionRange.end.character) - \(editorContent) - Line Annotations: - \( - content.editorContent?.lineAnnotations - .map { " - \($0)" } - .joined(separator: "\n") ?? "N/A" + return .init( + systemPrompt: """ + Active Document Context:### + Document Relative Path: \(relativePath) + Selection Range Start: \ + Line \(selectionRange.start.line) \ + Character \(selectionRange.start.character) + Selection Range End: \ + Line \(selectionRange.end.line) \ + Character \(selectionRange.end.character) + Cursor Position: \ + Line \(selectionRange.end.line) \ + Character \(selectionRange.end.character) + \(editorContent) + Line Annotations: + \( + content.editorContent?.lineAnnotations + .map { " - \($0)" } + .joined(separator: "\n") ?? "N/A" + ) + ### + """, + functions: [] ) - ### - """ } } diff --git a/Core/Sources/ChatContextCollector/ChatContextCollector.swift b/Core/Sources/ChatContextCollector/ChatContextCollector.swift index 450d7ce4..dd4ad75a 100644 --- a/Core/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Core/Sources/ChatContextCollector/ChatContextCollector.swift @@ -1,5 +1,20 @@ import Foundation +import OpenAIService + +public struct ChatContext { + public var systemPrompt: String + public var functions: [any ChatGPTFunction] + public init(systemPrompt: String, functions: [any ChatGPTFunction]) { + self.systemPrompt = systemPrompt + self.functions = functions + } +} public protocol ChatContextCollector { - func generateSystemPrompt(history: [String], content: String) -> String + func generateContext( + history: [ChatMessage], + scopes: Set, + content: String + ) -> ChatContext? } + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift new file mode 100644 index 00000000..611cd1c0 --- /dev/null +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -0,0 +1,127 @@ +import Foundation +import LangChain +import OpenAIService +import Preferences + +struct QueryWebsiteFunction: ChatGPTFunction { + struct Arguments: Codable { + var query: String + var urls: [String] + } + + struct Result: ChatGPTFunctionResult { + var answers: [String] + + var botReadableContent: String { + return answers.joined(separator: "\n") + } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "queryWebsite" + } + + var description: String { + "Useful for when you need to answer a question using information from a website." + } + + var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: [ + "query": [ + .type: "string", + .description: "things you want to know about the website", + ], + "urls": [ + .type: "array", + .description: "urls of the website, you can use urls appearing in the conversation", + .items: [ + .type: "string", + ], + ], + ], + .required: ["query", "urls"], + ] + } + + func prepare() async { + await reportProgress("Reading..") + } + + func call(arguments: Arguments) async throws -> Result { + do { + let embedding = OpenAIEmbedding(configuration: UserPreferenceEmbeddingConfiguration()) + + let result = try await withThrowingTaskGroup(of: String.self) { group in + for urlString in arguments.urls { + guard let url = URL(string: urlString) else { continue } + group.addTask { + // 1. grab the website content + await reportProgress("Loading \(url)..") + + if let database = await TemporaryUSearch.view(identifier: urlString) { + await reportProgress("Generating answers..") + let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) { + OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: 0)), + stream: true + ) + } + return try await qa.call(.init(arguments.query)).answer + } + let loader = WebLoader(urls: [url]) + let documents = try await loader.load() + await reportProgress("Processing \(url)..") + // 2. split the content + let splitter = RecursiveCharacterTextSplitter( + chunkSize: 1000, + chunkOverlap: 100 + ) + let splitDocuments = try await splitter.transformDocuments(documents) + // 3. embedding and store in db + await reportProgress("Embedding \(url)..") + let embeddedDocuments = try await embedding.embed(documents: splitDocuments) + let database = TemporaryUSearch(identifier: urlString) + try await database.set(embeddedDocuments) + // 4. generate answer + await reportProgress("Generating answers..") + let qa = RetrievalQAChain(vectorStore: database, embedding: embedding) { + OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: 0)), + stream: true + ) + } + let result = try await qa.call(.init(arguments.query)) + return result.answer + } + } + + var all = [String]() + for try await result in group { + all.append(result) + } + await reportProgress(""" + Finish reading websites. + \( + arguments.urls + .map { "- [\($0)](\($0))" } + .joined(separator: "\n") + ) + """) + + return all + } + + return .init(answers: result) + } catch { + await reportProgress("Failed reading websites.") + throw error + } + } +} + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift new file mode 100644 index 00000000..7eaaf89b --- /dev/null +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -0,0 +1,97 @@ +import BingSearchService +import Foundation +import OpenAIService +import Preferences + +struct SearchFunction: ChatGPTFunction { + static let dateFormatter = { + let it = DateFormatter() + it.dateFormat = "yyyy-MM-dd" + return it + }() + + struct Arguments: Codable { + var query: String + var freshness: String? + } + + struct Result: ChatGPTFunctionResult { + var result: BingSearchResult + + var botReadableContent: String { + result.webPages.value.enumerated().map { + let (index, page) = $0 + return """ + \(index + 1). \(page.name) \(page.url) + \(page.snippet) + """ + }.joined(separator: "\n") + } + } + + var reportProgress: (String) async -> Void = { _ in } + + var name: String { + "searchWeb" + } + + var description: String { + "Useful for when you need to answer questions about latest information." + } + + var argumentSchema: JSONSchemaValue { + let today = Self.dateFormatter.string(from: Date()) + return [ + .type: "object", + .properties: [ + "query": [ + .type: "string", + .description: "the search query", + ], + "freshness": [ + .type: "string", + .description: .string( + "limit the search result to a specific range, use only when user ask the question about current events. Today is \(today). Format: yyyy-MM-dd..yyyy-MM-dd" + ), + .examples: ["1919-10-20..1988-10-20"], + ], + ], + .required: ["query"], + ] + } + + func prepare() async { + await reportProgress("Searching..") + } + + func call(arguments: Arguments) async throws -> Result { + await reportProgress("Searching \(arguments.query)") + + do { + let bingSearch = BingSearchService( + subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey), + searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) + ) + let result = try await bingSearch.search( + query: arguments.query, + numberOfResult: UserDefaults.shared.value(for: \.chatGPTMaxToken) > 5000 ? 5 : 3, + freshness: arguments.freshness + ) + + await reportProgress(""" + Finish searching \(arguments.query) + \( + result.webPages.value + .map { "- [\($0.name)](\($0.url))" } + .joined(separator: "\n") + ) + """) + + return .init(result: result) + } catch { + await reportProgress("Failed searching: \(error.localizedDescription)") + throw error + } + } +} + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift new file mode 100644 index 00000000..a9a1313f --- /dev/null +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -0,0 +1,56 @@ +import ChatContextCollector +import Foundation +import OpenAIService + +public final class WebChatContextCollector: ChatContextCollector { + var recentLinks = [String]() + + public init() {} + + public func generateContext( + history: [ChatMessage], + scopes: Set, + content: String + ) -> ChatContext? { + guard scopes.contains("web") else { return nil } + let links = Self.detectLinks(from: history) + Self.detectLinks(from: content) + let functions: [(any ChatGPTFunction)?] = [ + SearchFunction(), + // allow this function only when there is a link in the memory. + links.isEmpty ? nil : QueryWebsiteFunction(), + ] + return .init( + systemPrompt: "You prefer to answer questions with latest content on the internet.", + functions: functions.compactMap { $0 } + ) + } +} + +extension WebChatContextCollector { + static func detectLinks(from messages: [ChatMessage]) -> [String] { + return messages.lazy + .compactMap { + $0.content ?? $0.functionCall?.arguments + } + .map(detectLinks(from:)) + .flatMap { $0 } + } + + static func detectLinks(from content: String) -> [String] { + var links = [String]() + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let matches = detector?.matches( + in: content, + options: [], + range: NSRange(content.startIndex..., in: content) + ) + + for match in matches ?? [] { + guard let range = Range(match.range, in: content) else { continue } + let url = content[range] + links.append(String(url)) + } + return links + } +} + diff --git a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift index 98612db8..803f2806 100644 --- a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift @@ -27,7 +27,7 @@ public actor AITerminalChatPlugin: ChatPlugin { do { if let command { - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init(role: .user, content: content)) } delegate?.pluginDidStartResponding(self) @@ -43,14 +43,14 @@ public actor AITerminalChatPlugin: ChatPlugin { case .cancellation: delegate?.pluginDidEndResponding(self) delegate?.pluginDidEnd(self) - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init(role: .assistant, content: "Cancelled")) } case .modification: let result = try await modifyCommand(command: command, requirement: content) self.command = result delegate?.pluginDidEndResponding(self) - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init(role: .assistant, content: """ Should I run this command? You can instruct me to modify it again. ``` @@ -60,7 +60,7 @@ public actor AITerminalChatPlugin: ChatPlugin { } case .other: delegate?.pluginDidEndResponding(self) - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init( role: .assistant, content: "Sorry, I don't understand. Do you want me to run it?" @@ -68,7 +68,7 @@ public actor AITerminalChatPlugin: ChatPlugin { } } } else { - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init( role: .user, content: originalMessage, @@ -79,7 +79,7 @@ public actor AITerminalChatPlugin: ChatPlugin { let result = try await generateCommand(task: content) command = result if isCancelled { return } - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init(role: .assistant, content: """ Should I run this command? You can instruct me to modify it. ``` @@ -90,7 +90,7 @@ public actor AITerminalChatPlugin: ChatPlugin { delegate?.pluginDidEndResponding(self) } } catch { - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init(role: .assistant, content: error.localizedDescription)) } delegate?.pluginDidEndResponding(self) diff --git a/Core/Sources/ChatPlugin/AskChatGPT.swift b/Core/Sources/ChatPlugin/AskChatGPT.swift index f157c21e..e95deac9 100644 --- a/Core/Sources/ChatPlugin/AskChatGPT.swift +++ b/Core/Sources/ChatPlugin/AskChatGPT.swift @@ -7,6 +7,17 @@ public func askChatGPT( question: String, temperature: Double? = nil ) async throws -> String? { - let service = ChatGPTService(systemPrompt: systemPrompt, temperature: temperature) + let configuration = UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: temperature)) + let memory = AutoManagedChatGPTMemory( + systemPrompt: systemPrompt, + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() + ) + let service = ChatGPTService( + memory: memory, + configuration: configuration + ) return try await service.sendAndWait(content: question) } + diff --git a/Core/Sources/ChatPlugin/CallAIFunction.swift b/Core/Sources/ChatPlugin/CallAIFunction.swift index 9802d282..e29a4d31 100644 --- a/Core/Sources/ChatPlugin/CallAIFunction.swift +++ b/Core/Sources/ChatPlugin/CallAIFunction.swift @@ -16,8 +16,16 @@ func callAIFunction( } } let argsString = args.joined(separator: ", ") + let configuration = UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: 0)) let service = ChatGPTService( - systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value." + memory: AutoManagedChatGPTMemory( + systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value.", + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() + ), + configuration: configuration ) return try await service.sendAndWait(content: argsString) } + diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift index 85ab6eff..cb6f2e4a 100644 --- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift @@ -42,7 +42,7 @@ public actor TerminalChatPlugin: ChatPlugin { return try await Environment.guessProjectRootURLForFile(fileURL) }() - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append( .init( role: .user, @@ -69,7 +69,7 @@ public actor TerminalChatPlugin: ChatPlugin { for try await content in output { if isCancelled { throw CancellationError() } - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -78,7 +78,7 @@ public actor TerminalChatPlugin: ChatPlugin { } } outputContent += "\n[finished]" - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -86,7 +86,7 @@ public actor TerminalChatPlugin: ChatPlugin { } } catch let error as Terminal.TerminationError { outputContent += "\n[error: \(error.status)]" - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -94,7 +94,7 @@ public actor TerminalChatPlugin: ChatPlugin { } } catch { outputContent += "\n[error: \(error.localizedDescription)]" - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift index 296f564a..23fac0ba 100644 --- a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift +++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift @@ -25,7 +25,7 @@ public actor MathChatPlugin: ChatPlugin { async let translatedAnswer = translate(text: "Answer:") var reply = ChatMessage(id: id, role: .assistant, content: "") - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in history.append(.init(role: .user, content: originalMessage, summary: content)) } @@ -33,7 +33,7 @@ public actor MathChatPlugin: ChatPlugin { let result = try await solveMathProblem(content) let formattedResult = "\(await translatedAnswer) \(result)" if !isCancelled { - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -43,7 +43,7 @@ public actor MathChatPlugin: ChatPlugin { } } catch { if !isCancelled { - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift index 602b94ae..e4c60aca 100644 --- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift +++ b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift @@ -23,9 +23,7 @@ public actor SearchChatPlugin: ChatPlugin { 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)) - } + await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage, summary: content)) do { let (eventStream, cancelAgent) = try await search(content) @@ -54,7 +52,7 @@ public actor SearchChatPlugin: ChatPlugin { """ } - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } @@ -77,7 +75,7 @@ public actor SearchChatPlugin: ChatPlugin { } } catch { - await chatGPTService.mutateHistory { history in + await chatGPTService.memory.mutateHistory { history in if history.last?.id == id { history.removeLast() } diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift index e1952740..18df59da 100644 --- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift +++ b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchQuery.swift @@ -1,6 +1,7 @@ import BingSearchService import Foundation import LangChain +import OpenAIService enum SearchEvent { case startAction(String) @@ -42,7 +43,10 @@ func search(_ query: String) async throws ), ] - let chatModel = OpenAIChat(temperature: 0, stream: true) + let chatModel = OpenAIChat( + configuration: UserPreferenceChatGPTConfiguration().overriding { $0.temperature = 0 }, + stream: true + ) let agentExecutor = AgentExecutor( agent: ChatAgent( @@ -55,63 +59,37 @@ func search(_ query: String) async throws earlyStopHandleType: .generate ) - class ResultCallbackManager: ChainCallbackManager { + return (AsyncThrowingStream { continuation in 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 + let callbackManager = CallbackManager { manager in + manager.on(CallbackEvents.AgentActionDidStart.self) { + continuation.yield(.startAction("\($0.toolName): \($0.toolInput)")) } - accumulation.append(token) - if accumulation.hasSuffix("Final Answer: ") { - isGeneratingFinalAnswer = true - accumulation = "" + + manager.on(CallbackEvents.AgentActionDidEnd.self) { + continuation.yield(.endAction("\($0.toolName): \($0.toolInput)")) } - } - } - return (AsyncThrowingStream { continuation in - let callback = ResultCallbackManager( - onFinalAnswerToken: { - continuation.yield(.answerToken($0)) - }, - onAgentActionStart: { - continuation.yield(.startAction($0)) - }, - onAgentActionEnd: { - continuation.yield(.endAction($0)) + manager.on(CallbackEvents.LLMDidProduceNewToken.self) { + if isGeneratingFinalAnswer { + continuation.yield(.answerToken($0)) + return + } + accumulation.append($0) + if accumulation.hasSuffix("Final Answer: ") { + isGeneratingFinalAnswer = true + accumulation = "" + } } - ) + } Task { do { - let finalAnswer = try await agentExecutor.run(query, callbackManagers: [callback]) + let finalAnswer = try await agentExecutor.run( + query, + callbackManagers: [callbackManager] + ) continuation.yield(.finishAnswer(finalAnswer, linkStorage.links)) continuation.finish() } catch { diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift index 2a1c5254..f882b750 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift @@ -42,23 +42,17 @@ public actor ShortcutChatPlugin: ChatPlugin { guard let shortcutName, !shortcutName.isEmpty else { message.content = "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`." - await chatGPTService.mutateHistory { history in - history.append(message) - } + await chatGPTService.memory.appendMessage(message) return } var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines) if input.isEmpty { // if no input detected, use the previous message as input - input = await chatGPTService.history.last?.content ?? "" - await chatGPTService.mutateHistory { history in - history.append(.init(role: .user, content: originalMessage)) - } + input = await chatGPTService.memory.messages.last?.content ?? "" + await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage)) } else { - await chatGPTService.mutateHistory { history in - history.append(.init(role: .user, content: originalMessage)) - } + await chatGPTService.memory.appendMessage(.init(role: .user, content: originalMessage)) } do { @@ -71,7 +65,7 @@ public actor ShortcutChatPlugin: ChatPlugin { .appendingPathComponent("\(id)-input.txt") let temporaryOutputFileURL = temporaryURL .appendingPathComponent("\(id)-output") - + try input.write(to: temporaryInputFileURL, atomically: true, encoding: .utf8) let command = """ @@ -86,7 +80,7 @@ public actor ShortcutChatPlugin: ChatPlugin { currentDirectoryPath: "/", environment: [:] ) - + await Task.yield() if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) { @@ -96,33 +90,25 @@ public actor ShortcutChatPlugin: ChatPlugin { if text.isEmpty { message.content = "Finished" } - await chatGPTService.mutateHistory { history in - history.append(message) - } + await chatGPTService.memory.appendMessage(message) } else { message.content = """ [View File](\(temporaryOutputFileURL)) """ - await chatGPTService.mutateHistory { history in - history.append(message) - } + await chatGPTService.memory.appendMessage(message) } - + return } - + message.content = "Finished" - await chatGPTService.mutateHistory { history in - history.append(message) - } + await chatGPTService.memory.appendMessage(message) } catch { message.content = error.localizedDescription if error.localizedDescription.isEmpty { message.content = "Error" } - await chatGPTService.mutateHistory { history in - history.append(message) - } + await chatGPTService.memory.appendMessage(message) } } diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift index 7f827150..76f16677 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift @@ -45,16 +45,14 @@ public actor ShortcutInputChatPlugin: ChatPlugin { role: .assistant, content: "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`." ) - await chatGPTService.mutateHistory { history in - history.append(reply) - } + await chatGPTService.memory.appendMessage(reply) return } var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines) if input.isEmpty { // if no input detected, use the previous message as input - input = await chatGPTService.history.last?.content ?? "" + input = await chatGPTService.memory.messages.last?.content ?? "" } do { @@ -112,9 +110,7 @@ public actor ShortcutInputChatPlugin: ChatPlugin { role: .assistant, content: error.localizedDescription ) - await chatGPTService.mutateHistory { history in - history.append(reply) - } + await chatGPTService.memory.appendMessage(reply) } } diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift new file mode 100644 index 00000000..a65ff5fd --- /dev/null +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -0,0 +1,8 @@ +import ChatContextCollector +import WebChatContextCollector + +let allContextCollectors: [any ChatContextCollector] = [ + ActiveDocumentChatContextCollector(), + WebChatContextCollector(), +] + diff --git a/Core/Sources/ChatService/ChatFunctionProvider.swift b/Core/Sources/ChatService/ChatFunctionProvider.swift new file mode 100644 index 00000000..8370a612 --- /dev/null +++ b/Core/Sources/ChatService/ChatFunctionProvider.swift @@ -0,0 +1,19 @@ +import Foundation +import OpenAIService + +final class ChatFunctionProvider { + var functions: [any ChatGPTFunction] = [] + + init() {} + + func removeAll() { + functions = [] + } + + func append(functions others: [any ChatGPTFunction]) { + functions.append(contentsOf: others) + } +} + +extension ChatFunctionProvider: ChatGPTFunctionProvider {} + diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift index 9825ec8e..99a7c629 100644 --- a/Core/Sources/ChatService/ChatPluginController.swift +++ b/Core/Sources/ChatService/ChatPluginController.swift @@ -35,7 +35,7 @@ final class ChatPluginController { if command == "exit" { if let plugin = runningPlugin { runningPlugin = nil - _ = await chatGPTService.mutateHistory { history in + _ = await chatGPTService.memory.mutateHistory { history in history.append(.init( role: .user, content: "", @@ -48,7 +48,7 @@ final class ChatPluginController { )) } } else { - _ = await chatGPTService.mutateHistory { history in + _ = await chatGPTService.memory.mutateHistory { history in history.append(.init( role: .system, content: "", diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index c7673758..4512fc20 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -3,30 +3,68 @@ import ChatPlugin import Combine import Foundation import OpenAIService +import Preferences public final class ChatService: ObservableObject { + public let memory: AutoManagedChatGPTMemory + public let configuration: OverridingChatGPTConfiguration public let chatGPTService: any ChatGPTServiceType public var allPluginCommands: [String] { allPlugins.map { $0.command } } - let pluginController: ChatPluginController - let contextController: DynamicContextController - var cancellable = Set() @Published public internal(set) var isReceivingMessage = false @Published public internal(set) var systemPrompt = UserDefaults.shared .value(for: \.defaultChatSystemPrompt) @Published public internal(set) var extraSystemPrompt = "" - public init(chatGPTService: T) { + let pluginController: ChatPluginController + let contextController: DynamicContextController + let functionProvider: ChatFunctionProvider + var cancellable = Set() + + init( + memory: AutoManagedChatGPTMemory, + configuration: OverridingChatGPTConfiguration, + functionProvider: ChatFunctionProvider, + chatGPTService: T + ) { + self.memory = memory + self.configuration = configuration self.chatGPTService = chatGPTService - pluginController = ChatPluginController(chatGPTService: chatGPTService, plugins: allPlugins) - contextController = DynamicContextController( + self.functionProvider = functionProvider + pluginController = ChatPluginController( chatGPTService: chatGPTService, - contextCollectors: ActiveDocumentChatContextCollector() + plugins: allPlugins + ) + contextController = DynamicContextController( + memory: memory, + functionProvider: functionProvider, + contextCollectors: allContextCollectors ) pluginController.chatService = self - chatGPTService.objectWillChange.sink { [weak self] _ in + } + + public convenience init() { + let configuration = UserPreferenceChatGPTConfiguration().overriding() + let functionProvider = ChatFunctionProvider() + let memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: configuration, + functionProvider: functionProvider + ) + self.init( + memory: memory, + configuration: configuration, + functionProvider: functionProvider, + chatGPTService: ChatGPTService( + memory: memory, + configuration: configuration, + functionProvider: functionProvider + ) + ) + + memory.observeHistoryChange { [weak self] in self?.objectWillChange.send() - }.store(in: &cancellable) + } } public func send(content: String) async throws { @@ -48,15 +86,31 @@ public final class ChatService: ObservableObject { } } + public func sendAndWait(content: String) async throws -> String { + try await send(content: content) + if let reply = await memory.history.last(where: { $0.role == .assistant })?.content { + return reply + } + return "" + } + public func stopReceivingMessage() async { await pluginController.stopResponding() await chatGPTService.stopReceivingMessage() isReceivingMessage = false + + // if it's stopped before the function finishes, remove the function call. + await memory.mutateHistory { history in + if history.last?.role == .assistant, history.last?.functionCall != nil { + history.removeLast() + } + } } public func clearHistory() async { await pluginController.cancel() - await chatGPTService.clearHistory() + await memory.clearHistory() + await chatGPTService.stopReceivingMessage() isReceivingMessage = false } @@ -66,25 +120,27 @@ public final class ChatService: ObservableObject { } public func deleteMessage(id: String) async { - await chatGPTService.mutateHistory { messages in - messages.removeAll(where: { $0.id == id }) - } + await memory.removeMessage(id) } public func resendMessage(id: String) async throws { - if let message = (await chatGPTService.history).first(where: { $0.id == id }) { - try await send(content: message.content) + if let message = (await memory.history).first(where: { $0.id == id }), + let content = message.content + { + try await send(content: content) } } public func setMessageAsExtraPrompt(id: String) async { - if let message = (await chatGPTService.history).first(where: { $0.id == id }) { - mutateExtraSystemPrompt(message.content) + if let message = (await memory.history).first(where: { $0.id == id }), + let content = message.content + { + mutateExtraSystemPrompt(content) await mutateHistory { history in history.append(.init( role: .assistant, content: "", - summary: "System prompt updated" + summary: "System prompt updated." )) } } @@ -100,7 +156,80 @@ public final class ChatService: ObservableObject { } public func mutateHistory(_ mutator: @escaping (inout [ChatMessage]) -> Void) async { - await chatGPTService.mutateHistory(mutator) + await memory.mutateHistory(mutator) + } + + public func handleCustomCommand(_ command: CustomCommand) async throws { + struct CustomCommandInfo { + var specifiedSystemPrompt: String? + var extraSystemPrompt: String? + var sendingMessageImmediately: String? + var name: String? + } + + let info: CustomCommandInfo? = { + switch command.feature { + case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): + let updatePrompt = useExtraSystemPrompt ?? true + return .init( + extraSystemPrompt: updatePrompt ? extraSystemPrompt : nil, + sendingMessageImmediately: prompt, + name: command.name + ) + case let .customChat(systemPrompt, prompt): + return .init( + specifiedSystemPrompt: systemPrompt, + extraSystemPrompt: "", + sendingMessageImmediately: prompt, + name: command.name + ) + case .promptToCode: return nil + case .singleRoundDialog: return nil + } + }() + + guard let info else { return } + + let templateProcessor = CustomCommandTemplateProcessor() + mutateSystemPrompt(info.specifiedSystemPrompt.map(templateProcessor.process)) + mutateExtraSystemPrompt(info.extraSystemPrompt.map(templateProcessor.process) ?? "") + + let customCommandPrefix = { + if let name = info.name { return "[\(name)] " } + return "" + }() + + if info.specifiedSystemPrompt != nil || info.extraSystemPrompt != nil { + await mutateHistory { history in + history.append(.init( + role: .assistant, + content: "", + summary: "\(customCommandPrefix)System prompt is updated." + )) + } + } + + if let sendingMessageImmediately = info.sendingMessageImmediately, + !sendingMessageImmediately.isEmpty + { + try await send(content: templateProcessor.process(sendingMessageImmediately)) + } + } + + public func handleSingleRoundDialogCommand( + systemPrompt: String?, + overwriteSystemPrompt: Bool, + prompt: String + ) async throws -> String { + let templateProcessor = CustomCommandTemplateProcessor() + if let systemPrompt { + if overwriteSystemPrompt { + mutateSystemPrompt(templateProcessor.process(systemPrompt)) + } else { + mutateExtraSystemPrompt(templateProcessor.process(systemPrompt)) + } + } + return try await sendAndWait(content: templateProcessor.process(prompt)) } } diff --git a/Core/Sources/Service/CustomCommandTemplateProcessor.swift b/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift similarity index 100% rename from Core/Sources/Service/CustomCommandTemplateProcessor.swift rename to Core/Sources/ChatService/CustomCommandTemplateProcessor.swift diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index 1cdb4275..eca176dc 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -1,32 +1,83 @@ import ChatContextCollector import Foundation import OpenAIService +import Parsing import Preferences import XcodeInspector final class DynamicContextController { - let chatGPTService: any ChatGPTServiceType let contextCollectors: [ChatContextCollector] + let memory: AutoManagedChatGPTMemory + let functionProvider: ChatFunctionProvider - init(chatGPTService: any ChatGPTServiceType, contextCollectors: ChatContextCollector...) { - self.chatGPTService = chatGPTService + convenience init( + memory: AutoManagedChatGPTMemory, + functionProvider: ChatFunctionProvider, + contextCollectors: ChatContextCollector... + ) { + self.init( + memory: memory, + functionProvider: functionProvider, + contextCollectors: contextCollectors + ) + } + + init( + memory: AutoManagedChatGPTMemory, + functionProvider: ChatFunctionProvider, + contextCollectors: [ChatContextCollector] + ) { + self.memory = memory + self.functionProvider = functionProvider self.contextCollectors = contextCollectors } func updatePromptToMatchContent(systemPrompt: String, content: String) async throws { + var content = content + let scopes = Self.parseScopes(&content) + functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) - let oldMessages = (await chatGPTService.history).map(\.content) + let oldMessages = await memory.history + let contexts = contextCollectors.compactMap { + $0.generateContext(history: oldMessages, scopes: scopes, content: content) + } let contextualSystemPrompt = """ \(language.isEmpty ? "" : "You must always reply in \(language)") \(systemPrompt) - \( - contextCollectors - .map { $0.generateSystemPrompt(history: oldMessages, content: content) } - .joined(separator: "\n") - ) + \(contexts.map(\.systemPrompt).filter { !$0.isEmpty }.joined(separator: "\n\n")) """ - await chatGPTService.mutateSystemPrompt(contextualSystemPrompt) + await memory.mutateSystemPrompt(contextualSystemPrompt) + functionProvider.append(functions: contexts.flatMap(\.functions)) + } +} + +extension DynamicContextController { + static func parseScopes(_ prompt: inout String) -> Set { + guard !prompt.isEmpty else { return [] } + do { + let parser = Parse { + "@" + Many { + Prefix { $0.isLetter } + } separator: { + "+" + } terminator: { + " " + } + Skip { + Many { + " " + } + } + Rest() + } + let (scopes, rest) = try parser.parse(prompt) + prompt = String(rest) + return Set(scopes.map(String.init)) + } catch { + return [] + } } } diff --git a/Core/Sources/Service/GUI/ChatProvider+Service.swift b/Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift similarity index 50% rename from Core/Sources/Service/GUI/ChatProvider+Service.swift rename to Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift index 5dc13f3b..6609fcd3 100644 --- a/Core/Sources/Service/GUI/ChatProvider+Service.swift +++ b/Core/Sources/ChatTab/ChatGPT/ChatGPTChatTab.swift @@ -1,26 +1,54 @@ import ChatService import Combine import Foundation -import OpenAIService -import SuggestionWidget +import SwiftUI + +/// A chat tab that provides a context aware chat bot, powered by ChatGPT. +public class ChatGPTChatTab: ChatTab { + public let service: ChatService + public let provider: ChatProvider + private var cancellable = Set() + + public func buildView() -> any View { + ChatPanel(chat: provider) + } + + public init(service: ChatService = .init()) { + self.service = service + provider = .init(service: service) + super.init(id: "Chat-" + provider.id.uuidString, title: "Chat") + + provider.$history.sink { [weak self] _ in + if let title = self?.provider.title { + self?.title = title + } + }.store(in: &cancellable) + } +} extension ChatProvider { - convenience init( - service: ChatService, - fileURL: URL, - onCloseChat: @escaping () -> Void, - onSwitchContext: @escaping () -> Void - ) { + convenience init(service: ChatService) { self.init(pluginIdentifiers: service.allPluginCommands) let cancellable = service.objectWillChange.sink { [weak self] in guard let self else { return } Task { @MainActor in - self.history = (await service.chatGPTService.history).map { message in + self.history = (await service.memory.history).map { message in .init( id: message.id, - isUser: message.role == .user, - text: message.summary ?? message.content + role: { + switch message.role { + case .system: return .ignored + case .user: return .user + case .assistant: + if let text = message.summary ?? message.content, !text.isEmpty { + return .assistant + } + return .ignored + case .function: return .function + } + }(), + text: message.summary ?? message.content ?? "" ) } self.isReceivingMessage = service.isReceivingMessage @@ -34,11 +62,7 @@ extension ChatProvider { onMessageSend = { [cancellable] message in _ = cancellable Task { - do { - _ = try await service.send(content: message) - } catch { - PresentInWindowSuggestionPresenter().presentError(error) - } + try await service.send(content: message) } } onStop = { @@ -53,17 +77,6 @@ extension ChatProvider { } } - onClose = { - Task { - await service.stopReceivingMessage() - onCloseChat() - } - } - - self.onSwitchContext = { - onSwitchContext() - } - onDeleteMessage = { id in Task { await service.deleteMessage(id: id) @@ -72,11 +85,7 @@ extension ChatProvider { onResendMessage = { id in Task { - do { - try await service.resendMessage(id: id) - } catch { - PresentInWindowSuggestionPresenter().presentError(error) - } + try await service.resendMessage(id: id) } } @@ -85,14 +94,13 @@ extension ChatProvider { await service.resetPrompt() } } - + onRunCustomCommand = { command in Task { - let commandHandler = PseudoCommandHandler() - await commandHandler.handleCustomCommand(command) + try await service.handleCustomCommand(command) } } - + onSetAsExtraPrompt = { id in Task { await service.setMessageAsExtraPrompt(id: id) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift b/Core/Sources/ChatTab/ChatGPT/ChatPanel.swift similarity index 93% rename from Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift rename to Core/Sources/ChatTab/ChatGPT/ChatPanel.swift index 69d5d8ff..a14354d7 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift +++ b/Core/Sources/ChatTab/ChatGPT/ChatPanel.swift @@ -1,18 +1,22 @@ import AppKit import MarkdownUI +import SharedUIComponents import SwiftUI private let r: Double = 8 -struct ChatPanel: View { - let chat: ChatProvider +public struct ChatPanel: View { + @ObservedObject var chat: ChatProvider @Namespace var inputAreaNamespace @State var typedMessage = "" - var body: some View { + public init(chat: ChatProvider, typedMessage: String = "") { + self.chat = chat + self.typedMessage = typedMessage + } + + public var body: some View { VStack(spacing: 0) { - ChatPanelToolbar(chat: chat) - Divider() ChatPanelMessages( chat: chat ) @@ -23,39 +27,6 @@ struct ChatPanel: View { ) } .background(.regularMaterial) - .xcodeStyleFrame() - } -} - -struct ChatPanelToolbar: View { - @ObservedObject var chat: ChatProvider - @AppStorage(\.useGlobalChat) var useGlobalChat - - var body: some View { - HStack { - Button(action: { - chat.close() - }) { - Image(systemName: "xmark") - .padding(4) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .keyboardShortcut("w", modifiers: [.command]) - - Spacer() - - Toggle(isOn: .init(get: { - useGlobalChat - }, set: { _ in - chat.switchContext() - })) { EmptyView() } - .toggleStyle(GlobalChatSwitchToggleStyle()) - } - .padding(.leading, 4) - .padding(.trailing, 8) - .padding(.vertical, 4) - .background(.regularMaterial) } } @@ -74,17 +45,21 @@ struct ChatPanelMessages: View { } ForEach(chat.history.reversed(), id: \.id) { message in - let text = message.text.isEmpty && !message.isUser ? "..." : message - .text + let text = message.text - if message.isUser { + switch message.role { + case .user: UserMessage(id: message.id, text: text, chat: chat) .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) .padding(.vertical, 4) - } else { + case .assistant: BotMessage(id: message.id, text: text, chat: chat) .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) .padding(.vertical, 4) + case .function: + FunctionMessage(id: message.id, text: text) + case .ignored: + EmptyView() } } .listItemTint(.clear) @@ -147,7 +122,7 @@ private struct Instruction: View { - 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`. """ ) @@ -162,7 +137,7 @@ private struct Instruction: View { - 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`. """ ) @@ -297,6 +272,21 @@ private struct BotMessage: View { } } +struct FunctionMessage: View { + let id: String + let text: String + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + Markdown(text) + .textSelection(.enabled) + .markdownTheme(.functionCall(fontSize: chatFontSize)) + .scaleEffect(x: -1, y: -1, anchor: .center) + .padding(.vertical, 2) + .padding(.trailing, 2) + } +} + struct ChatPanelInputArea: View { @ObservedObject var chat: ChatProvider @Binding var typedMessage: String @@ -398,7 +388,7 @@ struct ChatPanelInputArea: View { chat.send(typedMessage) typedMessage = "" } - + func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] { guard text.count == 1 else { return [] } let plugins = chat.pluginIdentifiers.map { "/\($0)" } @@ -407,7 +397,7 @@ struct ChatPanelInputArea: View { "@selection", "@file", ] - + let result: [String] = availableFeatures .filter { $0.hasPrefix(text) && $0 != text } .compactMap { @@ -473,6 +463,7 @@ struct ChatContextMenu: View { switch $0.feature { case .chatWithSelection, .customChat: return true case .promptToCode: return false + case .singleRoundDialog: return false } }, id: \.name @@ -584,12 +575,12 @@ struct ChatPanel_Preview: PreviewProvider { static let history: [ChatMessage] = [ .init( id: "1", - isUser: true, + role: .user, text: "**Hello**" ), .init( id: "2", - isUser: false, + role: .assistant, text: """ ```swift func foo() {} @@ -597,11 +588,19 @@ struct ChatPanel_Preview: PreviewProvider { **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? """ ), - .init(id: "5", isUser: false, text: "Yooo"), - .init(id: "4", isUser: true, text: "Yeeeehh"), + .init(id: "7", role: .ignored, text: "Ignored"), + .init(id: "6", role: .function, text: """ + Searching for something... + - abc + - [def](https://1.com) + > hello + > hi + """), + .init(id: "5", role: .assistant, text: "Yooo"), + .init(id: "4", role: .user, text: "Yeeeehh"), .init( id: "3", - isUser: true, + role: .user, text: #""" Please buy me a coffee! | Coffee | Milk | @@ -624,7 +623,7 @@ struct ChatPanel_Preview: PreviewProvider { history: ChatPanel_Preview.history, isReceivingMessage: true )) - .frame(width: 450, height: 700) + .frame(width: 450, height: 1200) .colorScheme(.dark) } } @@ -700,6 +699,3 @@ struct ChatPanel_Light_Preview: PreviewProvider { } } - - - diff --git a/Core/Sources/SuggestionWidget/ChatProvider.swift b/Core/Sources/ChatTab/ChatGPT/ChatProvider.swift similarity index 77% rename from Core/Sources/SuggestionWidget/ChatProvider.swift rename to Core/Sources/ChatTab/ChatGPT/ChatProvider.swift index 33102223..c0d9b31d 100644 --- a/Core/Sources/SuggestionWidget/ChatProvider.swift +++ b/Core/Sources/ChatTab/ChatGPT/ChatProvider.swift @@ -1,20 +1,31 @@ import Foundation +import OpenAIService import Preferences import SwiftUI public final class ChatProvider: ObservableObject { public typealias MessageID = String - let id = UUID() + public let id = UUID() @Published public var history: [ChatMessage] = [] @Published public var isReceivingMessage = false public var pluginIdentifiers: [String] = [] public var systemPrompt = "" + public var title: String { + let defaultTitle = "Chat" + guard let lastMessageText = history + .filter({ $0.role == .assistant || $0.role == .user }) + .last? + .text else { return defaultTitle } + if lastMessageText.isEmpty { return defaultTitle } + return lastMessageText + .trimmingCharacters(in: .punctuationCharacters) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + public var extraSystemPrompt = "" public var onMessageSend: (String) -> Void public var onStop: () -> Void public var onClear: () -> Void - public var onClose: () -> Void - public var onSwitchContext: () -> Void public var onDeleteMessage: (MessageID) -> Void public var onResendMessage: (MessageID) -> Void public var onResetPrompt: () -> Void @@ -28,8 +39,6 @@ public final class ChatProvider: ObservableObject { onMessageSend: @escaping (String) -> Void = { _ in }, onStop: @escaping () -> Void = {}, onClear: @escaping () -> Void = {}, - onClose: @escaping () -> Void = {}, - onSwitchContext: @escaping () -> Void = {}, onDeleteMessage: @escaping (MessageID) -> Void = { _ in }, onResendMessage: @escaping (MessageID) -> Void = { _ in }, onResetPrompt: @escaping () -> Void = {}, @@ -42,8 +51,6 @@ public final class ChatProvider: ObservableObject { self.onMessageSend = onMessageSend self.onStop = onStop self.onClear = onClear - self.onClose = onClose - self.onSwitchContext = onSwitchContext self.onDeleteMessage = onDeleteMessage self.onResendMessage = onResendMessage self.onResetPrompt = onResetPrompt @@ -54,8 +61,6 @@ public final class ChatProvider: ObservableObject { public func send(_ message: String) { onMessageSend(message) } public func stop() { onStop() } public func clear() { onClear() } - public func close() { onClose() } - public func switchContext() { onSwitchContext() } public func deleteMessage(id: MessageID) { onDeleteMessage(id) } public func resendMessage(id: MessageID) { onResendMessage(id) } public func resetPrompt() { onResetPrompt() } @@ -67,13 +72,20 @@ public final class ChatProvider: ObservableObject { } public struct ChatMessage: Equatable { + public enum Role { + case user + case assistant + case function + case ignored + } + public var id: String - public var isUser: Bool + public var role: Role public var text: String - public init(id: String, isUser: Bool, text: String) { + public init(id: String, role: Role, text: String) { self.id = id - self.isUser = isUser + self.role = role self.text = text } } diff --git a/Core/Sources/ChatTab/ChatGPT/Styles.swift b/Core/Sources/ChatTab/ChatGPT/Styles.swift new file mode 100644 index 00000000..330cc92f --- /dev/null +++ b/Core/Sources/ChatTab/ChatGPT/Styles.swift @@ -0,0 +1,100 @@ +import AppKit +import MarkdownUI +import SharedUIComponents +import SwiftUI + +extension Color { + static var contentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.1580096483, green: 0.1730263829, blue: 0.2026666105, alpha: 1) + } + return .white + })) + } + + static var userChatContentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.2284317913, green: 0.2145925438, blue: 0.3214019983, alpha: 1) + } + return #colorLiteral(red: 0.896820749, green: 0.8709097223, blue: 0.9766687925, alpha: 1) + })) + } +} + +extension NSAppearance { + var isDarkMode: Bool { + if bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { + return true + } else { + return false + } + } +} + +extension MarkdownUI.Theme { + static func custom(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.primary) + BackgroundColor(Color.clear) + FontSize(fontSize) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .padding(.top, 14) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .top) { + HStack(alignment: .center) { + Text(configuration.language ?? "code") + .foregroundStyle(.tertiary) + .font(.callout) + .padding(.leading, 8) + .lineLimit(1) + Spacer() + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + } + } + .markdownMargin(top: 4, bottom: 16) + } + } + + static func functionCall(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.secondary) + BackgroundColor(Color.clear) + FontSize(fontSize - 1) + } + .list { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + .paragraph { configuration in + configuration.label + .markdownMargin(top: 0, bottom: 4) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 4, bottom: 4) + } + } +} + diff --git a/Core/Sources/ChatTab/ChatTab.swift b/Core/Sources/ChatTab/ChatTab.swift new file mode 100644 index 00000000..b41c4319 --- /dev/null +++ b/Core/Sources/ChatTab/ChatTab.swift @@ -0,0 +1,100 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +public struct ChatTabInfo: Identifiable, Equatable { + public var id: String + public var title: String + + public init(id: String, title: String) { + self.id = id + self.title = title + } +} + +public struct ChatTabInfoPreferenceKey: PreferenceKey { + public static var defaultValue: [ChatTabInfo] = [] + public static func reduce(value: inout [ChatTabInfo], nextValue: () -> [ChatTabInfo]) { + value.append(contentsOf: nextValue()) + } +} + +/// Every chat tab should conform to this type. +public typealias ChatTab = BaseChatTab & ChatTabType + +open class BaseChatTab: Equatable { + final class InfoObservable: ObservableObject { + @Published var id: String + @Published var title: String + init(id: String, title: String) { + self.title = title + self.id = id + } + } + + struct ContentView: View { + @ObservedObject var info: InfoObservable + var buildView: () -> any View + var body: some View { + AnyView(buildView()) + .preference( + key: ChatTabInfoPreferenceKey.self, + value: [ChatTabInfo( + id: info.id, + title: info.title + )] + ) + } + } + + public let id: String + public var title: String { + didSet { info.title = title } + } + + let info: InfoObservable + + public init(id: String, title: String) { + self.id = id + self.title = title + info = InfoObservable(id: id, title: title) + } + + @ViewBuilder + public var body: some View { + let id = "BaseChatTab\(info.id)" + if let tab = self as? ChatTabType { + ContentView(info: info, buildView: tab.buildView).id(id) + } else { + EmptyView().id(id) + } + } + + @ViewBuilder + public var menu: some View { + EmptyView() + } + + public static func == (lhs: BaseChatTab, rhs: BaseChatTab) -> Bool { + lhs.id == rhs.id + } +} + +public protocol ChatTabType { + @ViewBuilder + func buildView() -> any View +} + +public class EmptyChatTab: ChatTab { + public func buildView() -> any View { + VStack { + Text("Empty-\(id)") + } + .background(Color.blue) + } + + public init(id: String = UUID().uuidString) { + super.init(id: id, title: "Empty") + } +} + diff --git a/Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift b/Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift index cdbeddbd..2b1a0882 100644 --- a/Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift +++ b/Core/Sources/GitHubCopilotService/CopilotLocalProcessServer.swift @@ -222,6 +222,13 @@ extension CustomJSONRPCLanguageServer { } block(nil) return true + case "featureFlagsNotification": + if UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) { + Logger.gitHubCopilot + .info("\(anyNotification.method): \(anyNotification.params.debugDescription)") + } + block(nil) + return true default: return false } diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift index 62617909..65b7e89b 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift +++ b/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift @@ -5,12 +5,12 @@ public struct GitHubCopilotInstallationManager { private static var isInstalling = false static var downloadURL: URL { - let commitHash = "1358e8e45ecedc53daf971924a0541ddf6224faf" + let commitHash = "a4a6d6b3f9e284e7f5c849619e06cd228cad8abd" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } - static let latestSupportedVersion = "1.8.4" + static let latestSupportedVersion = "1.9.1" public init() {} @@ -113,7 +113,7 @@ public struct GitHubCopilotInstallationManager { includingPropertiesForKeys: nil, options: [] ) - + defer { for url in contentURLs { try? FileManager.default.removeItem(at: url) @@ -127,8 +127,18 @@ public struct GitHubCopilotInstallationManager { return } - let lspURL = gitFolderURL.appendingPathComponent("copilot") - let installationURL = urls.executableURL.appendingPathComponent("copilot") + let lspURL = gitFolderURL.appendingPathComponent("dist") + let copilotURL = urls.executableURL.appendingPathComponent("copilot") + + if !FileManager.default.fileExists(atPath: copilotURL.path) { + try FileManager.default.createDirectory( + at: copilotURL, + withIntermediateDirectories: true, + attributes: nil + ) + } + + let installationURL = copilotURL.appendingPathComponent("dist") try FileManager.default.copyItem(at: lspURL, to: installationURL) // update permission 755 diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift index feeaa9c2..1f11a7e3 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift +++ b/Core/Sources/GitHubCopilotService/GitHubCopilotService.swift @@ -165,13 +165,11 @@ public class GitHubCopilotBaseService { self.server = server localProcessServer = localServer - + Task { try await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) } } - - public static func createFoldersIfNeeded() throws -> ( applicationSupportURL: URL, @@ -314,6 +312,18 @@ public final class GitHubCopilotSuggestionService: GitHubCopilotBaseService, } return true } + .map { + if UserDefaults.shared.value(for: \.gitHubCopilotIgnoreTrailingNewLines) { + var updated = $0 + var text = updated.text[...] + while let last = text.last, last.isNewline || last.isWhitespace { + text = text.dropLast(1) + } + updated.text = String(text) + return updated + } + return $0 + } try Task.checkCancellation() return completions } diff --git a/Core/Sources/HostApp/AccountSettings/AzureView.swift b/Core/Sources/HostApp/AccountSettings/AzureView.swift index d28e111b..cb2b59b6 100644 --- a/Core/Sources/HostApp/AccountSettings/AzureView.swift +++ b/Core/Sources/HostApp/AccountSettings/AzureView.swift @@ -9,6 +9,7 @@ final class AzureViewSettings: ObservableObject { @AppStorage(\.azureOpenAIAPIKey) var azureOpenAIAPIKey: String @AppStorage(\.azureOpenAIBaseURL) var azureOpenAIBaseURL: String @AppStorage(\.azureChatGPTDeployment) var azureChatGPTDeployment: String + @AppStorage(\.azureEmbeddingDeployment) var azureEmbeddingDeployment: String init() {} } @@ -44,7 +45,11 @@ struct AzureView: View { isTesting = true defer { isTesting = false } do { - let reply = try await ChatGPTService(designatedProvider: .azureOpenAI) + let reply = + try await ChatGPTService( + configuration: UserPreferenceChatGPTConfiguration() + .overriding(.init(featureProvider: .azureOpenAI)) + ) .sendAndWait(content: "Hello", summary: nil) toast(Text("ChatGPT replied: \(reply ?? "N/A")"), .info) } catch { @@ -54,6 +59,15 @@ struct AzureView: View { } .disabled(isTesting) } + + HStack { + TextField( + text: $settings.azureEmbeddingDeployment, + prompt: Text("") + ) { + Text("Embedding Model Deployment Name") + }.textFieldStyle(.roundedBorder) + } } } } diff --git a/Core/Sources/HostApp/AccountSettings/CopilotView.swift b/Core/Sources/HostApp/AccountSettings/CopilotView.swift index 0b9e6666..2f9968c1 100644 --- a/Core/Sources/HostApp/AccountSettings/CopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/CopilotView.swift @@ -18,7 +18,8 @@ struct CopilotView: View { @AppStorage(\.gitHubCopilotProxyUsername) var gitHubCopilotProxyUsername @AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL - + @AppStorage(\.gitHubCopilotIgnoreTrailingNewLines) + var gitHubCopilotIgnoreTrailingNewLines init() {} } @@ -229,13 +230,20 @@ struct CopilotView: View { Divider() Form { + Toggle( + "Ignore Trailing New Lines and Whitespaces", + isOn: $settings.gitHubCopilotIgnoreTrailingNewLines + ) Toggle("Verbose Log", isOn: $settings.gitHubCopilotVerboseLog) } Divider() Form { - TextField(text: $settings.gitHubCopilotProxyHost, prompt: Text("xxx.xxx.xxx.xxx, leave it blank to disable proxy.")) { + TextField( + text: $settings.gitHubCopilotProxyHost, + prompt: Text("xxx.xxx.xxx.xxx, leave it blank to disable proxy.") + ) { Text("Proxy Host") } TextField(text: $settings.gitHubCopilotProxyPort, prompt: Text("80")) { diff --git a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift index a6dea775..ccf037f9 100644 --- a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift +++ b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift @@ -9,10 +9,11 @@ struct OpenAIView: View { final class Settings: ObservableObject { @AppStorage(\.openAIAPIKey) var openAIAPIKey: String @AppStorage(\.chatGPTModel) var chatGPTModel: String + @AppStorage(\.embeddingModel) var embeddingModel: String @AppStorage(\.openAIBaseURL) var openAIBaseURL: String init() {} } - + let apiKeyURL = URL(string: "https://platform.openai.com/account/api-keys")! let modelURL = URL( string: "https://platform.openai.com/docs/models/model-endpoint-compatibility" @@ -49,7 +50,11 @@ struct OpenAIView: View { isTesting = true defer { isTesting = false } do { - let reply = try await ChatGPTService(designatedProvider: .openAI) + let reply = + try await ChatGPTService( + configuration: UserPreferenceChatGPTConfiguration() + .overriding(.init(featureProvider: .openAI)) + ) .sendAndWait(content: "Hello", summary: nil) toast(Text("ChatGPT replied: \(reply ?? "N/A")"), .info) } catch { @@ -78,6 +83,26 @@ struct OpenAIView: View { Image(systemName: "questionmark.circle.fill") }.buttonStyle(.plain) } + + HStack { + Picker(selection: $settings.embeddingModel) { + if !settings.embeddingModel.isEmpty, + OpenAIEmbeddingModel(rawValue: settings.embeddingModel) == nil + { + Text(settings.embeddingModel).tag(settings.embeddingModel) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } label: { + Text("Embedding Model") + }.pickerStyle(.menu) + Button(action: { + openURL(modelURL) + }) { + Image(systemName: "questionmark.circle.fill") + }.buttonStyle(.plain) + } } } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift new file mode 100644 index 00000000..c6409ea8 --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -0,0 +1,49 @@ +import ComposableArchitecture +import Foundation +import Preferences +import SwiftUI + +struct CustomCommandFeature: ReducerProtocol { + struct State: Equatable { + var editCustomCommand: EditCustomCommand.State? + } + + let settings: CustomCommandView.Settings + let toast: (Text, ToastType) -> Void + + enum Action: Equatable { + case createNewCommand + case editCommand(CustomCommand) + case editCustomCommand(EditCustomCommand.Action) + case deleteCommand(CustomCommand) + } + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .createNewCommand: + state.editCustomCommand = EditCustomCommand.State(nil) + return .none + case let .editCommand(command): + state.editCustomCommand = EditCustomCommand.State(command) + return .none + case .editCustomCommand(.close): + state.editCustomCommand = nil + return .none + case let .deleteCommand(command): + settings.customCommands.removeAll( + where: { $0.id == command.id } + ) + if state.editCustomCommand?.commandId == command.id { + state.editCustomCommand = nil + } + return .none + case .editCustomCommand: + return .none + + } + }.ifLet(\.editCustomCommand, action: /Action.editCustomCommand) { + EditCustomCommand(settings: settings, toast: toast) + } + } +} diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift new file mode 100644 index 00000000..4722c505 --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -0,0 +1,298 @@ +import ComposableArchitecture +import MarkdownUI +import Preferences +import SwiftUI + +extension List { + @ViewBuilder + func removeBackground() -> some View { + if #available(macOS 13.0, *) { + scrollContentBackground(.hidden) + } else { + background(Color.clear) + } + } +} + +let customCommandStore = StoreOf( + initialState: .init(), + reducer: CustomCommandFeature( + settings: .init(), + toast: { content, type in + Task { @MainActor in + globalToastController.toast(content: content, type: type) + } + } + ) +) + +struct CustomCommandView: View { + final class Settings: ObservableObject { + @AppStorage(\.customCommands) var customCommands + + init(customCommands: AppStorage<[CustomCommand]>? = nil) { + if let list = customCommands { + _customCommands = list + } + } + } + + var store: StoreOf + @StateObject var settings = Settings() + @Environment(\.toast) var toast + + var body: some View { + HStack(spacing: 0) { + leftPane + Divider() + rightPane + } + } + + @ViewBuilder + var leftPane: some View { + List { + ForEach(settings.customCommands, id: \.name) { command in + CommandButton(store: store, command: command) + } + .onMove(perform: { indices, newOffset in + settings.customCommands.move(fromOffsets: indices, toOffset: newOffset) + }) + } + .removeBackground() + .padding(.vertical, 4) + .listStyle(.plain) + .frame(width: 200) + .background(Color.primary.opacity(0.05)) + .overlay { + if settings.customCommands.isEmpty { + Text(""" + Empty + Add command with "+" button + """) + .multilineTextAlignment(.center) + } + } + .safeAreaInset(edge: .bottom) { + Button(action: { + store.send(.createNewCommand) + }) { + Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") + } + .buttonStyle(.plain) + .padding() + } + } + + struct CommandButton: View { + let store: StoreOf + let command: CustomCommand + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") + + VStack(alignment: .leading) { + Text(command.name) + .foregroundStyle(.primary) + + Group { + switch command.feature { + case .chatWithSelection: + Text("Send Message") + case .customChat: + Text("Custom Chat") + case .promptToCode: + Text("Prompt to Code") + case .singleRoundDialog: + Text("Single Round Dialog") + } + } + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.editCommand(command)) + } + } + .padding(4) + .background { + WithViewStore(store, observe: { $0.editCustomCommand?.commandId }) { viewStore in + RoundedRectangle(cornerRadius: 4) + .fill( + viewStore.state == command.id + ? Color.primary.opacity(0.05) + : Color.clear + ) + } + } + .contextMenu { + Button("Remove") { + store.send(.deleteCommand(command)) + } + } + } + } + + @ViewBuilder + var rightPane: some View { + IfLetStore(store.scope( + state: \.editCustomCommand, + action: CustomCommandFeature.Action.editCustomCommand + )) { store in + EditCustomCommandView(store: store) + } else: { + CustomCommandTypeDescription(text: """ + # Send Message + + This command sends a message to the active chat tab. You can provide additional context through the "Extra System Prompt" as well. + + # Prompt to Code + + This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the "Extra Context" as well. + + # Custom Chat + + This command will overwrite the system prompt to let the bot behave differently. + + # Single Round Dialog + + This command allows you to send a message to a temporary chat without opening the chat panel. + + It is particularly useful for one-time commands, such as running a terminal command with `/run`. + + For example, you can set the prompt to `/run open .` to open the project in Finder. + """) + } + } +} + +struct CustomCommandTypeDescription: View { + let text: String + var body: some View { + ScrollView { + Markdown(text) + .lineLimit(nil) + .markdownTheme( + .gitHub + .text { + ForegroundColor(.secondary) + BackgroundColor(.clear) + FontSize(14) + } + .heading1 { conf in + VStack(alignment: .leading, spacing: 0) { + conf.label + .relativePadding(.bottom, length: .em(0.3)) + .relativeLineSpacing(.em(0.125)) + .markdownMargin(top: 24, bottom: 16) + .markdownTextStyle { + FontWeight(.semibold) + FontSize(.em(1.25)) + } + Divider() + } + } + ) + .padding() + .background(Color.primary.opacity(0.02), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init(lineWidth: 1)) + .foregroundColor(Color(nsColor: .separatorColor)) + } + .padding() + } + } +} + +// MARK: - Previews + +struct CustomCommandView_Preview: PreviewProvider { + static var previews: some View { + let settings = CustomCommandView.Settings(customCommands: .init(wrappedValue: [ + .init( + commandId: "1", + name: "Explain Code", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: "Hello", + useExtraSystemPrompt: false + ) + ), + .init( + commandId: "2", + name: "Refactor Code", + feature: .promptToCode( + extraSystemPrompt: nil, + prompt: "Refactor", + continuousMode: false, + generateDescription: true + ) + ), + ], "CustomCommandView_Preview")) + + return CustomCommandView( + store: .init( + initialState: .init( + editCustomCommand: .init(.init(.init( + commandId: "1", + name: "Explain Code", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: "Hello", + useExtraSystemPrompt: false + ) + ))) + ), + reducer: CustomCommandFeature( + settings: settings, + toast: { _, _ in } + ) + ), + settings: settings + ) + } +} + +struct CustomCommandView_NoEditing_Preview: PreviewProvider { + static var previews: some View { + let settings = CustomCommandView.Settings(customCommands: .init(wrappedValue: [ + .init( + commandId: "1", + name: "Explain Code", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: "Hello", + useExtraSystemPrompt: false + ) + ), + .init( + commandId: "2", + name: "Refactor Code", + feature: .promptToCode( + extraSystemPrompt: nil, + prompt: "Refactor", + continuousMode: false, + generateDescription: true + ) + ), + ], "CustomCommandView_Preview")) + + return CustomCommandView( + store: .init( + initialState: .init( + editCustomCommand: nil + ), + reducer: CustomCommandFeature( + settings: settings, + toast: { _, _ in } + ) + ), + settings: settings + ) + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift new file mode 100644 index 00000000..36b42e19 --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift @@ -0,0 +1,260 @@ +import ComposableArchitecture +import Foundation +import Preferences +import SwiftUI + +struct EditCustomCommand: ReducerProtocol { + enum CommandType: Int, CaseIterable, Equatable { + case sendMessage + case promptToCode + case customChat + case singleRoundDialog + } + + struct State: Equatable { + @BindingState var name: String = "" + @BindingState var commandType: CommandType = .sendMessage + var isNewCommand: Bool = false + let commandId: String + + var sendMessage = EditSendMessageCommand.State() + var promptToCode = EditPromptToCodeCommand.State() + var customChat = EditCustomChatCommand.State() + var singleRoundDialog = EditSingleRoundDialogCommand.State() + + init(_ command: CustomCommand?) { + isNewCommand = command == nil + commandId = command?.id ?? UUID().uuidString + name = command?.name ?? "New Command" + + switch command?.feature { + case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): + commandType = .sendMessage + sendMessage = .init( + extraSystemPrompt: extraSystemPrompt ?? "", + useExtraSystemPrompt: useExtraSystemPrompt ?? false, + prompt: prompt ?? "" + ) + case .none: + commandType = .sendMessage + sendMessage = .init( + extraSystemPrompt: "", + useExtraSystemPrompt: false, + prompt: "Hello" + ) + case let .customChat(systemPrompt, prompt): + commandType = .customChat + customChat = .init( + systemPrompt: systemPrompt ?? "", + prompt: prompt ?? "" + ) + case let .singleRoundDialog( + systemPrompt, + overwriteSystemPrompt, + prompt, + receiveReplyInNotification + ): + commandType = .singleRoundDialog + singleRoundDialog = .init( + systemPrompt: systemPrompt ?? "", + overwriteSystemPrompt: overwriteSystemPrompt ?? false, + prompt: prompt ?? "", + receiveReplyInNotification: receiveReplyInNotification ?? false + ) + case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): + commandType = .promptToCode + promptToCode = .init( + extraSystemPrompt: extraSystemPrompt ?? "", + prompt: prompt ?? "", + continuousMode: continuousMode ?? false, + generateDescription: generateDescription ?? true + ) + } + } + } + + enum Action: BindableAction, Equatable { + case saveCommand + case close + case binding(BindingAction) + case sendMessage(EditSendMessageCommand.Action) + case promptToCode(EditPromptToCodeCommand.Action) + case customChat(EditCustomChatCommand.Action) + case singleRoundDialog(EditSingleRoundDialogCommand.Action) + } + + let settings: CustomCommandView.Settings + let toast: (Text, ToastType) -> Void + + var body: some ReducerProtocol { + Scope(state: \.sendMessage, action: /Action.sendMessage) { + EditSendMessageCommand() + } + + Scope(state: \.promptToCode, action: /Action.promptToCode) { + EditPromptToCodeCommand() + } + + Scope(state: \.customChat, action: /Action.customChat) { + EditCustomChatCommand() + } + + Scope(state: \.singleRoundDialog, action: /Action.singleRoundDialog) { + EditSingleRoundDialogCommand() + } + + BindingReducer() + + Reduce { state, action in + switch action { + case .saveCommand: + guard !state.name.isEmpty else { + toast(Text("Command name cannot be empty."), .error) + return .none + } + + let newCommand = CustomCommand( + commandId: state.commandId, + name: state.name, + feature: { + switch state.commandType { + case .sendMessage: + let state = state.sendMessage + return .chatWithSelection( + extraSystemPrompt: state.extraSystemPrompt, + prompt: state.prompt, + useExtraSystemPrompt: state.useExtraSystemPrompt + ) + case .promptToCode: + let state = state.promptToCode + return .promptToCode( + extraSystemPrompt: state.extraSystemPrompt, + prompt: state.prompt, + continuousMode: state.continuousMode, + generateDescription: state.generateDescription + ) + case .customChat: + let state = state.customChat + return .customChat( + systemPrompt: state.systemPrompt, + prompt: state.prompt + ) + case .singleRoundDialog: + let state = state.singleRoundDialog + return .singleRoundDialog( + systemPrompt: state.systemPrompt, + overwriteSystemPrompt: state.overwriteSystemPrompt, + prompt: state.prompt, + receiveReplyInNotification: state.receiveReplyInNotification + ) + } + }() + ) + + if state.isNewCommand { + settings.customCommands.append(newCommand) + state.isNewCommand = false + toast(Text("The command is created."), .info) + } else { + if let index = settings.customCommands.firstIndex(where: { + $0.id == newCommand.id + }) { + settings.customCommands[index] = newCommand + } else { + settings.customCommands.append(newCommand) + } + toast(Text("The command is updated."), .info) + } + + return .none + + case .close: + return .none + + case .binding: + return .none + case .sendMessage: + return .none + case .promptToCode: + return .none + case .customChat: + return .none + case .singleRoundDialog: + return .none + } + } + } +} + +struct EditSendMessageCommand: ReducerProtocol { + struct State: Equatable { + @BindingState var extraSystemPrompt: String = "" + @BindingState var useExtraSystemPrompt: Bool = false + @BindingState var prompt: String = "" + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { _, action in + switch action { + case .binding: + return .none + } + } + } +} + +struct EditPromptToCodeCommand: ReducerProtocol { + struct State: Equatable { + @BindingState var extraSystemPrompt: String = "" + @BindingState var prompt: String = "" + @BindingState var continuousMode: Bool = false + @BindingState var generateDescription: Bool = false + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerProtocol { + BindingReducer() + } +} + +struct EditCustomChatCommand: ReducerProtocol { + struct State: Equatable { + @BindingState var systemPrompt: String = "" + @BindingState var prompt: String = "" + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerProtocol { + BindingReducer() + } +} + +struct EditSingleRoundDialogCommand: ReducerProtocol { + struct State: Equatable { + @BindingState var systemPrompt: String = "" + @BindingState var overwriteSystemPrompt: Bool = false + @BindingState var prompt: String = "" + @BindingState var receiveReplyInNotification: Bool = false + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerProtocol { + BindingReducer() + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift new file mode 100644 index 00000000..70754213 --- /dev/null +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -0,0 +1,264 @@ +import ComposableArchitecture +import MarkdownUI +import Preferences +import SwiftUI + +@MainActor +struct EditCustomCommandView: View { + @Environment(\.toast) var toast + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + ScrollView { + Form { + sharedForm + featureSpecificForm + }.padding() + }.safeAreaInset(edge: .bottom) { + bottomBar + } + } + + @ViewBuilder var sharedForm: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + TextField("Name", text: viewStore.$name) + + Picker("Command Type", selection: viewStore.$commandType) { + ForEach( + EditCustomCommand.CommandType.allCases, + id: \.rawValue + ) { commandType in + Text({ + switch commandType { + case .sendMessage: + return "Send Message" + case .promptToCode: + return "Prompt to Code" + case .customChat: + return "Custom Chat" + case .singleRoundDialog: + return "Single Round Dialog" + } + }() as String).tag(commandType) + } + } + } + } + + @ViewBuilder var featureSpecificForm: some View { + WithViewStore( + store, + observe: { $0.commandType } + ) { viewStore in + switch viewStore.state { + case .sendMessage: + EditSendMessageCommandView( + store: store.scope( + state: \.sendMessage, + action: EditCustomCommand.Action.sendMessage + ) + ) + case .promptToCode: + EditPromptToCodeCommandView( + store: store.scope( + state: \.promptToCode, + action: EditCustomCommand.Action.promptToCode + ) + ) + case .customChat: + EditCustomChatCommandView( + store: store.scope( + state: \.customChat, + action: EditCustomCommand.Action.customChat + ) + ) + case .singleRoundDialog: + EditSingleRoundDialogCommandView( + store: store.scope( + state: \.singleRoundDialog, + action: EditCustomCommand.Action.singleRoundDialog + ) + ) + } + } + } + + @ViewBuilder var bottomBar: some View { + VStack { + Divider() + + VStack(alignment: .trailing) { + Text( + "After renaming or adding a custom command, please restart Xcode to refresh the menu." + ) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Button("Close") { + store.send(.close) + } + + WithViewStore(store, observe: { $0.isNewCommand }) { viewStore in + if viewStore.state { + Button("Add") { + store.send(.saveCommand) + } + } else { + Button("Save") { + store.send(.saveCommand) + } + } + } + } + } + .padding(.horizontal) + } + .padding(.bottom) + .background(.regularMaterial) + } +} + +struct EditSendMessageCommandView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + VStack(alignment: .leading, spacing: 4) { + Toggle("Extra System Prompt", isOn: viewStore.$useExtraSystemPrompt) + EditableText(text: viewStore.$extraSystemPrompt) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 4) { + Text("Prompt") + EditableText(text: viewStore.$prompt) + } + .padding(.vertical, 4) + } + } +} + +struct EditPromptToCodeCommandView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + Toggle("Continuous Mode", isOn: viewStore.$continuousMode) + Toggle("Generate Description", isOn: viewStore.$generateDescription) + + VStack(alignment: .leading, spacing: 4) { + Text("Extra Context") + EditableText(text: viewStore.$extraSystemPrompt) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 4) { + Text("Prompt") + EditableText(text: viewStore.$prompt) + } + .padding(.vertical, 4) + } + } +} + +struct EditCustomChatCommandView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + VStack(alignment: .leading, spacing: 4) { + Text("System Prompt") + EditableText(text: viewStore.$systemPrompt) + } + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 4) { + Text("Prompt") + EditableText(text: viewStore.$prompt) + } + .padding(.vertical, 4) + } + } +} + +struct EditSingleRoundDialogCommandView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + VStack(alignment: .leading, spacing: 4) { + Text("System Prompt") + EditableText(text: viewStore.$systemPrompt) + } + .padding(.vertical, 4) + + Picker(selection: viewStore.$overwriteSystemPrompt) { + Text("Append to Default System Prompt").tag(false) + Text("Overwrite Default System Prompt").tag(true) + } label: { + Text("Mode") + } + .pickerStyle(.radioGroup) + + VStack(alignment: .leading, spacing: 4) { + Text("Prompt") + EditableText(text: viewStore.$prompt) + } + .padding(.vertical, 4) + + Toggle("Receive Reply in Notification", isOn: viewStore.$receiveReplyInNotification) + Text( + "You will be prompted to grant the app permission to send notifications for the first time." + ) + .font(.footnote) + .foregroundColor(.secondary) + } + } +} + + + +// MARK: - Preview + +struct EditCustomCommandView_Preview: PreviewProvider { + static var previews: some View { + EditCustomCommandView( + store: .init( + initialState: .init(.init( + commandId: "4", + name: "Explain Code", + feature: .promptToCode( + extraSystemPrompt: nil, + prompt: "Hello", + continuousMode: false, + generateDescription: true + ) + )), + reducer: EditCustomCommand( + settings: .init(customCommands: .init( + wrappedValue: [], + "CustomCommandView_Preview" + )), + toast: { _, _ in } + ) + ) + ) + .frame(width: 800) + } +} + +struct EditSingleRoundDialogCommandView_Preview: PreviewProvider { + static var previews: some View { + EditSingleRoundDialogCommandView(store: .init( + initialState: .init(), + reducer: EditSingleRoundDialogCommand() + )) + .frame(width: 800, height: 600) + } +} + diff --git a/Core/Sources/HostApp/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandView.swift deleted file mode 100644 index 46f0c5eb..00000000 --- a/Core/Sources/HostApp/CustomCommandView.swift +++ /dev/null @@ -1,427 +0,0 @@ -import Preferences -import SwiftUI - -extension List { - @ViewBuilder - func removeBackground() -> some View { - if #available(macOS 13.0, *) { - scrollContentBackground(.hidden) - } else { - background(Color.clear) - } - } -} - -struct CustomCommandView: View { - final class Settings: ObservableObject { - @AppStorage(\.customCommands) var customCommands - var illegalNames: [String] { - let existed = customCommands.map(\.name) - let builtin: [String] = [ - "Get Suggestions", - "Accept Suggestion", - "Reject Suggestion", - "Next Suggestion", - "Previous Suggestion", - "Real-time Suggestions", - "Prefetch Suggestions", - "Open Chat", - "Prompt to Code", - ] - - return existed + builtin - } - - init(customCommands: AppStorage<[CustomCommand]>? = nil) { - if let list = customCommands { - _customCommands = list - } - } - } - - struct EditingCommand { - var isNew: Bool - var command: CustomCommand - } - - @State var editingCommand: EditingCommand? - - @StateObject var settings = Settings() - - var body: some View { - HStack(spacing: 0) { - List { - ForEach(settings.customCommands, id: \.name) { command in - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal") - - VStack(alignment: .leading) { - Text(command.name) - .foregroundStyle(.primary) - - Group { - switch command.feature { - case .chatWithSelection: - Text("Open Chat") - case .customChat: - Text("Custom Chat") - case .promptToCode: - Text("Prompt to Code") - } - } - .font(.caption) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - editingCommand = .init(isNew: false, command: command) - } - } - .padding(4) - .background( - editingCommand?.command.id == command.id - ? Color.primary.opacity(0.05) - : Color.clear, - in: RoundedRectangle(cornerRadius: 4) - ) - .contextMenu { - Button("Remove") { - settings.customCommands.removeAll( - where: { $0.id == command.id } - ) - if let editingCommand, editingCommand.command.id == command.id { - self.editingCommand = nil - } - } - } - } - .onMove(perform: { indices, newOffset in - settings.customCommands.move(fromOffsets: indices, toOffset: newOffset) - }) - } - .removeBackground() - .padding(.vertical, 4) - .listStyle(.plain) - .frame(width: 200) - .background(Color.primary.opacity(0.05)) - .overlay { - if settings.customCommands.isEmpty { - Text(""" - Empty - Add command with "+" button - """) - .multilineTextAlignment(.center) - } - } - .safeAreaInset(edge: .bottom) { - Button(action: { - editingCommand = .init(isNew: true, command: CustomCommand( - commandId: UUID().uuidString, - name: "New Command", - feature: .chatWithSelection( - extraSystemPrompt: nil, - prompt: "Tell me about the code.", - useExtraSystemPrompt: false - ) - )) - }) { - Text(Image(systemName: "plus.circle.fill")) + Text(" New Command") - } - .buttonStyle(.plain) - .padding() - } - - Divider() - - if let editingCommand { - EditCustomCommandView( - editingCommand: $editingCommand, - settings: settings - ).id(editingCommand.command.id) - } else { - Color.clear - } - } - } -} - -struct EditCustomCommandView: View { - @Environment(\.toast) var toast - @Binding var editingCommand: CustomCommandView.EditingCommand? - var settings: CustomCommandView.Settings - let originalName: String - @State var commandType: CommandType - - @State var name: String - @State var prompt: String - @State var systemPrompt: String - @State var usePrompt: Bool - @State var continuousMode: Bool - @State var editingContentInFullScreen: Binding? - @State var generatingPromptToCodeDescription: Bool - - enum CommandType: Int, CaseIterable { - case chatWithSelection - case promptToCode - case customChat - } - - init( - editingCommand: Binding, - settings: CustomCommandView.Settings - ) { - _editingCommand = editingCommand - self.settings = settings - originalName = editingCommand.wrappedValue?.command.name ?? "" - name = originalName - switch editingCommand.wrappedValue?.command.feature { - case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): - commandType = .chatWithSelection - self.prompt = prompt ?? "" - systemPrompt = extraSystemPrompt ?? "" - usePrompt = useExtraSystemPrompt ?? true - continuousMode = false - generatingPromptToCodeDescription = true - case let .customChat(systemPrompt, prompt): - commandType = .customChat - self.systemPrompt = systemPrompt ?? "" - self.prompt = prompt ?? "" - usePrompt = false - continuousMode = false - generatingPromptToCodeDescription = true - case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): - commandType = .promptToCode - self.prompt = prompt ?? "" - systemPrompt = extraSystemPrompt ?? "" - usePrompt = false - self.continuousMode = continuousMode ?? false - generatingPromptToCodeDescription = generateDescription ?? true - case .none: - commandType = .chatWithSelection - prompt = "" - systemPrompt = "" - continuousMode = false - usePrompt = true - generatingPromptToCodeDescription = true - } - } - - var body: some View { - ScrollView { - Form { - TextField("Name", text: $name) - - Picker("Command Type", selection: $commandType) { - ForEach(CommandType.allCases, id: \.rawValue) { commandType in - Text({ - switch commandType { - case .chatWithSelection: - return "Open Chat" - case .promptToCode: - return "Prompt to Code" - case .customChat: - return "Custom Chat" - } - }() as String).tag(commandType) - } - } - - switch commandType { - case .chatWithSelection: - systemPromptTextField(title: "Extra System Prompt", hasToggle: true) - promptTextField - case .promptToCode: - continuousModeToggle - generateDescriptionToggle - systemPromptTextField(title: "Extra System Prompt", hasToggle: false) - promptTextField - case .customChat: - systemPromptTextField(hasToggle: false) - promptTextField - } - }.padding() - }.safeAreaInset(edge: .bottom) { - VStack { - Divider() - - VStack { - Text( - "After renaming or adding a custom command, please restart Xcode to refresh the menu." - ) - .foregroundStyle(.secondary) - - HStack { - Spacer() - Button("Close") { - editingCommand = nil - } - - lazy var newCommand = CustomCommand( - commandId: editingCommand?.command.id ?? UUID().uuidString, - name: name, - feature: { - switch commandType { - case .chatWithSelection: - return .chatWithSelection( - extraSystemPrompt: systemPrompt, - prompt: prompt, - useExtraSystemPrompt: usePrompt - ) - case .promptToCode: - return .promptToCode( - extraSystemPrompt: systemPrompt, - prompt: prompt, - continuousMode: continuousMode, - generateDescription: generatingPromptToCodeDescription - ) - case .customChat: - return .customChat( - systemPrompt: systemPrompt, - prompt: prompt - ) - } - }() - ) - - if editingCommand?.isNew ?? true { - Button("Add") { - guard !settings.illegalNames.contains(newCommand.name) else { - toast(Text("Command name is illegal."), .error) - return - } - guard !newCommand.name.isEmpty else { - toast(Text("Command name cannot be empty."), .error) - return - } - settings.customCommands.append(newCommand) - editingCommand?.isNew = false - editingCommand?.command = newCommand - - toast(Text("The command is created."), .info) - } - } else { - Button("Save") { - guard !settings.illegalNames.contains(newCommand.name) - || newCommand.name == originalName - else { - toast(Text("Command name is illegal."), .error) - return - } - guard !newCommand.name.isEmpty else { - toast(Text("Command name cannot be empty."), .error) - return - } - - if let index = settings.customCommands.firstIndex(where: { - $0.id == newCommand.id - }) { - settings.customCommands[index] = newCommand - } else { - settings.customCommands.append(newCommand) - } - - toast(Text("The command is updated."), .info) - } - } - } - } - .padding(.horizontal) - } - .padding(.bottom) - .background(.regularMaterial) - } - } - - @ViewBuilder - var promptTextField: some View { - VStack(alignment: .leading, spacing: 4) { - Text("Prompt") - EditableText(text: $prompt) - } - .padding(.vertical, 4) - } - - @ViewBuilder - func systemPromptTextField(title: String? = nil, hasToggle: Bool) -> some View { - VStack(alignment: .leading, spacing: 4) { - if hasToggle { - Toggle(title ?? "System Prompt", isOn: $usePrompt) - } else { - Text(title ?? "System Prompt") - } - EditableText(text: $systemPrompt) - } - .padding(.vertical, 4) - } - - var continuousModeToggle: some View { - Toggle("Continuous Mode", isOn: $continuousMode) - } - - var generateDescriptionToggle: some View { - Toggle("Generate Description", isOn: $generatingPromptToCodeDescription) - } -} - -// MARK: - Previews - -struct CustomCommandView_Preview: PreviewProvider { - static var previews: some View { - CustomCommandView( - editingCommand: .init(isNew: false, command: .init( - commandId: "1", - name: "Explain Code", - feature: .chatWithSelection( - extraSystemPrompt: nil, - prompt: "Hello", - useExtraSystemPrompt: false - ) - )), - settings: .init(customCommands: .init(wrappedValue: [ - .init( - commandId: "1", - name: "Explain Code", - feature: .chatWithSelection( - extraSystemPrompt: nil, - prompt: "Hello", - useExtraSystemPrompt: false - ) - ), - .init( - commandId: "2", - name: "Refactor Code", - feature: .promptToCode( - extraSystemPrompt: nil, - prompt: "Refactor", - continuousMode: false, - generateDescription: true - ) - ), - ], "CustomCommandView_Preview")) - ) - } -} - -struct EditCustomCommandView_Preview: PreviewProvider { - static var previews: some View { - EditCustomCommandView( - editingCommand: .constant(CustomCommandView.EditingCommand( - isNew: false, - command: .init( - commandId: "4", - name: "Explain Code", - feature: .promptToCode( - extraSystemPrompt: nil, - prompt: "Hello", - continuousMode: false, - generateDescription: true - ) - ) - )), - settings: .init(customCommands: .init(wrappedValue: [], "CustomCommandView_Preview")) - ) - .frame(width: 800) - } -} - diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index 617ddd4d..e977926a 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -21,6 +21,8 @@ struct ChatSettingsView: View { @AppStorage(\.chatGPTModel) var chatGPTModel @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations + + @AppStorage(\.embeddingFeatureProvider) var embeddingFeatureProvider init() {} } @@ -46,12 +48,20 @@ struct ChatSettingsView: View { var chatSettingsForm: some View { Form { Picker( - "Feature Provider", + "Chat Feature Provider", selection: $settings.chatFeatureProvider ) { Text("OpenAI").tag(ChatFeatureProvider.openAI) Text("Azure OpenAI").tag(ChatFeatureProvider.azureOpenAI) } + + Picker( + "Embedding Feature Provider", + selection: $settings.embeddingFeatureProvider + ) { + Text("OpenAI").tag(EmbeddingFeatureProvider.openAI) + Text("Azure OpenAI").tag(EmbeddingFeatureProvider.azureOpenAI) + } if #available(macOS 13.0, *) { LabeledContent("Reply in Language") { diff --git a/Core/Sources/HostApp/SharedComponents/EditableText.swift b/Core/Sources/HostApp/SharedComponents/EditableText.swift index b5186ea0..46464f33 100644 --- a/Core/Sources/HostApp/SharedComponents/EditableText.swift +++ b/Core/Sources/HostApp/SharedComponents/EditableText.swift @@ -1,6 +1,16 @@ import Foundation import SwiftUI +// Hack to disable smart quotes and dashes in TextEditor +extension NSTextView { + open override var frame: CGRect { + didSet { + self.isAutomaticQuoteSubstitutionEnabled = false + self.isAutomaticDashSubstitutionEnabled = false + } + } +} + struct EditableText: View { var text: Binding @State var isEditing: Bool = false diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index c960716d..24db9ee4 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -11,14 +11,19 @@ enum Tab: Int, CaseIterable, Equatable { case debug } +@MainActor +let globalToastController = ToastController(messages: []) + public struct TabContainer: View { - @StateObject var toastController = ToastController(messages: []) + @ObservedObject var toastController: ToastController @State var tab = Tab.general - public init() {} + public init() { + self.toastController = globalToastController + } init(toastController: ToastController) { - _toastController = StateObject(wrappedValue: toastController) + self.toastController = toastController } public var body: some View { @@ -37,7 +42,7 @@ public struct TabContainer: View { case .feature: FeatureSettingsView() case .customCommand: - CustomCommandView() + CustomCommandView(store: customCommandStore) case .debug: DebugSettingsView() } diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift index 709ec2cb..5b1a68d2 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift @@ -147,10 +147,20 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { ### """ - let chatGPTService = ChatGPTService(systemPrompt: systemPrompt, temperature: 0.3) + let configuration = UserPreferenceChatGPTConfiguration() + .overriding(.init(temperature: 0)) + let memory = AutoManagedChatGPTMemory( + systemPrompt: systemPrompt, + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() + ) + let chatGPTService = ChatGPTService( + memory: memory, + configuration: configuration + ) service = chatGPTService if let firstMessage { - await chatGPTService.mutateHistory { history in + await memory.mutateHistory { history in history.append(.init(role: .user, content: firstMessage)) } } diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift index bcc0ec4f..f7fc7196 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift @@ -1,38 +1,157 @@ import AppKit +import ChatTab +import ComposableArchitecture import Environment +import Preferences import SuggestionWidget -@MainActor -public final class GraphicalUserInterfaceController { - public nonisolated static let shared = GraphicalUserInterfaceController() - nonisolated let suggestionWidget = SuggestionWidgetController() - private nonisolated init() { - Task { @MainActor in - suggestionWidget.dataSource = WidgetDataSource.shared - suggestionWidget.onOpenChatClicked = { [weak self] in - Task { - let uri = try await Environment.fetchFocusedElementURI() - let dataSource = WidgetDataSource.shared - await dataSource.createChatIfNeeded(for: uri) - self?.suggestionWidget.presentChatRoom(fileURL: uri) +struct GUI: ReducerProtocol { + struct State: Equatable { + var suggestionWidgetState = WidgetFeature.State() + + var chatTabGroup: ChatPanelFeature.ChatTabGroup { + get { suggestionWidgetState.chatPanelState.chatTapGroup } + set { suggestionWidgetState.chatPanelState.chatTapGroup = newValue } + } + } + + enum Action { + case openChatPanel(forceDetach: Bool) + case createChatGPTChatTabIfNeeded + case sendCustomCommandToActiveChat(CustomCommand) + + case suggestionWidget(WidgetFeature.Action) + } + + var body: some ReducerProtocol { + Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) { + WidgetFeature() + } + + Scope( + state: \.chatTabGroup, + action: /Action.suggestionWidget .. /WidgetFeature.Action.chatPanel + ) { + Reduce { _, action in + switch action { + case let .createNewTapButtonClicked(type): + _ = type // always ChatGPTChatTab at the moment. + let chatTap = ChatGPTChatTab() + return .run { send in + await send(.appendAndSelectTab(chatTap)) + } + + default: + return .none } } - suggestionWidget.onCustomCommandClicked = { command in - Task { - let commandHandler = PseudoCommandHandler() - await commandHandler.handleCustomCommand(command) + } + + Reduce { state, action in + switch action { + case let .openChatPanel(forceDetach): + return .run { send in + await send( + .suggestionWidget(.chatPanel(.presentChatPanel(forceDetach: forceDetach))) + ) + } + + case .createChatGPTChatTabIfNeeded: + if state.chatTabGroup.tabs.contains(where: { $0 is ChatGPTChatTab }) { + return .none + } + let chatTab = ChatGPTChatTab() + state.chatTabGroup.tabs.append(chatTab) + return .none + + case let .sendCustomCommandToActiveChat(command): + @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async { + if tab.service.isReceivingMessage { + await tab.service.stopReceivingMessage() + } + try? await tab.service.handleCustomCommand(command) + } + + if let activeTab = state.chatTabGroup.activeChatTab as? ChatGPTChatTab { + return .run { send in + await send(.openChatPanel(forceDetach: false)) + await stopAndHandleCommand(activeTab) + } } + + if let chatTab = state.chatTabGroup.tabs.first(where: { + guard $0 is ChatGPTChatTab else { return false } + return true + }) as? ChatGPTChatTab { + state.chatTabGroup.selectedTabId = chatTab.id + return .run { send in + await send(.openChatPanel(forceDetach: false)) + await stopAndHandleCommand(chatTab) + } + } + let chatTab = ChatGPTChatTab() + state.chatTabGroup.tabs.append(chatTab) + return .run { send in + await send(.openChatPanel(forceDetach: false)) + await stopAndHandleCommand(chatTab) + } + + case .suggestionWidget: + return .none } } } - +} + +@MainActor +public final class GraphicalUserInterfaceController { + public static let shared = GraphicalUserInterfaceController() + private let store: StoreOf + let widgetController: SuggestionWidgetController + let widgetDataSource: WidgetDataSource + let viewStore: ViewStoreOf + + private init() { + let suggestionDependency = SuggestionWidgetControllerDependency() + let store = StoreOf( + initialState: .init(), + reducer: GUI() + ) { dependencies in + dependencies.suggestionWidgetControllerDependency = suggestionDependency + dependencies.suggestionWidgetUserDefaultsObservers = .init() + } + self.store = store + viewStore = ViewStore(store) + widgetDataSource = .init() + + widgetController = SuggestionWidgetController( + store: store.scope( + state: \.suggestionWidgetState, + action: GUI.Action.suggestionWidget + ), + dependency: suggestionDependency + ) + + suggestionDependency.suggestionWidgetDataSource = widgetDataSource + suggestionDependency.onOpenChatClicked = { [weak self] in + Task { [weak self] in + await self?.viewStore.send(.createChatGPTChatTabIfNeeded).finish() + self?.viewStore.send(.openChatPanel(forceDetach: false)) + } + } + suggestionDependency.onCustomCommandClicked = { command in + Task { + let commandHandler = PseudoCommandHandler() + await commandHandler.handleCustomCommand(command) + } + } + } + public func openGlobalChat() { - UserDefaults.shared.set(true, for: \.useGlobalChat) - let dataSource = WidgetDataSource.shared - let fakeFileURL = URL(fileURLWithPath: "/") Task { - await dataSource.createChatIfNeeded(for: fakeFileURL) - suggestionWidget.presentDetachedGlobalChat() + await self.viewStore.send(.createChatGPTChatTabIfNeeded).finish() + viewStore.send(.openChatPanel(forceDetach: true)) } } } + diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift index 6cc9abab..fdb3e15c 100644 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift @@ -60,6 +60,7 @@ extension PromptToCodeProvider { let handler = PseudoCommandHandler() await handler.acceptSuggestion() if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { + try await Task.sleep(nanoseconds: 200_000_000) app.activate() } } diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 5c67c2c4..5f63b9ae 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -1,5 +1,7 @@ import ActiveApplicationMonitor import ChatService +import ChatTab +import ComposableArchitecture import Foundation import GitHubCopilotService import OpenAIService @@ -7,19 +9,8 @@ import PromptToCodeService import SuggestionModel import SuggestionWidget -@ServiceActor +@MainActor final class WidgetDataSource { - static let shared = WidgetDataSource() - - final class Chat { - let chatService: ChatService - let provider: ChatProvider - public init(chatService: ChatService, provider: ChatProvider) { - self.chatService = chatService - self.provider = provider - } - } - final class PromptToCode { let promptToCodeService: PromptToCodeService let provider: PromptToCodeProvider @@ -31,60 +22,10 @@ final class WidgetDataSource { self.provider = provider } } - - private(set) var globalChat: Chat? - private(set) var chats = [URL: Chat]() + private(set) var promptToCodes = [URL: PromptToCode]() - private init() {} - - @discardableResult - func createChatIfNeeded(for url: URL) -> ChatService { - let build = { - let service = ChatService(chatGPTService: ChatGPTService()) - let provider = ChatProvider( - service: service, - fileURL: url, - onCloseChat: { [weak self] in - if UserDefaults.shared.value(for: \.useGlobalChat) { - self?.globalChat = nil - } else { - self?.removeChat(for: url) - } - let presenter = PresentInWindowSuggestionPresenter() - presenter.closeChatRoom(fileURL: url) - if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { - app.activate() - } - }, - onSwitchContext: { [weak self] in - let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat) - UserDefaults.shared.set(!useGlobalChat, for: \.useGlobalChat) - self?.createChatIfNeeded(for: url) - let presenter = PresentInWindowSuggestionPresenter() - presenter.presentChatRoom(fileURL: url) - } - ) - return Chat(chatService: service, provider: provider) - } - - let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat) - if useGlobalChat { - if let globalChat { - return globalChat.chatService - } - let newChat = build() - globalChat = newChat - return newChat.chatService - } else { - if let chat = chats[url] { - return chat.chatService - } - let newChat = build() - chats[url] = newChat - return newChat.chatService - } - } + init() {} @discardableResult func createPromptToCode( @@ -121,7 +62,10 @@ final class WidgetDataSource { let presenter = PresentInWindowSuggestionPresenter() presenter.closePromptToCode(fileURL: url) if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { - app.activate() + Task { @MainActor in + try await Task.sleep(nanoseconds: 200_000_000) + app.activate() + } } } ) @@ -133,27 +77,22 @@ final class WidgetDataSource { return newPromptToCode.promptToCodeService } - func removeChat(for url: URL) { - chats[url] = nil - } - func removePromptToCode(for url: URL) { promptToCodes[url] = nil } func cleanup(for url: URL) { - removeChat(for: url) removePromptToCode(for: url) } } extension WidgetDataSource: SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> SuggestionProvider? { - for workspace in workspaces.values { - if let filespace = workspace.filespaces[url], - let suggestion = filespace.presentingSuggestion + for workspace in await workspaces.values { + if let filespace = await workspace.filespaces[url], + let suggestion = await filespace.presentingSuggestion { - return .init( + return await .init( code: suggestion.text, language: filespace.language, startLineIndex: suggestion.position.line, @@ -178,6 +117,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { + try await Task.sleep(nanoseconds: 200_000_000) app.activate() } } @@ -189,6 +129,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode { + try await Task.sleep(nanoseconds: 200_000_000) app.activate() } } @@ -199,21 +140,6 @@ extension WidgetDataSource: SuggestionWidgetDataSource { return nil } - func chatForFile(at url: URL) async -> ChatProvider? { - let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat) - if useGlobalChat { - if let globalChat { - return globalChat.provider - } - } else { - if let chat = chats[url] { - return chat.provider - } - } - - return nil - } - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { return promptToCodes[url]?.provider } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 44021edd..d3f28fd8 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -104,7 +104,7 @@ public class RealtimeSuggestionController { guard let focusElement = application.focusedElement else { return } let focusElementType = focusElement.description focusedUIElement = focusElement - + Task { // Notify suggestion service for open file. try await Task.sleep(nanoseconds: 500_000_000) let fileURL = try await Environment.fetchCurrentFileURL() @@ -118,6 +118,14 @@ public class RealtimeSuggestionController { editorObservationTask = nil editorObservationTask = Task { [weak self] in + let fileURL = try await Environment.fetchCurrentFileURL() + if let sourceEditor = self?.sourceEditor { + await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( + fileURL: fileURL, + sourceEditor: sourceEditor + ) + } + let notificationsFromEditor = AXNotificationStream( app: activeXcode, element: focusElement, @@ -135,8 +143,11 @@ public class RealtimeSuggestionController { await self.notifyEditingFileChange(editor: focusElement) case kAXSelectedTextChangedNotification: guard let sourceEditor else { continue } - await PseudoCommandHandler() - .invalidateRealtimeSuggestionsIfNeeded(sourceEditor: sourceEditor) + let fileURL = XcodeInspector.shared.activeDocumentURL + await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( + fileURL: fileURL, + sourceEditor: sourceEditor + ) default: continue } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 28d51d68..4a29ac25 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -11,7 +11,7 @@ public final class ScheduledCleaner { Task { @ServiceActor in while !Task.isCancelled { try await Task.sleep(nanoseconds: 10 * 60 * 1_000_000_000) - cleanUp() + await cleanUp() } } @@ -20,14 +20,14 @@ public final class ScheduledCleaner { for await app in ActiveApplicationMonitor.createStream() { try Task.checkCancellation() if let app, !app.isXcode { - cleanUp() + await cleanUp() } } } } @ServiceActor - func cleanUp() { + func cleanUp() async { let workspaceInfos = XcodeInspector.shared.xcodes.reduce( into: [ XcodeAppInstanceInspector.WorkspaceIdentifier: @@ -47,7 +47,7 @@ public final class ScheduledCleaner { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") for url in workspace.filespaces.keys { - WidgetDataSource.shared.cleanup(for: url) + await GraphicalUserInterfaceController.shared.widgetDataSource.cleanup(for: url) } workspace.cleanUp(availableTabs: []) workspaces[url] = nil @@ -62,7 +62,8 @@ public final class ScheduledCleaner { availableTabs: tabs ) { Logger.service.info("Remove idle filespace") - WidgetDataSource.shared.cleanup(for: url) + await GraphicalUserInterfaceController.shared.widgetDataSource + .cleanup(for: url) } } // cleanup workspace @@ -70,7 +71,7 @@ public final class ScheduledCleaner { } } } - + @ServiceActor public func closeAllChildProcesses() async { for (_, workspace) in workspaces { diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 0f6fcbfb..0a6356ad 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -43,27 +43,57 @@ struct PseudoCommandHandler { // Can't use handler if content is not available. guard let editor = await getEditorContent(sourceEditor: sourceEditor), - let filespace = await getFilespace() - else { return } + let filespace = await getFilespace(), + let (workspace, _) = try? await Workspace + .fetchOrCreateWorkspaceIfNeeded(fileURL: filespace.fileURL) else { return } + let fileURL = filespace.fileURL + let presenter = PresentInWindowSuggestionPresenter() + + presenter.markAsProcessing(true) + defer { presenter.markAsProcessing(false) } + + // Check if the current suggestion is still valid. if await filespace.validateSuggestions( lines: editor.lines, cursorPosition: editor.cursorPosition ) { return } else { - PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: filespace.fileURL) + presenter.discardSuggestion(fileURL: filespace.fileURL) } + + let snapshot = Filespace.Snapshot( + linesHash: editor.lines.hashValue, + cursorPosition: editor.cursorPosition + ) + + guard await filespace.suggestionSourceSnapshot != snapshot else { return } - // Otherwise, get it from pseudo handler directly. - let handler = WindowBaseCommandHandler() - _ = try? await handler.generateRealtimeSuggestions(editor: editor) + do { + try await workspace.generateSuggestions( + forFileAt: fileURL, + editor: editor + ) + if let sourceEditor { + _ = await filespace.validateSuggestions( + lines: sourceEditor.content.lines, + cursorPosition: sourceEditor.content.cursorPosition + ) + } + if await filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } catch { + return + } } - func invalidateRealtimeSuggestionsIfNeeded(sourceEditor: SourceEditor) async { - guard let fileURL = try? await Environment.fetchCurrentFileURL(), - let (_, filespace) = try? await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) else { return } + func invalidateRealtimeSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async { + guard let (_, filespace) = try? await Workspace + .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) else { return } if await !filespace.validateSuggestions( lines: sourceEditor.content.lines, @@ -94,7 +124,7 @@ struct PseudoCommandHandler { } switch command.feature { // editor content is not required. - case .customChat, .chatWithSelection: + case .customChat, .chatWithSelection, .singleRoundDialog: return .init( content: "", lines: [], diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 6761d5cd..1343115c 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -8,6 +8,7 @@ import OpenAIService import SuggestionInjector import SuggestionModel import SuggestionWidget +import UserNotifications import XPCShared @ServiceActor @@ -131,8 +132,10 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - if WidgetDataSource.shared.promptToCodes[fileURL]?.promptToCodeService != nil { - WidgetDataSource.shared.removePromptToCode(for: fileURL) + let dataSource = GraphicalUserInterfaceController.shared.widgetDataSource + + if await dataSource.promptToCodes[fileURL]?.promptToCodeService != nil { + await dataSource.removePromptToCode(for: fileURL) presenter.closePromptToCode(fileURL: fileURL) return } @@ -154,7 +157,9 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - if let service = WidgetDataSource.shared.promptToCodes[fileURL]?.promptToCodeService { + let dataSource = GraphicalUserInterfaceController.shared.widgetDataSource + + if let service = await dataSource.promptToCodes[fileURL]?.promptToCodeService { let suggestion = CodeSuggestion( text: service.code, position: service.selectionRange.start, @@ -177,7 +182,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { ) presenter.presentPromptToCode(fileURL: fileURL) } else { - WidgetDataSource.shared.removePromptToCode(for: fileURL) + await dataSource.removePromptToCode(for: fileURL) presenter.closePromptToCode(fileURL: fileURL) } @@ -232,17 +237,10 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { } func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { - Task { - do { - try await startChat( - specifiedSystemPrompt: nil, - extraSystemPrompt: nil, - sendingMessageImmediately: nil, - name: nil - ) - } catch { - presenter.presentError(error) - } + Task { @MainActor in + let viewStore = GraphicalUserInterfaceController.shared.viewStore + viewStore.send(.createChatGPTChatTabIfNeeded) + viewStore.send(.openChatPanel(forceDetach: false)) } return nil } @@ -288,21 +286,11 @@ extension WindowBaseCommandHandler { else { throw CommandNotFoundError() } switch command.feature { - case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): - let updatePrompt = useExtraSystemPrompt ?? true - try await startChat( - specifiedSystemPrompt: nil, - extraSystemPrompt: updatePrompt ? extraSystemPrompt : nil, - sendingMessageImmediately: prompt, - name: command.name - ) - case let .customChat(systemPrompt, prompt): - try await startChat( - specifiedSystemPrompt: systemPrompt, - extraSystemPrompt: "", - sendingMessageImmediately: prompt, - name: command.name - ) + case .chatWithSelection, .customChat: + Task { @MainActor in + GraphicalUserInterfaceController.shared.viewStore + .send(.sendCustomCommandToActiveChat(command)) + } case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): try await presentPromptToCode( editor: editor, @@ -312,6 +300,18 @@ extension WindowBaseCommandHandler { generateDescription: generateDescription, name: command.name ) + case let .singleRoundDialog( + systemPrompt, + overwriteSystemPrompt, + prompt, + receiveReplyInNotification + ): + try await executeSingleRoundDialog( + systemPrompt: systemPrompt, + overwriteSystemPrompt: overwriteSystemPrompt ?? false, + prompt: prompt ?? "", + receiveReplyInNotification: receiveReplyInNotification ?? false + ) } } @@ -369,7 +369,9 @@ extension WindowBaseCommandHandler { ) }() as (String, CursorRange) - let promptToCode = await WidgetDataSource.shared.createPromptToCode( + let dataSource = GraphicalUserInterfaceController.shared.widgetDataSource + + let promptToCode = await dataSource.createPromptToCode( for: fileURL, projectURL: workspace.projectRootURL, selectedCode: code, @@ -389,46 +391,44 @@ extension WindowBaseCommandHandler { presenter.presentPromptToCode(fileURL: fileURL) } - private func startChat( - specifiedSystemPrompt: String?, - extraSystemPrompt: String?, - sendingMessageImmediately: String?, - name: String? + func executeSingleRoundDialog( + systemPrompt: String?, + overwriteSystemPrompt: Bool, + prompt: String, + receiveReplyInNotification: Bool ) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } + guard !prompt.isEmpty else { return } - let focusedElementURI = try await Environment.fetchFocusedElementURI() + let service = ChatService() - let chat = WidgetDataSource.shared.createChatIfNeeded(for: focusedElementURI) - - let templateProcessor = CustomCommandTemplateProcessor() - chat.mutateSystemPrompt(specifiedSystemPrompt.map(templateProcessor.process)) - chat.mutateExtraSystemPrompt(extraSystemPrompt.map(templateProcessor.process) ?? "") + let result = try await service.handleSingleRoundDialogCommand( + systemPrompt: systemPrompt, + overwriteSystemPrompt: overwriteSystemPrompt, + prompt: prompt + ) - Task { - let customCommandPrefix = { - if let name { return "[\(name)] " } - return "" - }() + guard receiveReplyInNotification else { return } - if specifiedSystemPrompt != nil || extraSystemPrompt != nil { - await chat.chatGPTService.mutateHistory { history in - history.append(.init( - role: .assistant, - content: "", - summary: "\(customCommandPrefix)System prompt is updated." - )) - } - } + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert]) - if let sendingMessageImmediately, !sendingMessageImmediately.isEmpty { - try await chat - .send(content: templateProcessor.process(sendingMessageImmediately)) + if granted { + let content = UNMutableNotificationContent() + content.title = "Reply" + content.body = result + let request = UNNotificationRequest( + identifier: "reply", + content: content, + trigger: nil + ) + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + presenter.presentError(error) } + } else { + presenter.presentErrorMessage("Notification permission is not granted.") } - - presenter.presentChatRoom(fileURL: focusedElementURI) } } diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 4e09de2b..9cb45971 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -1,27 +1,27 @@ import ChatService -import SuggestionModel import Foundation import OpenAIService +import SuggestionModel import SuggestionWidget struct PresentInWindowSuggestionPresenter { func presentSuggestion(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.suggestCode(fileURL: fileURL) + let controller = GraphicalUserInterfaceController.shared.widgetController + controller.suggestCode() } } func discardSuggestion(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.discardSuggestion(fileURL: fileURL) + let controller = GraphicalUserInterfaceController.shared.widgetController + controller.discardSuggestion() } } func markAsProcessing(_ isProcessing: Bool) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget + let controller = GraphicalUserInterfaceController.shared.widgetController controller.markAsProcessing(isProcessing) } } @@ -30,43 +30,44 @@ struct PresentInWindowSuggestionPresenter { if error is CancellationError { return } if let urlError = error as? URLError, urlError.code == URLError.cancelled { return } Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget + let controller = GraphicalUserInterfaceController.shared.widgetController controller.presentError(error.localizedDescription) } } func presentErrorMessage(_ message: String) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget + let controller = GraphicalUserInterfaceController.shared.widgetController controller.presentError(message) } } func closeChatRoom(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.closeChatRoom(fileURL: fileURL) + let controller = GraphicalUserInterfaceController.shared.widgetController + controller.closeChatRoom() } } func presentChatRoom(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.presentChatRoom(fileURL: fileURL) + let controller = GraphicalUserInterfaceController.shared.widgetController + controller.presentChatRoom() } } - + func presentPromptToCode(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.presentPromptToCode(fileURL: fileURL) + let controller = GraphicalUserInterfaceController.shared.widgetController + controller.presentPromptToCode() } } - + func closePromptToCode(fileURL: URL) { Task { @MainActor in - let controller = GraphicalUserInterfaceController.shared.suggestionWidget - controller.discardPromptToCode(fileURL: fileURL) + let controller = GraphicalUserInterfaceController.shared.widgetController + controller.discardPromptToCode() } } } + diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift index fba0abe6..247fa2f3 100644 --- a/Core/Sources/Service/Workspace.swift +++ b/Core/Sources/Service/Workspace.swift @@ -68,31 +68,50 @@ final class Filespace { lastSuggestionUpdateTime = Environment.now() } + /// Validate the suggestion is still valid. + /// - Parameters: + /// - lines: lines of the file + /// - cursorPosition: cursor position + /// - Returns: `true` if the suggestion is still valid func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { - if cursorPosition.line != suggestionSourceSnapshot.cursorPosition.line { + guard let presentingSuggestion else { return false } + + // cursor has moved to another line + if cursorPosition.line != presentingSuggestion.position.line { reset() return false } + // the cursor position is valid guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { reset() return false } let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n - let suggestionLines = presentingSuggestion?.text.split(separator: "\n") ?? [] + let suggestionLines = presentingSuggestion.text.split(separator: "\n") let suggestionFirstLine = suggestionLines.first ?? "" - if !suggestionFirstLine.hasPrefix(editingLine) { + + // the line content doesn't match the suggestion + if cursorPosition.character > 0, + !suggestionFirstLine.hasPrefix(editingLine[..<(editingLine.index( + editingLine.startIndex, + offsetBy: cursorPosition.character, + limitedBy: editingLine.endIndex + ) ?? editingLine.endIndex)]) + { reset() return false } - - if editingLine == suggestionFirstLine, suggestionLines.count <= 1 { + + // finished typing the whole suggestion when the suggestion has only one line + if editingLine.hasPrefix(suggestionFirstLine), suggestionLines.count <= 1 { reset() return false } - if editingLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // undo to a state before the suggestion was generated + if editingLine.count < presentingSuggestion.position.character { reset() return false } @@ -282,10 +301,6 @@ extension Workspace { let filespace = createFilespaceIfNeeded(fileURL: fileURL) - if filespaces[fileURL] == nil { - filespaces[fileURL] = filespace - } - if !editor.uti.isEmpty { filespace.uti = editor.uti filespace.tabSize = editor.tabSize diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 5723b330..892498eb 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -46,7 +46,6 @@ public class XPCService: NSObject, XPCServiceProtocol { let task = Task { do { let editor = try JSONDecoder().decode(EditorContent.self, from: editorContent) - let mode = UserDefaults.shared.value(for: \.suggestionPresentationMode) let handler: SuggestionCommandHandler = WindowBaseCommandHandler() try Task.checkCancellation() guard let updatedContent = try await getUpdatedContent(handler, editor) else { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlock.swift b/Core/Sources/SharedUIComponents/CodeBlock.swift similarity index 89% rename from Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlock.swift rename to Core/Sources/SharedUIComponents/CodeBlock.swift index 77eae975..191d385f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlock.swift +++ b/Core/Sources/SharedUIComponents/CodeBlock.swift @@ -1,16 +1,16 @@ import SwiftUI -struct CodeBlock: View { - let code: String - let language: String - let startLineIndex: Int - let colorScheme: ColorScheme - let commonPrecedingSpaceCount: Int - let highlightedCode: [NSAttributedString] - let firstLinePrecedingSpaceCount: Int - let fontSize: Double +public struct CodeBlock: View { + public let code: String + public let language: String + public let startLineIndex: Int + public let colorScheme: ColorScheme + public let commonPrecedingSpaceCount: Int + public let highlightedCode: [NSAttributedString] + public let firstLinePrecedingSpaceCount: Int + public let fontSize: Double - init( + public init( code: String, language: String, startLineIndex: Int, @@ -37,7 +37,7 @@ struct CodeBlock: View { highlightedCode = result.code } - var body: some View { + public var body: some View { VStack(spacing: 2) { ForEach(0.. Void +public struct CopyButton: View { + public var copy: () -> Void @State var isCopied = false - var body: some View { + + public init(copy: @escaping () -> Void) { + self.copy = copy + } + + public var body: some View { Button(action: { withAnimation(.linear(duration: 0.1)) { isCopied = true diff --git a/Core/Sources/SuggestionWidget/CustomScrollView/CustomScrollView.swift b/Core/Sources/SharedUIComponents/CustomScrollView.swift similarity index 71% rename from Core/Sources/SuggestionWidget/CustomScrollView/CustomScrollView.swift rename to Core/Sources/SharedUIComponents/CustomScrollView.swift index 6828dce4..f66cb3fa 100644 --- a/Core/Sources/SuggestionWidget/CustomScrollView/CustomScrollView.swift +++ b/Core/Sources/SharedUIComponents/CustomScrollView.swift @@ -1,16 +1,17 @@ import AppKit import Combine +import Preferences import SwiftUI -struct CustomScrollViewHeightPreferenceKey: PreferenceKey { - static var defaultValue: Double = 0 - static func reduce(value: inout Double, nextValue: () -> Double) { +public struct CustomScrollViewHeightPreferenceKey: SwiftUI.PreferenceKey { + public static var defaultValue: Double = 0 + public static func reduce(value: inout Double, nextValue: () -> Double) { value = nextValue() + value } } -struct CustomScrollViewUpdateHeightModifier: ViewModifier { - func body(content: Content) -> some View { +public struct CustomScrollViewUpdateHeightModifier: ViewModifier { + public func body(content: Content) -> some View { content .background { GeometryReader { proxy in @@ -25,12 +26,16 @@ struct CustomScrollViewUpdateHeightModifier: ViewModifier { } /// Used to workaround a SwiftUI bug. https://github.com/intitni/CopilotForXcode/issues/122 -struct CustomScrollView: View { +public struct CustomScrollView: View { @ViewBuilder var content: () -> Content @State var height: Double = 10 @AppStorage(\.useCustomScrollViewWorkaround) var useNSScrollViewWrapper - var body: some View { + public init(content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { if useNSScrollViewWrapper { List { content() diff --git a/Core/Sources/SuggestionWidget/CustomTextEditor.swift b/Core/Sources/SharedUIComponents/CustomTextEditor.swift similarity index 63% rename from Core/Sources/SuggestionWidget/CustomTextEditor.swift rename to Core/Sources/SharedUIComponents/CustomTextEditor.swift index 6b14307c..609bd02b 100644 --- a/Core/Sources/SuggestionWidget/CustomTextEditor.swift +++ b/Core/Sources/SharedUIComponents/CustomTextEditor.swift @@ -1,19 +1,29 @@ import SwiftUI -struct CustomTextEditor: NSViewRepresentable { - func makeCoordinator() -> Coordinator { +public struct CustomTextEditor: NSViewRepresentable { + public func makeCoordinator() -> Coordinator { Coordinator(self) } - @Binding var text: String - let font: NSFont - let onSubmit: () -> Void - var completions: (_ text: String, _ words: [String], _ range: NSRange) - -> [String] = { _, _, _ in - [] - } + @Binding public var text: String + public let font: NSFont + public let onSubmit: () -> Void + public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] + + public init( + text: Binding, + font: NSFont, + onSubmit: @escaping () -> Void, + completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) + -> [String] = { _, _, _ in [] } + ) { + _text = text + self.font = font + self.onSubmit = onSubmit + self.completions = completions + } - func makeNSView(context: Context) -> NSScrollView { + public func makeNSView(context: Context) -> NSScrollView { context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) textView.delegate = context.coordinator @@ -21,11 +31,14 @@ struct CustomTextEditor: NSViewRepresentable { textView.font = font textView.allowsUndo = true textView.drawsBackground = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false return context.coordinator.theTextView } - func updateNSView(_ nsView: NSScrollView, context: Context) { + public func updateNSView(_ nsView: NSScrollView, context: Context) { context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) guard textView.string != text else { return } @@ -34,7 +47,7 @@ struct CustomTextEditor: NSViewRepresentable { } } -extension CustomTextEditor { +public extension CustomTextEditor { class Coordinator: NSObject, NSTextViewDelegate { var view: CustomTextEditor var theTextView = NSTextView.scrollableTextView() @@ -45,7 +58,7 @@ extension CustomTextEditor { self.view = view } - func textDidChange(_ notification: Notification) { + public func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } @@ -54,7 +67,10 @@ extension CustomTextEditor { textView.complete(nil) } - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + public func textView( + _ textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { if commandSelector == #selector(NSTextView.insertNewline(_:)) { if let event = NSApplication.shared.currentEvent, !event.modifierFlags.contains(.shift), @@ -68,7 +84,7 @@ extension CustomTextEditor { return false } - func textView( + public func textView( _ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String? @@ -76,7 +92,7 @@ extension CustomTextEditor { return true } - func textView( + public func textView( _ textView: NSTextView, completions words: [String], forPartialWordRange charRange: NSRange, diff --git a/Core/Sources/SuggestionWidget/SyntaxHighlighting.swift b/Core/Sources/SharedUIComponents/SyntaxHighlighting.swift similarity index 99% rename from Core/Sources/SuggestionWidget/SyntaxHighlighting.swift rename to Core/Sources/SharedUIComponents/SyntaxHighlighting.swift index ce856445..eca3bb89 100644 --- a/Core/Sources/SuggestionWidget/SyntaxHighlighting.swift +++ b/Core/Sources/SharedUIComponents/SyntaxHighlighting.swift @@ -3,9 +3,8 @@ import Foundation import Highlightr import Splash import SwiftUI -import XPCShared -func highlightedCodeBlock( +public func highlightedCodeBlock( code: String, language: String, brightMode: Bool, @@ -105,7 +104,7 @@ func highlightedCodeBlock( } } -func highlighted( +public func highlighted( code: String, language: String, brightMode: Bool, @@ -206,3 +205,4 @@ func convertToCodeLines( } return (output, commonLeadingSpaceCount) } + diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2a875f89..2afa2d23 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -1,47 +1,232 @@ import ActiveApplicationMonitor import AppKit +import ChatTab +import ComposableArchitecture import SwiftUI private let r: Double = 8 -@MainActor -final class ChatWindowViewModel: ObservableObject { - @Published var chat: ChatProvider? - @Published var colorScheme: ColorScheme - @Published var isPanelDisplayed = false - @Published var chatPanelInASeparateWindow = false +struct ChatWindowView: View { + let store: StoreOf + + struct OverallState: Equatable { + var isPanelDisplayed: Bool + var colorScheme: ColorScheme + var selectedTabId: String? + } + + var body: some View { + WithViewStore( + store, + observe: { + OverallState( + isPanelDisplayed: $0.isPanelDisplayed, + colorScheme: $0.colorScheme, + selectedTabId: $0.chatTapGroup.selectedTabId + ) + } + ) { viewStore in + VStack(spacing: 0) { + RoundedRectangle(cornerRadius: 2) + .fill(.tertiary) + .frame(width: 120, height: 4) + .frame(height: 16) + + Divider() + + ChatTabBar(store: store) + .frame(height: 26) - public init(chat: ChatProvider? = nil, colorScheme: ColorScheme = .dark) { - self.chat = chat - self.colorScheme = colorScheme + Divider() + + ChatTabContainer(store: store) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background { + Button(action: { + viewStore.send(.hideButtonClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("M", modifiers: [.command]) + + Button(action: { + viewStore.send(.closeActiveTabClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("W", modifiers: [.command]) + } + .background(.regularMaterial) + .xcodeStyleFrame() + .opacity(viewStore.state.isPanelDisplayed ? 1 : 0) + .frame(minWidth: Style.panelWidth, minHeight: Style.panelHeight) + .preferredColorScheme(viewStore.state.colorScheme) + } } } -struct ChatWindowView: View { - @ObservedObject var viewModel: ChatWindowViewModel +struct ChatTabBar: View { + let store: StoreOf + + struct TabBarState: Equatable { + var tabInfo: [ChatTabInfo] + var selectedTabId: String + } var body: some View { - Group { - if let chat = viewModel.chat { - ChatPanel(chat: chat) - .background { - Button(action: { - viewModel.isPanelDisplayed = false - if let app = ActiveApplicationMonitor.previousActiveApplication, - app.isXcode - { - app.activate() - } - }) { - EmptyView() + WithViewStore( + store, + observe: { TabBarState( + tabInfo: $0.chatTapGroup.tabInfo, + selectedTabId: $0.chatTapGroup.selectedTabId + ?? $0.chatTapGroup.tabInfo.first?.id ?? "" + ) } + ) { viewStore in + HStack(spacing: 0) { + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(viewStore.state.tabInfo, id: \.id) { info in + ChatTabBarButton( + store: store, + info: info, + isSelected: info.id == viewStore.state.selectedTabId + ) } - .keyboardShortcut("M", modifiers: [.command]) } + } + + Divider() + + Button(action: { + store.send(.createNewTapButtonClicked(type: "")) + }) { + Image(systemName: "plus") + .foregroundColor(.secondary) + .padding(8) + }.buttonStyle(.plain) } } - .opacity(viewModel.isPanelDisplayed ? 1 : 0) - .frame(minWidth: Style.panelWidth, minHeight: Style.panelHeight) - .preferredColorScheme(viewModel.colorScheme) + } +} + +struct ChatTabBarButton: View { + let store: StoreOf + let info: ChatTabInfo + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack(spacing: 0) { + Button(action: { + store.send(.tabClicked(id: info.id)) + }) { + Text(info.title) + .font(.callout) + .lineLimit(1) + .frame(maxWidth: 120) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal, 32) + + .overlay(alignment: .leading) { + Button(action: { + store.send(.closeTabButtonClicked(id: info.id)) + }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(2) + .padding(.leading, 8) + .opacity(isHovered ? 1 : 0) + } + .onHover { isHovered = $0 } + .animation(.linear(duration: 0.1), value: isHovered) + .animation(.linear(duration: 0.1), value: isSelected) + + Divider().padding(.vertical, 6) + } + .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) + .frame(maxHeight: .infinity) + } +} + +struct ChatTabContainer: View { + let store: StoreOf + + struct TabContainerState: Equatable { + var tabs: [BaseChatTab] + var selectedTabId: String? + } + + var body: some View { + WithViewStore( + store, + observe: { + TabContainerState( + tabs: $0.chatTapGroup.tabs, + selectedTabId: $0.chatTapGroup.selectedTabId + ?? $0.chatTapGroup.tabInfo.first?.id ?? "" + ) + } + ) { viewStore in + ZStack { + if viewStore.state.tabs.isEmpty { + Text("Empty") + } else { + ForEach(viewStore.state.tabs, id: \.id) { tab in + tab.body + .opacity(tab.id == viewStore.state.selectedTabId ? 1 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + } + .onPreferenceChange(ChatTabInfoPreferenceKey.self) { items in + store.send(.updateChatTabInfo(items)) + } + } +} + +struct ChatWindowView_Previews: PreviewProvider { + class FakeChatTab: ChatTab { + func buildView() -> any View { + ChatPanel( + chat: .init( + history: [ + .init(id: "1", role: .assistant, text: "Hello World"), + ], + isReceivingMessage: false + ), + typedMessage: "Hello World!" + ) + } + + override init(id: String, title: String) { + super.init(id: id, title: title) + } + } + + static var previews: some View { + ChatWindowView( + store: .init( + initialState: .init( + chatTapGroup: .init( + tabs: [ + FakeChatTab(id: "1", title: "Hello I am a chatbot"), + EmptyChatTab(id: "2"), + ], + selectedTabId: "1" + ), + isPanelDisplayed: true + ), + reducer: ChatPanelFeature() + ) + ) + .padding() } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift new file mode 100644 index 00000000..8570067c --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -0,0 +1,151 @@ +import ActiveApplicationMonitor +import AppKit +import ChatTab +import ComposableArchitecture +import SwiftUI + +public struct ChatPanelFeature: ReducerProtocol { + public struct ChatTabGroup: Equatable { + public var tabs: [BaseChatTab] + public var tabTypes: [String] + public var tabInfo: [ChatTabInfo] + public var selectedTabId: String? + + init( + tabs: [BaseChatTab] = [], + tabTypes: [String] = [], + tabInfo: [ChatTabInfo] = [], + selectedTabId: String? = nil + ) { + self.tabs = tabs + self.tabTypes = tabTypes + self.tabInfo = tabInfo + self.selectedTabId = selectedTabId + } + + public var activeChatTab: BaseChatTab? { + guard let id = selectedTabId else { return tabs.first } + guard let tab = tabs.first(where: { $0.id == id }) else { return tabs.first } + return tab + } + } + + public struct State: Equatable { + public var chatTapGroup = ChatTabGroup() + var colorScheme: ColorScheme = .light + var isPanelDisplayed = false + var chatPanelInASeparateWindow = false + } + + public enum Action: Equatable { + // Window + case hideButtonClicked + case closeActiveTabClicked + case toggleChatPanelDetachedButtonClicked + case detachChatPanel + case attachChatPanel + case presentChatPanel(forceDetach: Bool) + + // Tabs + case updateChatTabInfo([ChatTabInfo]) + case closeTabButtonClicked(id: String) + case createNewTapButtonClicked(type: String) + case tabClicked(id: String) + case appendAndSelectTab(BaseChatTab) + } + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.activatePreviouslyActiveXcode) var activatePreviouslyActiveXcode + @Dependency(\.activateExtensionService) var activateExtensionService + + public var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .hideButtonClicked: + state.isPanelDisplayed = false + + return .run { _ in + await activatePreviouslyActiveXcode() + } + + case .closeActiveTabClicked: + if let id = state.chatTapGroup.selectedTabId { + return .run { send in + await send(.closeTabButtonClicked(id: id)) + } + } + + state.isPanelDisplayed = false + return .none + + case .toggleChatPanelDetachedButtonClicked: + state.chatPanelInASeparateWindow.toggle() + return .none + + case .detachChatPanel: + state.chatPanelInASeparateWindow = true + return .none + + case .attachChatPanel: + state.chatPanelInASeparateWindow = false + return .none + + case let .presentChatPanel(forceDetach): + if forceDetach { + state.chatPanelInASeparateWindow = true + } + state.isPanelDisplayed = true + return .run { _ in + await activateExtensionService() + } + + case let .updateChatTabInfo(chatTabInfo): + let previousSelectedIndex = state.chatTapGroup.tabInfo + .firstIndex(where: { $0.id == state.chatTapGroup.selectedTabId }) + state.chatTapGroup.tabInfo = chatTabInfo + if !chatTabInfo.contains(where: { $0.id == state.chatTapGroup.selectedTabId }) { + if let previousSelectedIndex { + let proposedSelectedIndex = previousSelectedIndex - 1 + if proposedSelectedIndex >= 0, + proposedSelectedIndex < chatTabInfo.endIndex + { + state.chatTapGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id + } else { + state.chatTapGroup.selectedTabId = chatTabInfo.first?.id + } + } else { + state.chatTapGroup.selectedTabId = nil + } + } + return .none + + case let .closeTabButtonClicked(id): + state.chatTapGroup.tabs.removeAll { $0.id == id } + if state.chatTapGroup.tabs.isEmpty { + state.isPanelDisplayed = false + } + return .none + + case .createNewTapButtonClicked: + return .none // handled elsewhere + + case let .tabClicked(id): + guard state.chatTapGroup.tabInfo.contains(where: { $0.id == id }) else { + state.chatTapGroup.selectedTabId = nil + return .none + } + state.chatTapGroup.selectedTabId = id + return .none + + case let .appendAndSelectTab(tab): + guard !state.chatTapGroup.tabInfo.contains(where: { $0.id == tab.id }) + else { return .none } + state.chatTapGroup.tabs.append(tab) + state.chatTapGroup.selectedTabId = tab.id + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift new file mode 100644 index 00000000..e3978d1d --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift @@ -0,0 +1,90 @@ +import ActiveApplicationMonitor +import ComposableArchitecture +import Environment +import Preferences +import SuggestionModel +import SwiftUI + +public struct CircularWidgetFeature: ReducerProtocol { + public struct IsProcessingCounter: Equatable { + var expirationDate: TimeInterval + } + + public struct State: Equatable { + var isProcessingCounters = [IsProcessingCounter]() + var isProcessing: Bool + var isDisplayingContent: Bool + var isContentEmpty: Bool + var isChatPanelDetached: Bool + var isChatOpen: Bool + var animationProgress: Double = 0 + } + + public enum Action: Equatable { + case widgetClicked + case detachChatPanelToggleClicked + case openChatButtonClicked + case runCustomCommandButtonClicked(CustomCommand) + case markIsProcessing + case endIsProcessing + case _forceEndIsProcessing + case _refreshRing + } + + struct CancelAutoEndIsProcessKey: Hashable {} + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + + public func reduce(into state: inout State, action: Action) -> EffectTask { + switch action { + case .detachChatPanelToggleClicked: + return .none // handled elsewhere + + case .openChatButtonClicked: + return .run { _ in + suggestionWidgetControllerDependency.onOpenChatClicked() + } + + case let .runCustomCommandButtonClicked(command): + return .run { _ in + suggestionWidgetControllerDependency.onCustomCommandClicked(command) + } + + case .widgetClicked: + return .none // handled elsewhere + + case .markIsProcessing: + let deadline = Date().timeIntervalSince1970 + 20 + state.isProcessingCounters.append(IsProcessingCounter(expirationDate: deadline)) + state.isProcessing = true + return .run { send in + try await Task.sleep(nanoseconds: 20 * 1_000_000_000) + try Task.checkCancellation() + await send(._forceEndIsProcessing) + }.cancellable(id: CancelAutoEndIsProcessKey(), cancelInFlight: true) + + case .endIsProcessing: + if !state.isProcessingCounters.isEmpty { + state.isProcessingCounters.removeFirst() + } + state.isProcessingCounters + .removeAll(where: { $0.expirationDate < Date().timeIntervalSince1970 }) + state.isProcessing = !state.isProcessingCounters.isEmpty + return .none + + case ._forceEndIsProcessing: + state.isProcessingCounters.removeAll() + state.isProcessing = false + return .none + + case ._refreshRing: + if state.isProcessing { + state.animationProgress = 1 - state.animationProgress + } else { + state.animationProgress = state.isContentEmpty ? 0 : 1 + } + return .none + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift new file mode 100644 index 00000000..5c2387c4 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -0,0 +1,164 @@ +import AppKit +import ComposableArchitecture +import Foundation + +public struct PanelFeature: ReducerProtocol { + public struct State: Equatable { + var content: SharedPanelFeature.Content? { + get { sharedPanelState.content ?? suggestionPanelState.content } + set { + sharedPanelState.content = newValue + suggestionPanelState.content = newValue + } + } + + // MARK: SharedPanel + + var sharedPanelState = SharedPanelFeature.State() + + // MARK: SuggestionPanel + + var suggestionPanelState = SuggestionPanelFeature.State() + } + + public enum Action: Equatable { + case presentSuggestion + case presentError(String) + case presentPromptToCode + case presentPanelContent(SharedPanelFeature.Content, shouldDisplay: Bool) + case discardPanelContent + case removeDisplayedContent + case switchToAnotherEditorAndUpdateContent + + case sharedPanel(SharedPanelFeature.Action) + case suggestionPanel(SuggestionPanelFeature.Action) + } + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + var windows: WidgetWindows { suggestionWidgetControllerDependency.windows } + + public var body: some ReducerProtocol { + Scope(state: \.suggestionPanelState, action: /Action.suggestionPanel) { + SuggestionPanelFeature() + } + + Scope(state: \.sharedPanelState, action: /Action.sharedPanel) { + SharedPanelFeature() + } + + Reduce { state, action in + switch action { + case .presentSuggestion: + return .run { send in + guard let provider = await fetchSuggestionProvider( + fileURL: xcodeInspector.activeDocumentURL + ) else { return } + let content = SharedPanelFeature.Content.suggestion(provider) + await send(.presentPanelContent(content, shouldDisplay: true)) + }.animation(.easeInOut(duration: 0.2)) + + case let .presentError(errorDescription): + return .run { send in + let content = SharedPanelFeature.Content.error(errorDescription) + await send(.presentPanelContent(content, shouldDisplay: true)) + }.animation(.easeInOut(duration: 0.2)) + + case .presentPromptToCode: + return .run { send in + guard let provider = await fetchPromptToCodeProvider( + fileURL: xcodeInspector.activeDocumentURL + ) else { return } + let content = SharedPanelFeature.Content.promptToCode(provider) + await send(.presentPanelContent(content, shouldDisplay: true)) + + // looks like we need a delay. + try await Task.sleep(nanoseconds: 150_000_000) + await NSApplication.shared.activate(ignoringOtherApps: true) + await windows.sharedPanelWindow.makeKey() + }.animation(.easeInOut(duration: 0.2)) + + case let .presentPanelContent(content, shouldDisplay): + state.content = content + + guard shouldDisplay else { return .none } + + switch content { + case .suggestion: + switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { + case .nearbyTextCursor: + state.suggestionPanelState.isPanelDisplayed = true + case .floatingWidget: + state.sharedPanelState.isPanelDisplayed = true + } + case .error: + state.sharedPanelState.isPanelDisplayed = true + case .promptToCode: + state.sharedPanelState.isPanelDisplayed = true + } + + return .none + + case .discardPanelContent: + return .run { send in + let fileURL = xcodeInspector.activeDocumentURL + if let provider = await fetchPromptToCodeProvider(fileURL: fileURL) { + await send(.presentPanelContent( + .promptToCode(provider), + shouldDisplay: false + )) + } else if let provider = await fetchSuggestionProvider(fileURL: fileURL) { + await send(.presentPanelContent( + .suggestion(provider), + shouldDisplay: false + )) + } else { + await send(.removeDisplayedContent) + } + }.animation(.easeInOut(duration: 0.2)) + + case .switchToAnotherEditorAndUpdateContent: + return .run { send in + let fileURL = xcodeInspector.activeDocumentURL + if let provider = await fetchPromptToCodeProvider(fileURL: fileURL) { + await send(.presentPanelContent( + .promptToCode(provider), + shouldDisplay: false + )) + } else if let provider = await fetchSuggestionProvider(fileURL: fileURL) { + await send(.presentPanelContent( + .suggestion(provider), + shouldDisplay: false + )) + } else { + await send(.removeDisplayedContent) + } + } + + case .removeDisplayedContent: + state.content = nil + return .none + + case .sharedPanel: + return .none + case .suggestionPanel: + return .none + } + } + } + + func fetchSuggestionProvider(fileURL: URL) async -> SuggestionProvider? { + guard let provider = await suggestionWidgetControllerDependency + .suggestionWidgetDataSource? + .suggestionForFile(at: fileURL) else { return nil } + return provider + } + + func fetchPromptToCodeProvider(fileURL: URL) async -> PromptToCodeProvider? { + guard let provider = await suggestionWidgetControllerDependency + .suggestionWidgetDataSource? + .promptToCodeForFile(at: fileURL) else { return nil } + return provider + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift new file mode 100644 index 00000000..3ce2f637 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift @@ -0,0 +1,54 @@ +import ComposableArchitecture +import Environment +import Preferences +import SwiftUI + +public struct SharedPanelFeature: ReducerProtocol { + public enum Content: Equatable { + case suggestion(SuggestionProvider) + case promptToCode(PromptToCodeProvider) + case error(String) + + var contentHash: String { + switch self { + case let .error(e): + return "error: \(e)" + case let .suggestion(provider): + return "suggestion: \(provider.code.hashValue)" + case let .promptToCode(provider): + return "provider: \(provider.id)" + } + } + + public static func == (lhs: Content, rhs: Content) -> Bool { + lhs.contentHash == rhs.contentHash + } + } + + public struct State: Equatable { + var content: Content? + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + var isPanelDisplayed: Bool = false + var opacity: Double { + guard isPanelDisplayed else { return 0 } + guard content != nil else { return 0 } + return 1 + } + } + + public enum Action: Equatable { + case closeButtonTapped + } + + public var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .closeButtonTapped: + state.content = nil + state.isPanelDisplayed = false + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift new file mode 100644 index 00000000..b253d69a --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -0,0 +1,27 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +public struct SuggestionPanelFeature: ReducerProtocol { + public struct State: Equatable { + var content: SharedPanelFeature.Content? + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + var isPanelDisplayed: Bool = false + var isPanelOutOfFrame: Bool = false + var opacity: Double { + guard isPanelDisplayed else { return 0 } + if isPanelOutOfFrame { return 0 } + guard content != nil else { return 0 } + return 1 + } + } + + public enum Action: Equatable { + case noAction + } + + public var body: some ReducerProtocol { + Reduce { _, _ in .none } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift new file mode 100644 index 00000000..830feabc --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -0,0 +1,670 @@ +import ActiveApplicationMonitor +import AsyncAlgorithms +import AXNotificationStream +import ChatTab +import ComposableArchitecture +import Environment +import Foundation +import Preferences +import SwiftUI +import XcodeInspector + +public struct WidgetFeature: ReducerProtocol { + public struct WindowState: Equatable { + var alphaValue: Double = 0 + var frame: CGRect = .zero + } + + public struct Windows: Equatable { + public var widgetWindowState = WindowState() + public var chatWindowState = WindowState() + public var suggestionPanelWindowState = WindowState() + public var sharedPanelWindowState = WindowState() + public var tabWindowState = WindowState() + } + + public struct State: Equatable { + public var colorScheme: ColorScheme = .light + + // MARK: Panels + + public var panelState = PanelFeature.State() + + // MARK: ChatPanel + + public var chatPanelState = ChatPanelFeature.State() + + // MARK: CircularWidget + + public struct CircularWidgetState: Equatable { + var isProcessingCounters = [CircularWidgetFeature.IsProcessingCounter]() + var isProcessing: Bool = false + var animationProgress: Double = 0 + } + + public var circularWidgetState = CircularWidgetState() + var _circularWidgetState: CircularWidgetFeature.State { + get { + .init( + isProcessingCounters: circularWidgetState.isProcessingCounters, + isProcessing: circularWidgetState.isProcessing, + isDisplayingContent: { + if chatPanelState.isPanelDisplayed { + return true + } + if panelState.sharedPanelState.isPanelDisplayed, + panelState.sharedPanelState.content != nil + { + return true + } + if panelState.suggestionPanelState.isPanelDisplayed, + panelState.suggestionPanelState.content != nil + { + return true + } + return false + }(), + isContentEmpty: chatPanelState.chatTapGroup.tabs.isEmpty + && panelState.sharedPanelState.content == nil, + isChatPanelDetached: chatPanelState.chatPanelInASeparateWindow, + isChatOpen: chatPanelState.isPanelDisplayed, + animationProgress: circularWidgetState.animationProgress + ) + } + set { + circularWidgetState = .init( + isProcessingCounters: newValue.isProcessingCounters, + isProcessing: newValue.isProcessing, + animationProgress: newValue.animationProgress + ) + } + } + + public init() {} + } + + private enum CancelID { + case observeActiveApplicationChange + case observeCompletionPanelChange + case observeFullscreenChange + case observeWindowChange + case observeEditorChange + case observeUserDefaults + } + + public enum Action: Equatable { + case startup + case observeActiveApplicationChange + case observeCompletionPanelChange + case observeFullscreenChange + case observeColorSchemeChange + case observePresentationModeChange + + case observeWindowChange + case observeEditorChange + + case updateActiveApplication + case updateColorScheme + + case updateWindowLocation(animated: Bool) + case updateWindowOpacity + + case panel(PanelFeature.Action) + case chatPanel(ChatPanelFeature.Action) + case circularWidget(CircularWidgetFeature.Action) + } + + var windows: WidgetWindows { + suggestionWidgetControllerDependency.windows + } + + @Dependency(\.suggestionWidgetUserDefaultsObservers) var userDefaultsObservers + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.activeApplicationMonitor) var activeApplicationMonitor + @Dependency(\.xcodeInspector) var xcodeInspector + + public init() {} + + public var body: some ReducerProtocol { + Scope(state: \._circularWidgetState, action: /Action.circularWidget) { + CircularWidgetFeature() + } + + Reduce { state, action in + switch action { + case .circularWidget(.detachChatPanelToggleClicked): + return .run { send in + await send(.chatPanel(.toggleChatPanelDetachedButtonClicked)) + } + + case .circularWidget(.widgetClicked): + let isDisplayingContent = state._circularWidgetState.isDisplayingContent + if isDisplayingContent { + state.panelState.sharedPanelState.isPanelDisplayed = false + state.panelState.suggestionPanelState.isPanelDisplayed = false + state.chatPanelState.isPanelDisplayed = false + } else { + state.panelState.sharedPanelState.isPanelDisplayed = true + state.panelState.suggestionPanelState.isPanelDisplayed = true + state.chatPanelState.isPanelDisplayed = true + } + return .run { _ in + guard isDisplayingContent else { return } + if let app = activeApplicationMonitor.previousActiveApplication, app.isXcode { + try await Task.sleep(nanoseconds: 200_000_000) + app.activate() + } + } + + default: return .none + } + } + + Scope(state: \.panelState, action: /Action.panel) { + PanelFeature() + } + + Scope(state: \.chatPanelState, action: /Action.chatPanel) { + ChatPanelFeature() + } + + Reduce { state, action in + switch action { + case .chatPanel(.presentChatPanel): + let isDetached = state.chatPanelState.chatPanelInASeparateWindow + return .run { send in + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + if isDetached { + Task { @MainActor in + windows.chatPanelWindow.alphaValue = 1 + } + } + } + case .chatPanel(.toggleChatPanelDetachedButtonClicked): + let isDetached = state.chatPanelState.chatPanelInASeparateWindow + return .run { send in + await send(.updateWindowLocation(animated: !isDetached)) + await send(.updateWindowOpacity) + } + default: return .none + } + } + + Reduce { state, action in + switch action { + case .startup: + return .merge( + .run { send in await send(.observeActiveApplicationChange) }, + .run { send in await send(.observeCompletionPanelChange) }, + .run { send in await send(.observeFullscreenChange) }, + .run { send in await send(.observeColorSchemeChange) }, + .run { send in await send(.observePresentationModeChange) } + ) + + case .observeActiveApplicationChange: + return .run { send in + var previousApp: NSRunningApplication? + for await app in activeApplicationMonitor.createStream() { + try Task.checkCancellation() + if app != previousApp { + await send(.updateActiveApplication) + } + previousApp = app + } + }.cancellable(id: CancelID.observeActiveApplicationChange, cancelInFlight: true) + + case .observeCompletionPanelChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = XcodeInspector.shared.$completionPanel.sink { newValue in + Task { + if newValue == nil { + // so that the buttons on the suggestion panel could be + // clicked + // before the completion panel updates the location of the + // suggestion panel + try await Task.sleep(nanoseconds: 400_000_000) + } + continuation.yield() + } + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + try Task.checkCancellation() + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + } + }.cancellable(id: CancelID.observeCompletionPanelChange, cancelInFlight: true) + + case .observeFullscreenChange: + return .run { _ in + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) + for await _ in sequence { + try Task.checkCancellation() + guard let activeXcode = activeApplicationMonitor.activeXcode + else { continue } + guard await windows.fullscreenDetector.isOnActiveSpace else { continue } + let app = AXUIElementCreateApplication(activeXcode.processIdentifier) + if let window = app.focusedWindow { + await windows.orderFront() + } + } + }.cancellable(id: CancelID.observeFullscreenChange, cancelInFlight: true) + + case .observeColorSchemeChange: + return .run { send in + await send(.updateColorScheme) + let stream = AsyncStream { continuation in + userDefaultsObservers.colorSchemeChangeObserver.onChange = { + continuation.yield() + } + + userDefaultsObservers.systemColorSchemeChangeObserver.onChange = { + continuation.yield() + } + + continuation.onTermination = { _ in + userDefaultsObservers.colorSchemeChangeObserver.onChange = {} + userDefaultsObservers.systemColorSchemeChangeObserver.onChange = {} + } + } + + for await _ in stream { + try Task.checkCancellation() + await send(.updateColorScheme) + } + }.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true) + + case .observePresentationModeChange: + return .run { send in + await send(.updateColorScheme) + let stream = AsyncStream { continuation in + userDefaultsObservers.presentationModeChangeObserver.onChange = { + continuation.yield() + } + + continuation.onTermination = { _ in + userDefaultsObservers.presentationModeChangeObserver.onChange = {} + } + } + + for await _ in stream { + try Task.checkCancellation() + await send(.updateWindowLocation(animated: false)) + } + }.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true) + + case .observeWindowChange: + guard let app = activeApplicationMonitor.activeApplication else { return .none } + guard app.isXcode else { return .none } + + return .run { send in + await send(.observeEditorChange) + + let notifications = AXNotificationStream( + app: app, + notificationNames: + kAXApplicationActivatedNotification, + kAXMovedNotification, + kAXResizedNotification, + kAXMainWindowChangedNotification, + kAXFocusedWindowChangedNotification, + kAXFocusedUIElementChangedNotification, + kAXWindowMovedNotification, + kAXWindowResizedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification + ) + for await notification in notifications { + try Task.checkCancellation() + + if [ + kAXFocusedUIElementChangedNotification, + kAXApplicationActivatedNotification, + kAXMainWindowChangedNotification, + kAXFocusedWindowChangedNotification, + ].contains(notification.name) { + await hidePanelWindows() + await send(.panel(.removeDisplayedContent)) + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + await send(.observeEditorChange) + await send(.panel(.switchToAnotherEditorAndUpdateContent)) + } else { + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + } + } + }.cancellable(id: CancelID.observeWindowChange, cancelInFlight: true) + + case .observeEditorChange: + guard let app = activeApplicationMonitor.activeApplication else { return .none } + return .run { send in + let appElement = AXUIElementCreateApplication(app.processIdentifier) + if let focusedElement = appElement.focusedElement, + focusedElement.description == "Source Editor", + let scrollView = focusedElement.parent, + let scrollBar = scrollView.verticalScrollBar + { + let selectionRangeChange = AXNotificationStream( + app: app, + element: focusedElement, + notificationNames: kAXSelectedTextChangedNotification + ) + let scroll = AXNotificationStream( + app: app, + element: scrollBar, + notificationNames: kAXValueChangedNotification + ) + + if #available(macOS 13.0, *) { + for await _ in merge( + selectionRangeChange.debounce(for: Duration.milliseconds(500)), + scroll + ) { + guard activeApplicationMonitor.latestXcode != nil + else { return } + try Task.checkCancellation() + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + } + } else { + for await _ in merge(selectionRangeChange, scroll) { + guard activeApplicationMonitor.latestXcode != nil + else { return } + try Task.checkCancellation() + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + } + } + } + }.cancellable(id: CancelID.observeEditorChange, cancelInFlight: true) + + case .updateActiveApplication: + if let app = activeApplicationMonitor.activeApplication, app.isXcode { + return .run { send in + await send(.panel(.switchToAnotherEditorAndUpdateContent)) + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + await windows.orderFront() + await send(.observeWindowChange) + } + } + return .run { send in + await send(.updateWindowLocation(animated: false)) + await send(.updateWindowOpacity) + } + + case .updateColorScheme: + let widgetColorScheme = UserDefaults.shared.value(for: \.widgetColorScheme) + let systemColorScheme: ColorScheme = NSApp.effectiveAppearance.name == .darkAqua + ? .dark + : .light + + let scheme: ColorScheme = { + switch (widgetColorScheme, systemColorScheme) { + case (.system, .dark), (.dark, _): + return .dark + case (.system, .light), (.light, _): + return .light + case (.system, _): + return .light + } + }() + + state.colorScheme = scheme + state.panelState.sharedPanelState.colorScheme = scheme + state.panelState.suggestionPanelState.colorScheme = scheme + state.chatPanelState.colorScheme = scheme + return .none + + case let .updateWindowLocation(animated): + guard let widgetLocation = generateWidgetLocation() else { return .none } + state.panelState.sharedPanelState.alignTopToAnchor = widgetLocation + .defaultPanelLocation + .alignPanelTop + + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + state.panelState.suggestionPanelState.isPanelOutOfFrame = false + state.panelState.suggestionPanelState + .alignTopToAnchor = suggestionPanelLocation + .alignPanelTop + } else { + state.panelState.suggestionPanelState.isPanelOutOfFrame = true + } + + let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow + + return .run { _ in + Task { @MainActor in + windows.widgetWindow.setFrame( + widgetLocation.widgetFrame, + display: false, + animate: animated + ) + windows.tabWindow.setFrame( + widgetLocation.tabFrame, + display: false, + animate: animated + ) + windows.sharedPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + windows.suggestionPanelWindow.setFrame( + suggestionPanelLocation.frame, + display: false, + animate: animated + ) + } + + if isChatPanelDetached { + if windows.chatPanelWindow.alphaValue == 0 { + windows.chatPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + } + } else { + windows.chatPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + } + } + } + + case .updateWindowOpacity: + let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow + let hasChat = !state.chatPanelState.chatTapGroup.tabs.isEmpty + + return .run { _ in + Task { @MainActor in + if let app = activeApplicationMonitor.activeApplication, app.isXcode { + let application = AXUIElementCreateApplication(app.processIdentifier) + /// We need this to hide the windows when Xcode is minimized. + let noFocus = application.focusedWindow == nil + windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = noFocus ? 0 : 1 + windows.tabWindow.alphaValue = noFocus ? 0 : 1 + + if isChatPanelDetached { + windows.chatPanelWindow.alphaValue = hasChat ? 1 : 0 + } else { + windows.chatPanelWindow.alphaValue = noFocus ? 0 : 1 + } + } else if let app = activeApplicationMonitor.activeApplication, + app.bundleIdentifier == Bundle.main.bundleIdentifier + { + let noFocus = { + guard let xcode = xcodeInspector.latestActiveXcode + else { return true } + if let window = xcode.appElement.focusedWindow, + window.role == "AXWindow" + { + return false + } + return true + }() + + windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = noFocus ? 0 : 1 + windows.tabWindow.alphaValue = noFocus ? 0 : 1 + if isChatPanelDetached { + windows.chatPanelWindow.alphaValue = hasChat ? 1 : 0 + } else { + windows.chatPanelWindow.alphaValue = noFocus && !windows + .chatPanelWindow.isKeyWindow ? 0 : 1 + } + } else { + windows.sharedPanelWindow.alphaValue = 0 + windows.suggestionPanelWindow.alphaValue = 0 + windows.widgetWindow.alphaValue = 0 + windows.tabWindow.alphaValue = 0 + if !isChatPanelDetached { + windows.chatPanelWindow.alphaValue = 0 + } + } + } + } + + case let .circularWidget(action): + switch action { + case .openChatButtonClicked: + suggestionWidgetControllerDependency.onOpenChatClicked() + return .none + + default: + return .none + } + + case .panel: + return .none + + case .chatPanel: + return .none + } + } + } + + @MainActor + func hidePanelWindows() { + windows.sharedPanelWindow.alphaValue = 0 + windows.suggestionPanelWindow.alphaValue = 0 + } + + func generateWidgetLocation() -> WidgetLocation? { + if let application = xcodeInspector.latestActiveXcode?.appElement { + if let focusElement = xcodeInspector.focusedEditor?.element, + let parent = focusElement.parent, + let frame = parent.rect, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + let positionMode = UserDefaults.shared + .value(for: \.suggestionWidgetPositionMode) + let suggestionMode = UserDefaults.shared + .value(for: \.suggestionPresentationMode) + + switch positionMode { + case .fixedToBottom: + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: xcodeInspector.completionPanel + ) + default: + break + } + return result + case .alignToTextCursor: + var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: xcodeInspector.completionPanel + ) + default: + break + } + return result + } + } else if var window = application.focusedWindow, + var frame = application.focusedWindow?.rect, + !["menu bar", "menu bar item"].contains(window.description), + frame.size.height > 300, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + if ["open_quickly"].contains(window.identifier) + || ["alert"].contains(window.label) + { + // fallback to use workspace window + guard let workspaceWindow = application.windows + .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), + let rect = workspaceWindow.rect + else { + return WidgetLocation( + widgetFrame: .zero, + tabFrame: .zero, + defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) + ) + } + + window = workspaceWindow + frame = rect + } + + if ["Xcode.WorkspaceWindow"].contains(window.identifier) { + // extra padding to bottom so buttons won't be covered + frame.size.height -= 40 + } else { + // move a bit away from the window so buttons won't be covered + frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 + frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth + } + + return UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + preferredInsideEditorMinWidth: 9_999_999_999 // never + ) + } + } + return nil + } +} + diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift new file mode 100644 index 00000000..de2320cc --- /dev/null +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -0,0 +1,125 @@ +import ActiveApplicationMonitor +import AppKit +import ComposableArchitecture +import Dependencies +import Foundation +import Preferences +import UserDefaultsObserver +import XcodeInspector + +public final class SuggestionWidgetControllerDependency { + public var suggestionWidgetDataSource: SuggestionWidgetDataSource? + public var onOpenChatClicked: () -> Void = {} + public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } + public var windows: WidgetWindows = .init() + + public init() {} +} + +@MainActor +public final class WidgetWindows { + var fullscreenDetector: NSWindow! + var widgetWindow: NSWindow! + var tabWindow: NSWindow! + var sharedPanelWindow: NSWindow! + var suggestionPanelWindow: NSWindow! + var chatPanelWindow: NSWindow! + + nonisolated + init() {} + + func orderFront() { + widgetWindow?.orderFrontRegardless() + tabWindow?.orderFrontRegardless() + sharedPanelWindow?.orderFrontRegardless() + suggestionPanelWindow?.orderFrontRegardless() + chatPanelWindow?.orderFrontRegardless() + } +} + +public final class WidgetUserDefaultsObservers { + let presentationModeChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [ + UserDefaultPreferenceKeys().suggestionPresentationMode.key, + ], context: nil + ) + let colorSchemeChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().widgetColorScheme.key, + ], context: nil + ) + let systemColorSchemeChangeObserver = UserDefaultsObserver( + object: UserDefaults.standard, forKeyPaths: ["AppleInterfaceStyle"], context: nil + ) + + public init() {} +} + +struct SuggestionWidgetControllerDependencyKey: DependencyKey { + static let liveValue = SuggestionWidgetControllerDependency() +} + +struct UserDefaultsDependencyKey: DependencyKey { + static let liveValue = WidgetUserDefaultsObservers() +} + +struct XcodeInspectorKey: DependencyKey { + static let liveValue = XcodeInspector.shared +} + +struct ActiveApplicationMonitorKey: DependencyKey { + static let liveValue = ActiveApplicationMonitor.self +} + +struct ActivatePreviouslyActiveXcodeKey: DependencyKey { + static let liveValue = { @MainActor in + @Dependency(\.activeApplicationMonitor) var activeApplicationMonitor + if let app = activeApplicationMonitor.previousActiveApplication, app.isXcode { + try? await Task.sleep(nanoseconds: 200_000_000) + app.activate() + } + } +} + +struct ActivateExtensionServiceKey: DependencyKey { + static let liveValue = { @MainActor in + try? await Task.sleep(nanoseconds: 150_000_000) + NSApplication.shared.activate(ignoringOtherApps: true) + } +} + +public extension DependencyValues { + var suggestionWidgetControllerDependency: SuggestionWidgetControllerDependency { + get { self[SuggestionWidgetControllerDependencyKey.self] } + set { self[SuggestionWidgetControllerDependencyKey.self] = newValue } + } + + var suggestionWidgetUserDefaultsObservers: WidgetUserDefaultsObservers { + get { self[UserDefaultsDependencyKey.self] } + set { self[UserDefaultsDependencyKey.self] = newValue } + } +} + +extension DependencyValues { + var xcodeInspector: XcodeInspector { + get { self[XcodeInspectorKey.self] } + set { self[XcodeInspectorKey.self] = newValue } + } + + var activeApplicationMonitor: ActiveApplicationMonitor.Type { + get { self[ActiveApplicationMonitorKey.self] } + set { self[ActiveApplicationMonitorKey.self] = newValue } + } + + var activatePreviouslyActiveXcode: () async -> Void { + get { self[ActivatePreviouslyActiveXcodeKey.self] } + set { self[ActivatePreviouslyActiveXcodeKey.self] = newValue } + } + + var activateExtensionService: () async -> Void { + get { self[ActivateExtensionServiceKey.self] } + set { self[ActivateExtensionServiceKey.self] = newValue } + } +} + diff --git a/Core/Sources/SuggestionWidget/PromptToCodeProvider.swift b/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift similarity index 100% rename from Core/Sources/SuggestionWidget/PromptToCodeProvider.swift rename to Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift diff --git a/Core/Sources/SuggestionWidget/SuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift similarity index 100% rename from Core/Sources/SuggestionWidget/SuggestionProvider.swift rename to Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index 55367db6..bb039c9d 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -1,52 +1,8 @@ +import ComposableArchitecture import Environment import Preferences import SwiftUI -@MainActor -final class SharedPanelViewModel: ObservableObject { - enum Content { - case suggestion(SuggestionProvider) - case promptToCode(PromptToCodeProvider) - case error(String) - - var contentHash: String { - switch self { - case let .error(e): - return "error: \(e)" - case let .suggestion(provider): - return "suggestion: \(provider.code.hashValue)" - case let .promptToCode(provider): - return "provider: \(provider.id)" - } - } - } - - @Published var content: Content? - @Published var colorScheme: ColorScheme - - init( - content: Content? = nil, - colorScheme: ColorScheme = .dark - ) { - self.content = content - self.colorScheme = colorScheme - } -} - -@MainActor -final class SharedPanelDisplayController: ObservableObject { - @Published var alignTopToAnchor = false - @Published var isPanelDisplayed: Bool = false - - init( - alignTopToAnchor: Bool = false, - isPanelDisplayed: Bool = false - ) { - self.alignTopToAnchor = alignTopToAnchor - self.isPanelDisplayed = isPanelDisplayed - } -} - extension View { @ViewBuilder func animation( @@ -64,69 +20,79 @@ extension View { } struct SharedPanelView: View { - @ObservedObject var viewModel: SharedPanelViewModel - @ObservedObject var displayController: SharedPanelDisplayController + var store: StoreOf @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + struct OverallState: Equatable { + var isPanelDisplayed: Bool + var opacity: Double + var colorScheme: ColorScheme + var contentHash: String + var alignTopToAnchor: Bool + } + var body: some View { - VStack(spacing: 0) { - if !displayController.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) - } + WithViewStore( + store, + observe: { OverallState( + isPanelDisplayed: $0.isPanelDisplayed, + opacity: $0.opacity, + colorScheme: $0.colorScheme, + contentHash: $0.content?.contentHash ?? "", + alignTopToAnchor: $0.alignTopToAnchor + ) } + ) { viewStore in + VStack(spacing: 0) { + if !viewStore.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } - VStack { - if let content = viewModel.content { - ZStack(alignment: .topLeading) { - switch content { - case let .suggestion(suggestion): - switch suggestionPresentationMode { - case .nearbyTextCursor: - EmptyView() - case .floatingWidget: - CodeBlockSuggestionPanel(suggestion: suggestion) + IfLetStore(store.scope(state: \.content, action: { $0 })) { store in + WithViewStore(store) { viewStore in + ZStack(alignment: .topLeading) { + switch viewStore.state { + case let .suggestion(suggestion): + switch suggestionPresentationMode { + case .nearbyTextCursor: + EmptyView() + case .floatingWidget: + CodeBlockSuggestionPanel(suggestion: suggestion) + } + case let .promptToCode(provider): + PromptToCodePanel(provider: provider) + case let .error(description): + ErrorPanel(description: description) { + viewStore.send( + .closeButtonTapped, + animation: .easeInOut(duration: 0.2) + ) + } } - case let .promptToCode(provider): - PromptToCodePanel(provider: provider) - case let .error(description): - ErrorPanel( - viewModel: viewModel, - displayController: displayController, - description: description - ) } + .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) } - .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) - .fixedSize(horizontal: false, vertical: true) - .allowsHitTesting(displayController.isPanelDisplayed) } - } - .frame(maxWidth: .infinity) + .allowsHitTesting(viewStore.isPanelDisplayed) + .frame(maxWidth: .infinity) - if displayController.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) + if viewStore.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } } + .preferredColorScheme(viewStore.colorScheme) + .opacity(viewStore.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: viewStore.isPanelDisplayed + ) + .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) } - .preferredColorScheme(viewModel.colorScheme) - .opacity({ - guard displayController.isPanelDisplayed else { return 0 } - guard viewModel.content != nil else { return 0 } - return 1 - }()) - .animation( - featureFlag: \.animationACrashSuggestion, - .easeInOut(duration: 0.2), - value: viewModel.content?.contentHash - ) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: displayController.isPanelDisplayed - ) - .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) } } @@ -154,29 +120,40 @@ struct CommandButtonStyle: ButtonStyle { struct SuggestionPanelView_Error_Preview: PreviewProvider { static var previews: some View { - SharedPanelView(viewModel: .init( - content: .error("This is an error\nerror") - ), displayController: .init(isPanelDisplayed: true)) + SharedPanelView(store: .init( + initialState: .init( + content: .error("This is an error\nerror"), + colorScheme: .light, + isPanelDisplayed: true + ), + reducer: SharedPanelFeature() + )) .frame(width: 450, height: 200) } } struct SuggestionPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { static var previews: some View { - SharedPanelView(viewModel: .init( - content: .suggestion(SuggestionProvider( - code: """ - - (void)addSubview:(UIView *)view { - [self addSubview:view]; - } - """, - language: "objective-c", - startLineIndex: 8, - suggestionCount: 2, - currentSuggestionIndex: 0 - )), - colorScheme: .dark - ), displayController: .init(isPanelDisplayed: true)) + SharedPanelView(store: .init( + initialState: .init( + content: .suggestion( + SuggestionProvider( + code: """ + - (void)addSubview:(UIView *)view { + [self addSubview:view]; + } + """, + language: "objective-c", + startLineIndex: 8, + suggestionCount: 2, + currentSuggestionIndex: 0 + ) + ), + colorScheme: .dark, + isPanelDisplayed: true + ), + reducer: SharedPanelFeature() + )) .frame(width: 450, height: 200) .background { HStack { diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index ba9a4798..57c3cca9 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -1,9 +1,10 @@ import AppKit import MarkdownUI +import SharedUIComponents import SwiftUI enum Style { - static let panelHeight: Double = 500 + static let panelHeight: Double = 560 static let panelWidth: Double = 454 static let inlineSuggestionMinWidth: Double = 540 static let inlineSuggestionMaxHeight: Double = 400 @@ -89,7 +90,36 @@ extension MarkdownUI.Theme { } } } - .markdownMargin(top: 0, bottom: 16) + .markdownMargin(top: 4, bottom: 16) + } + } + + static func functionCall(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.secondary) + BackgroundColor(Color.clear) + FontSize(fontSize - 1) + } + .list { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + .paragraph { configuration in + configuration.label + .markdownMargin(top: 0, bottom: 4) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 4, bottom: 4) } } } + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 207a65ed..6babe4f2 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -1,3 +1,4 @@ +import SharedUIComponents import SwiftUI struct CodeBlockSuggestionPanel: View { @@ -174,3 +175,4 @@ struct CodeBlockSuggestionPanel_Bright_Objc_Preview: PreviewProvider { } } } + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift index a0750523..b5791d17 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift @@ -1,9 +1,8 @@ import SwiftUI struct ErrorPanel: View { - var viewModel: SharedPanelViewModel - var displayController: SharedPanelDisplayController var description: String + var onCloseButtonTap: () -> Void var body: some View { ZStack(alignment: .topTrailing) { @@ -15,10 +14,7 @@ struct ErrorPanel: View { .background(Color.red) // close button - Button(action: { - displayController.isPanelDisplayed = false - viewModel.content = nil - }) { + Button(action: onCloseButtonTap) { Image(systemName: "xmark") .padding([.leading, .bottom], 16) .padding([.top, .trailing], 8) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 33e12cc9..46407166 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -1,4 +1,5 @@ import MarkdownUI +import SharedUIComponents import SwiftUI struct PromptToCodePanel: View { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift index 4d4e1c9d..1949fcb0 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -1,89 +1,86 @@ +import ComposableArchitecture import Foundation import SwiftUI -@MainActor -final class SuggestionPanelDisplayController: ObservableObject { - @Published var alignTopToAnchor = false - @Published var isPanelOutOfFrame: Bool = false - @Published var isPanelDisplayed: Bool = false - - init( - alignTopToAnchor: Bool = false, - isPanelOutOfFrame: Bool = false, - isPanelDisplayed: Bool = false - ) { - self.alignTopToAnchor = alignTopToAnchor - self.isPanelDisplayed = isPanelDisplayed - self.isPanelOutOfFrame = isPanelOutOfFrame - } -} - struct SuggestionPanelView: View { - @ObservedObject var viewModel: SharedPanelViewModel - @ObservedObject var displayController: SuggestionPanelDisplayController + let store: StoreOf @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + struct OverallState: Equatable { + var isPanelDisplayed: Bool + var opacity: Double + var colorScheme: ColorScheme + var contentHash: String + var isPanelOutOfFrame: Bool + var alignTopToAnchor: Bool + } + var body: some View { - VStack(spacing: 0) { - if !displayController.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) - } + WithViewStore( + store, + observe: { OverallState( + isPanelDisplayed: $0.isPanelDisplayed, + opacity: $0.opacity, + colorScheme: $0.colorScheme, + contentHash: $0.content?.contentHash ?? "", + isPanelOutOfFrame: $0.isPanelOutOfFrame, + alignTopToAnchor: $0.alignTopToAnchor + ) } + ) { viewStore in + VStack(spacing: 0) { + if !viewStore.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } - VStack { - if let content = viewModel.content { - ZStack(alignment: .topLeading) { - switch content { - case let .suggestion(suggestion): - switch suggestionPresentationMode { - case .nearbyTextCursor: - CodeBlockSuggestionPanel(suggestion: suggestion) - case .floatingWidget: + IfLetStore(store.scope(state: \.content, action: { $0 })) { store in + WithViewStore(store) { viewStore in + ZStack(alignment: .topLeading) { + switch viewStore.state { + case let .suggestion(suggestion): + switch suggestionPresentationMode { + case .nearbyTextCursor: + CodeBlockSuggestionPanel(suggestion: suggestion) + case .floatingWidget: + EmptyView() + } + default: EmptyView() } - default: - EmptyView() } + .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) + .fixedSize(horizontal: false, vertical: true) } - .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) - .fixedSize(horizontal: false, vertical: true) - .allowsHitTesting( - displayController.isPanelDisplayed && !displayController.isPanelOutOfFrame - ) } - } - .frame(maxWidth: .infinity) + .allowsHitTesting( + viewStore.isPanelDisplayed && !viewStore.isPanelOutOfFrame + ) + .frame(maxWidth: .infinity) - if displayController.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) + if viewStore.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } } + .preferredColorScheme(viewStore.colorScheme) + .opacity(viewStore.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: viewStore.isPanelDisplayed + ) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: viewStore.isPanelOutOfFrame + ) + .frame( + maxWidth: Style.inlineSuggestionMinWidth, + maxHeight: Style.inlineSuggestionMaxHeight + ) } - .preferredColorScheme(viewModel.colorScheme) - .opacity({ - guard displayController.isPanelDisplayed else { return 0 } - guard !displayController.isPanelOutOfFrame else { return 0 } - guard viewModel.content != nil else { return 0 } - return 1 - }()) - .animation( - featureFlag: \.animationACrashSuggestion, - .easeInOut(duration: 0.2), - value: viewModel.content?.contentHash - ) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: displayController.isPanelDisplayed - ) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: displayController.isPanelOutOfFrame - ) - .frame(maxWidth: Style.inlineSuggestionMinWidth, maxHeight: Style.inlineSuggestionMaxHeight) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index f8b9b4b4..bdccbe87 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -3,6 +3,7 @@ import AppKit import AsyncAlgorithms import AXNotificationStream import Combine +import ComposableArchitecture import Environment import Preferences import SwiftUI @@ -45,17 +46,10 @@ public final class SuggestionWidgetController: NSObject { it.hasShadow = true it.contentView = NSHostingView( rootView: WidgetView( - viewModel: widgetViewModel, - panelViewModel: sharedPanelViewModel, - chatWindowViewModel: chatWindowViewModel, - sharedPanelDisplayController: sharedPanelDisplayController, - suggestionPanelDisplayController: suggestionPanelDisplayController, - onOpenChatClicked: { [weak self] in - self?.onOpenChatClicked() - }, - onCustomCommandClicked: { [weak self] command in - self?.onCustomCommandClicked(command) - } + store: store.scope( + state: \._circularWidgetState, + action: WidgetFeature.Action.circularWidget + ) ) ) it.setIsVisible(true) @@ -77,14 +71,17 @@ public final class SuggestionWidgetController: NSObject { it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( - rootView: TabView(chatWindowViewModel: chatWindowViewModel) + rootView: TabView(store: store.scope( + state: \.chatPanelState, + action: WidgetFeature.Action.chatPanel + )) ) it.setIsVisible(true) it.canBecomeKeyChecker = { false } return it }() - private lazy var panelWindow = { + private lazy var sharedPanelWindow = { let it = CanBecomeKeyWindow( contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), styleMask: .borderless, @@ -99,19 +96,28 @@ public final class SuggestionWidgetController: NSObject { it.hasShadow = true it.contentView = NSHostingView( rootView: SharedPanelView( - viewModel: sharedPanelViewModel, - displayController: sharedPanelDisplayController + store: store.scope( + state: \.panelState, + action: WidgetFeature.Action.panel + ).scope( + state: \.sharedPanelState, + action: PanelFeature.Action.sharedPanel + ) ) ) it.setIsVisible(true) - it.canBecomeKeyChecker = { [sharedPanelViewModel] in - if case .promptToCode = sharedPanelViewModel.content { return true } - return false + it.canBecomeKeyChecker = { [store] in + store.withState { state in + if case .promptToCode = state.panelState.sharedPanelState.content { + return true + } + return false + } } return it }() - private lazy var suggestionWindow = { + private lazy var suggestionPanelWindow = { let it = CanBecomeKeyWindow( contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), styleMask: .borderless, @@ -126,19 +132,21 @@ public final class SuggestionWidgetController: NSObject { it.hasShadow = true it.contentView = NSHostingView( rootView: SuggestionPanelView( - viewModel: sharedPanelViewModel, - displayController: suggestionPanelDisplayController + store: store.scope( + state: \.panelState, + action: WidgetFeature.Action.panel + ).scope( + state: \.suggestionPanelState, + action: PanelFeature.Action.suggestionPanel + ) ) ) + it.canBecomeKeyChecker = { false } it.setIsVisible(true) - it.canBecomeKeyChecker = { [sharedPanelViewModel] in - if case .promptToCode = sharedPanelViewModel.content { return true } - return false - } return it }() - private lazy var chatWindow = { + private lazy var chatPanelWindow = { let it = ChatWindow( contentRect: .zero, styleMask: [.resizable], @@ -152,589 +160,88 @@ public final class SuggestionWidgetController: NSObject { it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( - rootView: ChatWindowView(viewModel: chatWindowViewModel) + rootView: ChatWindowView( + store: store.scope( + state: \.chatPanelState, + action: WidgetFeature.Action.chatPanel + ) + ) ) it.setIsVisible(true) it.delegate = self return it }() - let widgetViewModel = WidgetViewModel() - let sharedPanelViewModel = SharedPanelViewModel() - let chatWindowViewModel = ChatWindowViewModel() - let sharedPanelDisplayController = SharedPanelDisplayController() - let suggestionPanelDisplayController = SuggestionPanelDisplayController() - - private var presentationModeChangeObserver = UserDefaultsObserver( - object: UserDefaults.shared, - forKeyPaths: [ - UserDefaultPreferenceKeys().suggestionPresentationMode.key, - ], context: nil - ) - private var colorSchemeChangeObserver = UserDefaultsObserver( - object: UserDefaults.shared, forKeyPaths: [ - UserDefaultPreferenceKeys().widgetColorScheme.key, - ], context: nil - ) - private var systemColorSchemeChangeObserver = UserDefaultsObserver( - object: UserDefaults.standard, forKeyPaths: ["AppleInterfaceStyle"], context: nil - ) - private var windowChangeObservationTask: Task? - private var activeApplicationMonitorTask: Task? - private var sourceEditorMonitorTask: Task? - private var fullscreenDetectingTask: Task? - private var currentFileURL: URL? - private var colorScheme: ColorScheme = .light + let store: StoreOf + let viewStore: ViewStoreOf private var cancellable = Set() - public var onOpenChatClicked: () -> Void = {} - public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } - public var dataSource: SuggestionWidgetDataSource? - - override public nonisolated init() { - super.init() - #warning( - "TODO: A test is initializing this class for unknown reasons, try a better way to avoid this." - ) - if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - - Task { @MainActor in - activeApplicationMonitorTask = Task { [weak self] in - var previousApp: NSRunningApplication? - for await app in ActiveApplicationMonitor.createStream() { - guard let self else { return } - try Task.checkCancellation() - defer { previousApp = app } - if let app = ActiveApplicationMonitor.activeXcode { - if app != previousApp { - windowChangeObservationTask?.cancel() - windowChangeObservationTask = nil - observeXcodeWindowChangeIfNeeded(app) - } - await updateContentForActiveEditor() - updateWindowLocation() - orderFront() - } else { - if ActiveApplicationMonitor.activeApplication?.bundleIdentifier != Bundle - .main.bundleIdentifier - { - self.widgetWindow.alphaValue = 0 - self.panelWindow.alphaValue = 0 - self.tabWindow.alphaValue = 0 - self.suggestionWindow.alphaValue = 0 - if !chatWindowViewModel.chatPanelInASeparateWindow { - self.chatWindow.alphaValue = 0 - } - } - } - } - } + public let dependency: SuggestionWidgetControllerDependency - fullscreenDetectingTask = Task { [weak self] in - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) - _ = self?.fullscreenDetector - for await _ in sequence { - try Task.checkCancellation() - guard let self else { return } - guard let activeXcode = ActiveApplicationMonitor.activeXcode else { continue } - guard fullscreenDetector.isOnActiveSpace else { continue } - let app = AXUIElementCreateApplication(activeXcode.processIdentifier) - if let window = app.focusedWindow, window.isFullScreen { - orderFront() - } - } - } + public init( + store: StoreOf, + dependency: SuggestionWidgetControllerDependency + ) { + self.dependency = dependency + self.store = store + viewStore = .init(store, observe: { $0 }) - presentationModeChangeObserver.onChange = { [weak self] in - guard let self else { return } - self.updateWindowLocation() - } + super.init() - chatWindowViewModel.$chatPanelInASeparateWindow.dropFirst().removeDuplicates() - .sink { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.updateWindowLocation(animated: true) - } - }.store(in: &cancellable) - - let updateColorScheme = { @MainActor [weak self] in - guard let self else { return } - let widgetColorScheme = UserDefaults.shared.value(for: \.widgetColorScheme) - let systemColorScheme: ColorScheme = NSApp.effectiveAppearance.name == .darkAqua - ? .dark - : .light - self.colorScheme = { - switch (widgetColorScheme, systemColorScheme) { - case (.system, .dark), (.dark, _): - return .dark - case (.system, .light), (.light, _): - return .light - case (.system, _): - return .light - } - }() - self.sharedPanelViewModel.colorScheme = self.colorScheme - self.chatWindowViewModel.colorScheme = self.colorScheme - Task { - await self.updateContentForActiveEditor() - } - } + if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - updateColorScheme() - colorSchemeChangeObserver.onChange = { - updateColorScheme() - } - systemColorSchemeChangeObserver.onChange = { - updateColorScheme() - } + dependency.windows.chatPanelWindow = chatPanelWindow + dependency.windows.tabWindow = tabWindow + dependency.windows.sharedPanelWindow = sharedPanelWindow + dependency.windows.suggestionPanelWindow = suggestionPanelWindow + dependency.windows.fullscreenDetector = fullscreenDetector + dependency.windows.widgetWindow = widgetWindow - XcodeInspector.shared.$completionPanel.sink { [weak self] newValue in - Task { @MainActor in - if newValue == nil { - // so that the buttons on the suggestion panel could be clicked - // before the completion panel updates the location of the suggestion panel - try await Task.sleep(nanoseconds: 200_000_000) - } - self?.updateWindowLocation() - } - }.store(in: &cancellable) - } - } - - func orderFront() { - widgetWindow.orderFrontRegardless() - tabWindow.orderFrontRegardless() - panelWindow.orderFrontRegardless() - suggestionWindow.orderFrontRegardless() - chatWindow.orderFrontRegardless() + store.send(.startup) } } // MARK: - Handle Events public extension SuggestionWidgetController { - func suggestCode(fileURL: URL) { - Task { - markAsProcessing(true) - defer { markAsProcessing(false) } - if let suggestion = await dataSource?.suggestionForFile(at: fileURL) { - sharedPanelViewModel.content = .suggestion(suggestion) - switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { - case .nearbyTextCursor: - suggestionPanelDisplayController.isPanelDisplayed = true - case .floatingWidget: - sharedPanelDisplayController.isPanelDisplayed = true - } - } - } + func suggestCode() { + store.send(.panel(.presentSuggestion)) } - func discardSuggestion(fileURL: URL) { - Task { - await updateContentForActiveEditor(fileURL: fileURL) - } + func discardSuggestion() { + store.send(.panel(.discardPanelContent)) } func markAsProcessing(_ isProcessing: Bool) { if isProcessing { - widgetViewModel.markIsProcessing() + store.send(.circularWidget(.markIsProcessing)) } else { - widgetViewModel.endIsProcessing() + store.send(.circularWidget(.endIsProcessing)) } } func presentError(_ errorDescription: String) { - sharedPanelViewModel.content = .error(errorDescription) - sharedPanelDisplayController.isPanelDisplayed = true + store.send(.panel(.presentError(errorDescription))) } - func presentChatRoom(fileURL: URL) { - Task { - markAsProcessing(true) - defer { markAsProcessing(false) } - if let chat = await dataSource?.chatForFile(at: fileURL) { - chatWindowViewModel.chat = chat - chatWindowViewModel.isPanelDisplayed = true - - if chatWindowViewModel.chatPanelInASeparateWindow { - self.updateWindowLocation() - } - - Task { @MainActor in - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - NSApplication.shared.activate(ignoringOtherApps: true) - } - } - } + func presentChatRoom() { + store.send(.chatPanel(.presentChatPanel(forceDetach: false))) } func presentDetachedGlobalChat() { - chatWindowViewModel.chatPanelInASeparateWindow = true - Task { - if let chat = await dataSource?.chatForFile(at: URL(fileURLWithPath: "/")) { - chatWindowViewModel.chat = chat - chatWindowViewModel.isPanelDisplayed = true - - if chatWindowViewModel.chatPanelInASeparateWindow { - self.updateWindowLocation() - } - - Task { @MainActor in - chatWindow.alphaValue = 1 - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - NSApplication.shared.activate(ignoringOtherApps: true) - } - } - } + store.send(.chatPanel(.presentChatPanel(forceDetach: true))) } - func closeChatRoom(fileURL: URL) { - Task { - await updateContentForActiveEditor(fileURL: fileURL) - } + func closeChatRoom() { +// store.send(.chatPanel(.closeChatPanel)) } - func presentPromptToCode(fileURL: URL) { - Task { - markAsProcessing(true) - defer { markAsProcessing(false) } - if let provider = await dataSource?.promptToCodeForFile(at: fileURL) { - sharedPanelViewModel.content = .promptToCode(provider) - sharedPanelDisplayController.isPanelDisplayed = true - - Task { @MainActor in - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - NSApplication.shared.activate(ignoringOtherApps: true) - panelWindow.makeKey() - } - } - } - } - - func discardPromptToCode(fileURL: URL) { - Task { - await updateContentForActiveEditor(fileURL: fileURL) - } + func presentPromptToCode() { + store.send(.panel(.presentPromptToCode)) } -} -// MARK: - Private - -extension SuggestionWidgetController { - private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) { - guard windowChangeObservationTask == nil else { return } - observeEditorChangeIfNeeded(app) - windowChangeObservationTask = Task { [weak self] in - let notifications = AXNotificationStream( - app: app, - notificationNames: - kAXApplicationActivatedNotification, - kAXMovedNotification, - kAXResizedNotification, - kAXMainWindowChangedNotification, - kAXFocusedWindowChangedNotification, - kAXFocusedUIElementChangedNotification, - kAXWindowMovedNotification, - kAXWindowResizedNotification, - kAXWindowMiniaturizedNotification, - kAXWindowDeminiaturizedNotification - ) - for await notification in notifications { - guard let self else { return } - try Task.checkCancellation() - - self.updateWindowLocation(animated: false) - - if [ - kAXFocusedUIElementChangedNotification, - kAXApplicationActivatedNotification, - ].contains(notification.name) { - sourceEditorMonitorTask?.cancel() - sourceEditorMonitorTask = nil - observeEditorChangeIfNeeded(app) - - guard let fileURL = try? await Environment.fetchFocusedElementURI() else { - continue - } - - guard fileURL != currentFileURL else { continue } - currentFileURL = fileURL - widgetViewModel.currentFileURL = currentFileURL - await updateContentForActiveEditor(fileURL: fileURL) - } - } - } - } - - private func observeEditorChangeIfNeeded(_ app: NSRunningApplication) { - guard sourceEditorMonitorTask == nil else { return } - let appElement = AXUIElementCreateApplication(app.processIdentifier) - if let focusedElement = appElement.focusedElement, - focusedElement.description == "Source Editor", - let scrollView = focusedElement.parent, - let scrollBar = scrollView.verticalScrollBar - { - sourceEditorMonitorTask = Task { [weak self] in - let selectionRangeChange = AXNotificationStream( - app: app, - element: focusedElement, - notificationNames: kAXSelectedTextChangedNotification - ) - let scroll = AXNotificationStream( - app: app, - element: scrollBar, - notificationNames: kAXValueChangedNotification - ) - - if #available(macOS 13.0, *) { - for await _ in merge( - selectionRangeChange.debounce(for: Duration.milliseconds(500)), - scroll - ) { - guard let self else { return } - guard ActiveApplicationMonitor.latestXcode != nil else { return } - try Task.checkCancellation() - self.updateWindowLocation(animated: false) - } - } else { - for await _ in merge(selectionRangeChange, scroll) { - guard let self else { return } - guard ActiveApplicationMonitor.latestXcode != nil else { return } - try Task.checkCancellation() - let mode = UserDefaults.shared.value(for: \.suggestionWidgetPositionMode) - if mode != .alignToTextCursor { break } - self.updateWindowLocation(animated: false) - } - } - } - } - } - - /// Update the window location and opacity. - private func updateWindowLocation(animated: Bool = false) { - let detachChat = chatWindowViewModel.chatPanelInASeparateWindow - - if let widgetLocation = generateWidgetLocation() { - widgetWindow.setFrame(widgetLocation.widgetFrame, display: false, animate: animated) - tabWindow.setFrame(widgetLocation.tabFrame, display: false, animate: animated) - panelWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, - display: false, - animate: animated - ) - - sharedPanelDisplayController.alignTopToAnchor = widgetLocation.defaultPanelLocation - .alignPanelTop - - if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { - suggestionWindow.setFrame( - suggestionPanelLocation.frame, - display: false, - animate: animated - ) - suggestionPanelDisplayController.isPanelOutOfFrame = false - suggestionPanelDisplayController.alignTopToAnchor = suggestionPanelLocation - .alignPanelTop - } else { - suggestionPanelDisplayController.isPanelOutOfFrame = true - } - - if detachChat { - if chatWindow.alphaValue == 0 { - chatWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, - display: false, - animate: animated - ) - } - } else { - chatWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, - display: false, - animate: animated - ) - } - } - - if let app = ActiveApplicationMonitor.activeApplication, app.isXcode { - let application = AXUIElementCreateApplication(app.processIdentifier) - /// We need this to hide the windows when Xcode is minimized. - let noFocus = application.focusedWindow == nil - panelWindow.alphaValue = noFocus ? 0 : 1 - suggestionWindow.alphaValue = noFocus ? 0 : 1 - widgetWindow.alphaValue = noFocus ? 0 : 1 - tabWindow.alphaValue = noFocus ? 0 : 1 - - if detachChat { - chatWindow.alphaValue = chatWindowViewModel.chat != nil ? 1 : 0 - } else { - chatWindow.alphaValue = noFocus ? 0 : 1 - } - } else if let app = ActiveApplicationMonitor.activeApplication, - app.bundleIdentifier == Bundle.main.bundleIdentifier - { - let noFocus = { - guard let xcode = XcodeInspector.shared.latestActiveXcode else { return true } - if let window = xcode.appElement.focusedWindow, window.role == "AXWindow" { - return false - } - return true - }() - - panelWindow.alphaValue = noFocus ? 0 : 1 - suggestionWindow.alphaValue = noFocus ? 0 : 1 - widgetWindow.alphaValue = noFocus ? 0 : 1 - tabWindow.alphaValue = noFocus ? 0 : 1 - if detachChat { - chatWindow.alphaValue = chatWindowViewModel.chat != nil ? 1 : 0 - } else { - chatWindow.alphaValue = noFocus && !chatWindow.isKeyWindow ? 0 : 1 - } - } else { - panelWindow.alphaValue = 0 - suggestionWindow.alphaValue = 0 - widgetWindow.alphaValue = 0 - tabWindow.alphaValue = 0 - if !detachChat { - chatWindow.alphaValue = 0 - } - } - } - - private func updateContentForActiveEditor(fileURL: URL? = nil) async { - guard let fileURL = await { - if let fileURL { return fileURL } - return try? await Environment.fetchCurrentFileURL() - }() else { - sharedPanelViewModel.content = nil - chatWindowViewModel.chat = nil - return - } - - if let chat = await dataSource?.chatForFile(at: fileURL) { - if chatWindowViewModel.chat?.id != chat.id { - chatWindowViewModel.chat = chat - } - } else { - chatWindowViewModel.chat = nil - } - - if let provider = await dataSource?.promptToCodeForFile(at: fileURL) { - if case let .promptToCode(currentProvider) = sharedPanelViewModel.content, - currentProvider.id == provider.id { return } - sharedPanelViewModel.content = .promptToCode(provider) - } else if let suggestion = await dataSource?.suggestionForFile(at: fileURL) { - sharedPanelViewModel.content = .suggestion(suggestion) - } else { - sharedPanelViewModel.content = nil - } - } - - private func generateWidgetLocation() -> WidgetLocation? { - if let application = XcodeInspector.shared.latestActiveXcode?.appElement { - if let focusElement = XcodeInspector.shared.focusedEditor?.element, - let parent = focusElement.parent, - let frame = parent.rect, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - let positionMode = UserDefaults.shared - .value(for: \.suggestionWidgetPositionMode) - let suggestionMode = UserDefaults.shared - .value(for: \.suggestionPresentationMode) - - switch positionMode { - case .fixedToBottom: - var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen - ) - switch suggestionMode { - case .nearbyTextCursor: - result.suggestionPanelLocation = UpdateLocationStrategy - .NearbyTextCursor() - .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement, - completionPanel: XcodeInspector.shared.completionPanel - ) - default: - break - } - return result - case .alignToTextCursor: - var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement - ) - switch suggestionMode { - case .nearbyTextCursor: - result.suggestionPanelLocation = UpdateLocationStrategy - .NearbyTextCursor() - .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement, - completionPanel: XcodeInspector.shared.completionPanel - ) - default: - break - } - return result - } - } else if var window = application.focusedWindow, - var frame = application.focusedWindow?.rect, - !["menu bar", "menu bar item"].contains(window.description), - frame.size.height > 300, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - if ["open_quickly"].contains(window.identifier) - || ["alert"].contains(window.label) - { - // fallback to use workspace window - guard let workspaceWindow = application.windows - .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), - let rect = workspaceWindow.rect - else { - return WidgetLocation( - widgetFrame: .zero, - tabFrame: .zero, - defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) - ) - } - - window = workspaceWindow - frame = rect - } - - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { - // extra padding to bottom so buttons won't be covered - frame.size.height -= 40 - } else { - // move a bit away from the window so buttons won't be covered - frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 - frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth - } - - return UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - preferredInsideEditorMinWidth: 9_999_999_999 // never - ) - } - } - return nil + func discardPromptToCode() { + store.send(.panel(.discardPanelContent)) } } @@ -742,20 +249,20 @@ extension SuggestionWidgetController { extension SuggestionWidgetController: NSWindowDelegate { public func windowWillMove(_ notification: Notification) { - guard (notification.object as? NSWindow) === chatWindow else { return } + guard (notification.object as? NSWindow) === chatPanelWindow else { return } Task { @MainActor in await Task.yield() - chatWindowViewModel.chatPanelInASeparateWindow = true + store.send(.chatPanel(.detachChatPanel)) } } public func windowDidBecomeKey(_ notification: Notification) { - guard (notification.object as? NSWindow) === chatWindow else { return } + guard (notification.object as? NSWindow) === chatPanelWindow else { return } let screenFrame = NSScreen.screens.first(where: { $0.frame.origin == .zero })? .frame ?? .zero var mouseLocation = NSEvent.mouseLocation - let windowFrame = chatWindow.frame - if mouseLocation.y > windowFrame.maxY - 40, + let windowFrame = chatPanelWindow.frame + if mouseLocation.y > windowFrame.maxY - 16, mouseLocation.y < windowFrame.maxY, mouseLocation.x > windowFrame.minX, mouseLocation.x < windowFrame.maxX @@ -769,7 +276,7 @@ extension SuggestionWidgetController: NSWindowDelegate { ), let event = NSEvent(cgEvent: cgEvent) { - chatWindow.performDrag(with: event) + chatPanelWindow.performDrag(with: event) } } } @@ -790,7 +297,7 @@ class ChatWindow: NSWindow { override func mouseDown(with event: NSEvent) { let windowFrame = frame let currentLocation = event.locationInWindow - if currentLocation.y > windowFrame.size.height - 40, + if currentLocation.y > windowFrame.size.height - 16, currentLocation.y < windowFrame.size.height, currentLocation.x > 0, currentLocation.x < windowFrame.width diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index 90082602..150ab938 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -1,8 +1,8 @@ +import ChatTab import Foundation public protocol SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> SuggestionProvider? - func chatForFile(at url: URL) async -> ChatProvider? func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? } @@ -23,11 +23,8 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { ) } - func chatForFile(at url: URL) async -> ChatProvider? { - return nil - } - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { return nil } } + diff --git a/Core/Sources/SuggestionWidget/TabView.swift b/Core/Sources/SuggestionWidget/TabView.swift index 7582896a..82166169 100644 --- a/Core/Sources/SuggestionWidget/TabView.swift +++ b/Core/Sources/SuggestionWidget/TabView.swift @@ -1,32 +1,49 @@ +import ComposableArchitecture import SwiftUI struct TabView: View { - @ObservedObject var chatWindowViewModel: ChatWindowViewModel + let store: StoreOf + + struct State: Equatable { + var chatPanelInASeparateWindow: Bool + var colorScheme: ColorScheme + } var body: some View { - Button(action: { - chatWindowViewModel.chatPanelInASeparateWindow = false - }, label: { - Image(systemName: "ellipsis.bubble.fill") - .frame(width: Style.widgetWidth, height: Style.widgetHeight) - .background( - Color.userChatContentBackground, - in: Circle() + WithViewStore( + store, + observe: { + State( + chatPanelInASeparateWindow: $0.chatPanelInASeparateWindow, + colorScheme: $0.colorScheme ) - }) - .buttonStyle(.plain) - .opacity(chatWindowViewModel.chatPanelInASeparateWindow ? 1 : 0) - .preferredColorScheme(chatWindowViewModel.colorScheme) - .frame(maxWidth: Style.widgetWidth, maxHeight: Style.widgetHeight) + } + ) { viewStore in + Button(action: { + viewStore.send(.toggleChatPanelDetachedButtonClicked) + }, label: { + Image(systemName: "ellipsis.bubble.fill") + .frame(width: Style.widgetWidth, height: Style.widgetHeight) + .background( + Color.userChatContentBackground, + in: Circle() + ) + }) + .buttonStyle(.plain) + .opacity(viewStore.chatPanelInASeparateWindow ? 1 : 0) + .preferredColorScheme(viewStore.colorScheme) + .frame(maxWidth: Style.widgetWidth, maxHeight: Style.widgetHeight) + } } } struct TabView_Preview: PreviewProvider { static var previews: some View { VStack { - TabView(chatWindowViewModel: .init()) + TabView(store: .init(initialState: .init(), reducer: ChatPanelFeature())) } .frame(width: 30) .background(Color.black) } } + diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 231053c1..7273eff9 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -1,161 +1,110 @@ import ActiveApplicationMonitor +import ComposableArchitecture import Environment import Preferences import SuggestionModel import SwiftUI -@MainActor -final class WidgetViewModel: ObservableObject { - struct IsProcessingCounter { - var expirationDate: TimeInterval - } - - private var isProcessingCounters = [IsProcessingCounter]() - private var cleanupIsProcessingCounterTask: Task? - @Published private(set) var isProcessing: Bool - @Published var currentFileURL: URL? - - func markIsProcessing(date: Date = Date()) { - let deadline = date.timeIntervalSince1970 + 20 - isProcessingCounters.append(IsProcessingCounter(expirationDate: deadline)) - isProcessing = true - - cleanupIsProcessingCounterTask?.cancel() - cleanupIsProcessingCounterTask = Task { [weak self] in - try await Task.sleep(nanoseconds: 20 * 1_000_000_000) - try Task.checkCancellation() - Task { @MainActor [weak self] in - guard let self else { return } - isProcessingCounters.removeAll() - isProcessing = false - } - } - } - - func endIsProcessing(date: Date = Date()) { - if !isProcessingCounters.isEmpty { - isProcessingCounters.removeFirst() - } - isProcessingCounters.removeAll(where: { $0.expirationDate < date.timeIntervalSince1970 }) - isProcessing = !isProcessingCounters.isEmpty - } - - init(isProcessing: Bool = false) { - self.isProcessing = isProcessing - } -} - struct WidgetView: View { - @ObservedObject var viewModel: WidgetViewModel - @ObservedObject var panelViewModel: SharedPanelViewModel - @ObservedObject var chatWindowViewModel: ChatWindowViewModel - @ObservedObject var sharedPanelDisplayController: SharedPanelDisplayController - @ObservedObject var suggestionPanelDisplayController: SuggestionPanelDisplayController + let store: StoreOf @State var isHovering: Bool = false - @State var processingProgress: Double = 0 var onOpenChatClicked: () -> Void = {} var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } var body: some View { - Circle().fill(isHovering ? .white.opacity(0.8) : .white.opacity(0.3)) + Circle() + .fill(isHovering ? .white.opacity(0.8) : .white.opacity(0.3)) .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { - let wasDisplayed = { - if (sharedPanelDisplayController.isPanelDisplayed || suggestionPanelDisplayController.isPanelDisplayed), - panelViewModel.content != nil { return true } - if chatWindowViewModel.isPanelDisplayed, - chatWindowViewModel.chat != nil { return true } - return false - }() - sharedPanelDisplayController.isPanelDisplayed = !wasDisplayed - suggestionPanelDisplayController.isPanelDisplayed = !wasDisplayed - chatWindowViewModel.isPanelDisplayed = !wasDisplayed - let isDisplayed = !wasDisplayed - - if !isDisplayed { - if let app = ActiveApplicationMonitor.previousActiveApplication, - app.isXcode - { - app.activate() - } - } + store.send(.widgetClicked) } } - .overlay { - let minimumLineWidth: Double = 3 - let lineWidth = (1 - processingProgress) * - (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth - let scale = max(processingProgress * 1, 0.0001) - let empty = panelViewModel.content == nil && chatWindowViewModel.chat == nil - - ZStack { - Circle() - .stroke( - Color(nsColor: .darkGray), - style: .init(lineWidth: minimumLineWidth) - ) - .padding(minimumLineWidth / 2) - - // how do I stop the repeatForever animation without removing the view? - // I tried many solutions found on stackoverflow but non of them works. - if viewModel.isProcessing { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity(!empty || viewModel.isProcessing ? 1 : 0) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1).repeatForever(autoreverses: true), - value: processingProgress - ) - } else { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity(!empty || viewModel.isProcessing ? 1 : 0) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1), - value: processingProgress - ) - } - } - } - .onChange(of: viewModel.isProcessing) { _ in refreshRing() } - .onChange(of: panelViewModel.content?.contentHash) { _ in refreshRing() } - .onChange(of: chatWindowViewModel.chat?.id) { _ in refreshRing() } + .overlay { overlayCircle } .onHover { yes in withAnimation(.easeInOut(duration: 0.2)) { isHovering = yes } }.contextMenu { - WidgetContextMenu( - chatWindowViewModel: chatWindowViewModel, - widgetViewModel: viewModel, - isChatOpen: chatWindowViewModel.isPanelDisplayed - && chatWindowViewModel.chat != nil, - onOpenChatClicked: onOpenChatClicked, - onCustomCommandClicked: onCustomCommandClicked - ) + WidgetContextMenu(store: store) } } - func refreshRing() { - Task { - await Task.yield() - if viewModel.isProcessing { - processingProgress = 1 - processingProgress - } else { - let empty = panelViewModel.content == nil && chatWindowViewModel.chat == nil - processingProgress = empty ? 0 : 1 + struct OverlayCircleState: Equatable { + var isProcessing: Bool + var isContentEmpty: Bool + } + + @ViewBuilder var overlayCircle: some View { + WithViewStore(store, observe: { $0.animationProgress }) { viewStore in + let processingProgress = viewStore.state + let minimumLineWidth: Double = 3 + let lineWidth = (1 - processingProgress) * + (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth + let scale = max(processingProgress * 1, 0.0001) + ZStack { + Circle() + .stroke( + Color(nsColor: .darkGray), + style: .init(lineWidth: minimumLineWidth) + ) + .padding(minimumLineWidth / 2) + + // how do I stop the repeatForever animation without removing the view? + // I tried many solutions found on stackoverflow but non of them works. + WithViewStore( + store, + observe: { + OverlayCircleState( + isProcessing: $0.isProcessing, + isContentEmpty: $0.isContentEmpty + ) + } + ) { viewStore in + Group { + if viewStore.isProcessing { + Circle() + .stroke( + Color.accentColor, + style: .init(lineWidth: lineWidth) + ) + .padding(minimumLineWidth / 2) + .scaleEffect(x: scale, y: scale) + .opacity( + !viewStore.isContentEmpty || viewStore + .isProcessing ? 1 : 0 + ) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 1) + .repeatForever(autoreverses: true), + value: processingProgress + ) + } else { + Circle() + .stroke( + Color.accentColor, + style: .init(lineWidth: lineWidth) + ) + .padding(minimumLineWidth / 2) + .scaleEffect(x: scale, y: scale) + .opacity( + !viewStore.isContentEmpty || viewStore + .isProcessing ? 1 : 0 + ) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 1), + value: processingProgress + ) + } + } + .onChange(of: viewStore.isProcessing) { _ in + viewStore.send(._refreshRing) + } + .onChange(of: viewStore.isContentEmpty) { _ in + viewStore.send(._refreshRing) + } + } } } } @@ -169,22 +118,20 @@ struct WidgetContextMenu: View { @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList @AppStorage(\.customCommands) var customCommands - @ObservedObject var chatWindowViewModel: ChatWindowViewModel - @ObservedObject var widgetViewModel: WidgetViewModel - @State var projectPath: String? - @State var fileURL: URL? - var isChatOpen: Bool - var onOpenChatClicked: () -> Void = {} - var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } + let store: StoreOf + + @Dependency(\.xcodeInspector) var xcodeInspector var body: some View { Group { Group { // Commands - if !isChatOpen { - Button(action: { - onOpenChatClicked() - }) { - Text("Open Chat") + WithViewStore(store, observe: { $0.isChatOpen }) { viewStore in + if !viewStore.state { + Button(action: { + viewStore.send(.openChatButtonClicked) + }) { + Text("Open Chat") + } } } @@ -202,12 +149,17 @@ struct WidgetContextMenu: View { Divider() Group { // Settings - Button(action: { - chatWindowViewModel.chatPanelInASeparateWindow.toggle() - }) { - Text("Detach Chat Panel") - if chatWindowViewModel.chatPanelInASeparateWindow { - Image(systemName: "checkmark") + WithViewStore( + store, + observe: { $0.isChatPanelDetached } + ) { viewStore in + Button(action: { + viewStore.send(.detachChatPanelToggleClicked) + }) { + Text("Detach Chat Panel") + if viewStore.state { + Image(systemName: "checkmark") + } } } @@ -241,37 +193,13 @@ struct WidgetContextMenu: View { Divider() } - .onAppear { - updateProjectPath(fileURL: widgetViewModel.currentFileURL) - } - .onChange(of: widgetViewModel.currentFileURL) { fileURL in - updateProjectPath(fileURL: fileURL) - } - } - - func updateProjectPath(fileURL: URL?) { - Task { - let projectURL: URL? = await { - if let url = try? await Environment.fetchCurrentProjectRootURLFromXcode() { - return url - } - guard let fileURL else { return nil } - return try? await Environment.guessProjectRootURLForFile(fileURL) - }() - if let projectURL { - Task { @MainActor in - self.fileURL = fileURL - self.projectPath = projectURL.path - } - } - } } func customCommandMenu() -> some View { Menu("Custom Commands") { ForEach(customCommands, id: \.name) { command in Button(action: { - onCustomCommandClicked(command) + store.send(.runCustomCommandButtonClicked(command)) }) { Text(command.name) } @@ -283,22 +211,25 @@ struct WidgetContextMenu: View { extension WidgetContextMenu { @ViewBuilder var enableSuggestionForProject: some View { - if let projectPath, disableSuggestionFeatureGlobally { - let matchedPath = suggestionFeatureEnabledProjectList.first { path in - projectPath.hasPrefix(path) - } - Button(action: { - if matchedPath != nil { - suggestionFeatureEnabledProjectList - .removeAll { path in path == matchedPath } - } else { - suggestionFeatureEnabledProjectList.append(projectPath) + WithViewStore(store) { _ in + let projectPath = xcodeInspector.activeProjectURL.path + if disableSuggestionFeatureGlobally { + let matchedPath = suggestionFeatureEnabledProjectList.first { path in + projectPath.hasPrefix(path) } - }) { - if matchedPath == nil { - Text("Add to Suggestion-Enabled Project List") - } else { - Text("Remove from Suggestion-Enabled Project List") + Button(action: { + if matchedPath != nil { + suggestionFeatureEnabledProjectList + .removeAll { path in path == matchedPath } + } else { + suggestionFeatureEnabledProjectList.append(projectPath) + } + }) { + if matchedPath == nil { + Text("Add to Suggestion-Enabled Project List") + } else { + Text("Remove from Suggestion-Enabled Project List") + } } } } @@ -306,7 +237,8 @@ extension WidgetContextMenu { @ViewBuilder var disableSuggestionForLanguage: some View { - if let fileURL { + WithViewStore(store) { _ in + let fileURL = xcodeInspector.activeDocumentURL let fileLanguage = languageIdentifierFromFileURL(fileURL) let matched = suggestionFeatureDisabledLanguageList.first { rawValue in fileLanguage.rawValue == rawValue @@ -332,45 +264,58 @@ struct WidgetView_Preview: PreviewProvider { static var previews: some View { VStack { WidgetView( - viewModel: .init(isProcessing: false), - panelViewModel: .init(), - chatWindowViewModel: .init(), - sharedPanelDisplayController: .init(), - suggestionPanelDisplayController: .init(), + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: CircularWidgetFeature() + ), isHovering: false ) WidgetView( - viewModel: .init(isProcessing: false), - panelViewModel: .init(), - chatWindowViewModel: .init(), - sharedPanelDisplayController: .init(), - suggestionPanelDisplayController: .init(), + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: CircularWidgetFeature() + ), isHovering: true ) WidgetView( - viewModel: .init(isProcessing: true), - panelViewModel: .init(), - chatWindowViewModel: .init(), - sharedPanelDisplayController: .init(), - suggestionPanelDisplayController: .init(), + store: Store( + initialState: .init( + isProcessing: true, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: CircularWidgetFeature() + ), isHovering: false ) WidgetView( - viewModel: .init(isProcessing: false), - panelViewModel: .init( - content: .suggestion(SuggestionProvider( - code: "Hello", - startLineIndex: 0, - suggestionCount: 0, - currentSuggestionIndex: 0 - )) + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: true, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: CircularWidgetFeature() ), - chatWindowViewModel: .init(), - sharedPanelDisplayController: .init(), - suggestionPanelDisplayController: .init(), isHovering: false ) } diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift index 52606cd9..862596e6 100644 --- a/Core/Sources/XcodeInspector/XcodeInspector.swift +++ b/Core/Sources/XcodeInspector/XcodeInspector.swift @@ -226,22 +226,26 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { uiElement: window ) focusedWindow = window - focusedWindowObservations.forEach { $0.cancel() } - focusedWindowObservations.removeAll() - - documentURL = window.documentURL - projectURL = window.projectURL - - window.$documentURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.documentURL = url - }.store(in: &focusedWindowObservations) - window.$projectURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.projectURL = url - }.store(in: &focusedWindowObservations) + + // should find a better solution to do this thread safe + Task { @MainActor in + focusedWindowObservations.forEach { $0.cancel() } + focusedWindowObservations.removeAll() + + documentURL = window.documentURL + projectURL = window.projectURL + + window.$documentURL + .filter { $0 != .init(fileURLWithPath: "/") } + .sink { [weak self] url in + self?.documentURL = url + }.store(in: &focusedWindowObservations) + window.$projectURL + .filter { $0 != .init(fileURLWithPath: "/") } + .sink { [weak self] url in + self?.projectURL = url + }.store(in: &focusedWindowObservations) + } } else { let window = XcodeWindowInspector(uiElement: window) focusedWindow = window diff --git a/Core/Tests/ChatServiceTests/ParseScopesTests.swift b/Core/Tests/ChatServiceTests/ParseScopesTests.swift new file mode 100644 index 00000000..78c6634f --- /dev/null +++ b/Core/Tests/ChatServiceTests/ParseScopesTests.swift @@ -0,0 +1,39 @@ +import XCTest + +@testable import ChatService + +final class ParseScopesTests: XCTestCase { + let parse = DynamicContextController.parseScopes + + func test_parse_single_scope() async throws { + var prompt = "@web hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, ["web"]) + XCTAssertEqual(prompt, "hello") + } + + func test_parse_multiple_spaces() async throws { + var prompt = "@web hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, ["web"]) + XCTAssertEqual(prompt, "hello") + } + + func test_parse_no_prefix_at_mark() async throws { + var prompt = " @web hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, []) + XCTAssertEqual(prompt, prompt) + } + + func test_parse_multiple_scopes() async throws { + var prompt = "@web+file+selection hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, ["web", "file", "selection"]) + XCTAssertEqual(prompt, "hello") + } +} + + + + diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index 823f7302..65c87fab 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -23,6 +23,8 @@ import XPCShared } Environment.triggerAction = { _ in } + + Environment.guessProjectRootURLForFile = { $0 } } func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSuggestion { diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift new file mode 100644 index 00000000..c0b3f80f --- /dev/null +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -0,0 +1,164 @@ +import Foundation +import XCTest +import SuggestionModel + +@testable import Service + +class FilespaceSuggestionInvalidationTests: XCTestCase { + @ServiceActor + func prepare(suggestionText: String, cursorPosition: CursorPosition) async throws -> Filespace { + let (_, filespace) = try await Workspace + .fetchOrCreateWorkspaceIfNeeded(fileURL: URL(fileURLWithPath: "file/path/to.swift")) + filespace.suggestions = [ + .init( + text: suggestionText, + position: cursorPosition, + uuid: "", + range: .outOfScope, + displayText: "" + ) + ] + return filespace + } + + func test_text_typing_suggestion_should_be_valid() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hell\n", "\n"], + cursorPosition: .init(line: 1, character: 4) + ) + XCTAssertTrue(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hell man\n", "\n"], + cursorPosition: .init(line: 1, character: 4) + ) + XCTAssertTrue(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_text_cursor_moved_to_another_line_should_invalidate() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hell\n", "\n"], + cursorPosition: .init(line: 2, character: 0) + ) + XCTAssertFalse(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_text_cursor_is_invalid_should_invalidate() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 100, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hell\n", "\n"], + cursorPosition: .init(line: 100, character: 4) + ) + XCTAssertFalse(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_line_content_does_not_match_input_should_invalidate() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "helo\n", "\n"], + cursorPosition: .init(line: 1, character: 4) + ) + XCTAssertFalse(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "helo\n", "\n"], + cursorPosition: .init(line: 1, character: 100) + ) + XCTAssertFalse(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello man\n", "\n"], + cursorPosition: .init(line: 1, character: 9) + ) + XCTAssertFalse(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_finish_typing_the_whole_single_line_suggestion_suggestion_is_incomplete_should_invalidate() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello man!!!!!\n", "\n"], + cursorPosition: .init(line: 1, character: 9) + ) + XCTAssertFalse(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNil(suggestion) + } + + func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws { + let filespace = try await prepare( + suggestionText: "hello man\nhow are you?", + cursorPosition: .init(line: 1, character: 0) + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello man\n", "\n"], + cursorPosition: .init(line: 1, character: 9) + ) + XCTAssertTrue(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate() async throws { + let filespace = try await prepare( + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 5) // generating man from hello + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hell\n", "\n"], + cursorPosition: .init(line: 1, character: 4) + ) + XCTAssertFalse(isValid) + let suggestion = await filespace.presentingSuggestion + XCTAssertNil(suggestion) + } +} + diff --git a/Core/Tests/SuggestionWidgetTests/ConvertToCodeLinesTests.swift b/Core/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift similarity index 99% rename from Core/Tests/SuggestionWidgetTests/ConvertToCodeLinesTests.swift rename to Core/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift index 10bb2149..351e3a6b 100644 --- a/Core/Tests/SuggestionWidgetTests/ConvertToCodeLinesTests.swift +++ b/Core/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import SuggestionWidget +@testable import SharedUIComponents final class ConvertToCodeLinesTests: XCTestCase { func test_do_not_remove_common_leading_spaces() async throws { diff --git a/Core/Tests/SuggestionWidgetTests/File.swift b/Core/Tests/SuggestionWidgetTests/File.swift new file mode 100644 index 00000000..fecc4ab4 --- /dev/null +++ b/Core/Tests/SuggestionWidgetTests/File.swift @@ -0,0 +1 @@ +import Foundation diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0cb346be..e0bb7eb7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -33,6 +33,10 @@ Most of the logics are implemented inside the package `Core`. Just run both the `ExtensionService` and the `EditorExtension` Target. +## SwiftUI Previews + +Looks like SwiftUI Previews are not very happy with Objective-C packages when running with app targets. To use previews, please switch schemes to the package product targets. + ## Unit Tests To run unit tests, just run test from the `Copilot for Xcode` target. diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index d7bed6bb..0ee34dcc 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -1,4 +1,3 @@ - import Environment import FileChangeChecker import LaunchAgentManager @@ -43,7 +42,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() DependencyUpdater().update() - initializePython() + Task { do { try await ServiceUpdateMigrator().migrate() diff --git a/ExtensionService/ExtensionService.entitlements b/ExtensionService/ExtensionService.entitlements index 5a41052f..ae1430f1 100644 --- a/ExtensionService/ExtensionService.entitlements +++ b/ExtensionService/ExtensionService.entitlements @@ -6,6 +6,8 @@ $(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE) + com.apple.security.cs.disable-library-validation + keychain-access-groups $(AppIdentifierPrefix)$(BUNDLE_IDENTIFIER_BASE).Shared diff --git a/ExtensionService/InitializePython.swift b/ExtensionService/InitializePython.swift deleted file mode 100644 index 7f438781..00000000 --- a/ExtensionService/InitializePython.swift +++ /dev/null @@ -1,38 +0,0 @@ -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 index d985bfb3..c0fbddcf 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ -setup: setup-langchain +setup: + echo "Setup." setup-langchain: + echo "Don't 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 + cp module.modulemap.copy 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 +.PHONY: setup setup-langchain diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..18fe2fb5 --- /dev/null +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/Contents.swift @@ -0,0 +1,98 @@ +import AppKit +import LangChain +import OpenAIService +import PlaygroundSupport +import SwiftUI + +struct QAForm: View { + @State var intermediateAnswers = [String]() + @State var answer: String = "" + @State var question: String = "What is Swift macros?" + @State var isProcessing: Bool = false + @State var url: String = "https://developer.apple.com/documentation/swift/applying-macros" + + var body: some View { + Form { + Section(header: Text("Input")) { + TextField("URL", text: $url) + TextField("Question", text: $question) + Button("Ask") { + Task { + do { + try await ask() + } catch { + answer = error.localizedDescription + } + } + } + .disabled(isProcessing) + } + Section(header: Text("Answer")) { + Text(answer) + } + Section(header: Text("Intermediate Answers")) { + ForEach(intermediateAnswers, id: \.self) { answer in + Text(answer) + Divider() + } + } + } + .formStyle(.grouped) + } + + func ask() async throws { + intermediateAnswers = [] + isProcessing = true + defer { isProcessing = false } + guard let url = URL(string: url) else { + answer = "Invalid URL" + return + } + let chatGPTConfiguration = UserPreferenceChatGPTConfiguration() + .overriding { $0.temperature = 0 } + let embeddingConfiguration = UserPreferenceEmbeddingConfiguration().overriding() + let embedding = OpenAIEmbedding(configuration: embeddingConfiguration) + let store: VectorStore = try await { + if let store = await TemporaryUSearch.view(identifier: url.absoluteString) { + return store + } else { + let webLoader = WebLoader(urls: [url]) + let store = TemporaryUSearch(identifier: url.absoluteString) + let webDocuments = try await webLoader.load() + let splitter = RecursiveCharacterTextSplitter( + chunkSize: 1000, + chunkOverlap: 100 + ) + let splitDocuments = try await splitter.transformDocuments(webDocuments) + let embeddedDocuments = try await embedding.embed(documents: splitDocuments) + try await store.set(embeddedDocuments) + return store + } + }() + + let qa = RetrievalQAChain( + vectorStore: store, + embedding: embedding, + chatModelFactory: { OpenAIChat(configuration: chatGPTConfiguration, stream: false) } + ) + answer = try await qa.run( + question, + callbackManagers: [ + .init { + $0.on(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer.self) { + intermediateAnswers.append($0) + } + }, + ] + ) + } +} + +let hostingView = NSHostingController( + rootView: QAForm() + .frame(width: 600, height: 800) +) + +PlaygroundPage.current.needsIndefiniteExecution = true +PlaygroundPage.current.liveView = hostingView + diff --git a/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/timeline.xctimeline b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/timeline.xctimeline new file mode 100644 index 00000000..9014327d --- /dev/null +++ b/Playground.playground/Pages/RetrievalQAChain.xcplaygroundpage/timeline.xctimeline @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/Playground.playground/Pages/WebScrapper.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..f29a794a --- /dev/null +++ b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/Contents.swift @@ -0,0 +1,57 @@ +import AppKit +import LangChain +import PlaygroundSupport +import SwiftUI + +struct ScrapperForm: View { + @State var webDocuments: [Document] = [] + @State var isProcessing: Bool = false + @State var url: String = "https://developer.apple.com/documentation/swift/applying-macros" + + var body: some View { + Form { + Section(header: Text("Input")) { + TextField("URL", text: $url) + Button("Scrap") { + Task { + do { + try await scrap() + } catch { + webDocuments = + [.init(pageContent: error.localizedDescription, metadata: [:])] + } + } + } + .disabled(isProcessing) + } + Section(header: Text("Web Content")) { + ForEach(webDocuments, id: \.pageContent) { document in + VStack(alignment: .leading) { + Text(document.pageContent) + .font(.body) + } + Divider() + } + } + } + .formStyle(.grouped) + } + + func scrap() async throws { + webDocuments = [] + isProcessing = true + defer { isProcessing = false } + guard let url = URL(string: url) else { return } + let webLoader = WebLoader(urls: [url]) + webDocuments = try await webLoader.load() + } +} + +let hostingView = NSHostingController( + rootView: ScrapperForm() + .frame(width: 600, height: 800) +) + +PlaygroundPage.current.needsIndefiniteExecution = true +PlaygroundPage.current.liveView = hostingView + diff --git a/Playground.playground/Pages/WebScrapper.xcplaygroundpage/timeline.xctimeline b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/timeline.xctimeline new file mode 100644 index 00000000..bf468afe --- /dev/null +++ b/Playground.playground/Pages/WebScrapper.xcplaygroundpage/timeline.xctimeline @@ -0,0 +1,6 @@ + + + + + diff --git a/Playground.playground/contents.xcplayground b/Playground.playground/contents.xcplayground new file mode 100644 index 00000000..2f0f29c9 --- /dev/null +++ b/Playground.playground/contents.xcplayground @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Python/Package.swift b/Python/Package.swift new file mode 100644 index 00000000..db8581e9 --- /dev/null +++ b/Python/Package.swift @@ -0,0 +1,18 @@ +// 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: "Python", + platforms: [.macOS(.v12)], + products: [ + .library(name: "Python", targets: ["Python", "PythonResources"]), + ], + dependencies: [], + targets: [ + .binaryTarget(name: "Python", path: "Python.xcframework"), + .target(name: "PythonResources") + ] +) + diff --git a/Python/Sources/PythonResources/Export.swift b/Python/Sources/PythonResources/Export.swift new file mode 100644 index 00000000..6446e9c0 --- /dev/null +++ b/Python/Sources/PythonResources/Export.swift @@ -0,0 +1,39 @@ +import Foundation + +class BundleFinder {} + +let containingBundle: Bundle? = { + if Bundle.main.path(forResource: "site-packages", ofType: nil) != nil { + return Bundle.main + } + + if Bundle.main.bundlePath.contains("Contents/Developer/Platforms") { + // unit tests + let bundle = Bundle(for: BundleFinder.self) + let path = bundle.bundleURL + .deletingLastPathComponent() + .appendingPathComponent("CopilotForXcodeExtensionService.app").path + return Bundle(path: path) + } + + let path = Bundle.main.bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Applications") + .appendingPathComponent("CopilotForXcodeExtensionService.app").path + + return Bundle(path: path) +}() + +public let sitePackagePath = containingBundle?.path( + forResource: "site-packages", + ofType: nil +) +public let stdLibPath = containingBundle?.path( + forResource: "python-stdlib", + ofType: nil +) +public let libDynloadPath = containingBundle?.path( + forResource: "python-stdlib/lib-dynload", + ofType: nil +) + diff --git a/Python/module.modulemap b/Python/module.modulemap deleted file mode 100644 index d89495b6..00000000 --- a/Python/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module Python { - umbrella header "Python.h" - export * - link "Python" -} \ No newline at end of file diff --git a/Python/module.modulemap.copy b/Python/module.modulemap.copy new file mode 100644 index 00000000..78e9071a --- /dev/null +++ b/Python/module.modulemap.copy @@ -0,0 +1,87 @@ +module Python { + umbrella header "Python.h" + export * + link "Python" + exclude header "marshal.h" + exclude header "py_curses.h" + exclude header "errcode.h" + exclude header "structmember.h" + exclude header "frameobject.h" + exclude header "/internal/pycore_pyerrors.h" + exclude header "/internal/pycore_warnings.h" + exclude header "/internal/pycore_global_strings.h" + exclude header "/internal/pycore_asdl.h" + exclude header "/internal/pycore_getopt.h" + exclude header "/internal/pycore_hamt.h" + exclude header "/internal/pycore_pymath.h" + exclude header "/internal/pycore_parser.h" + exclude header "/internal/pycore_accu.h" + exclude header "/internal/pycore_runtime_init.h" + exclude header "/internal/pycore_pylifecycle.h" + exclude header "/internal/pycore_runtime.h" + exclude header "/internal/pycore_emscripten_signal.h" + exclude header "/internal/pycore_namespace.h" + exclude header "/internal/pycore_genobject.h" + exclude header "/internal/pycore_gil.h" + exclude header "/internal/pycore_exceptions.h" + exclude header "/internal/pycore_compile.h" + exclude header "/internal/pycore_signal.h" + exclude header "/internal/pycore_atomic_funcs.h" + exclude header "/internal/pycore_sliceobject.h" + exclude header "/internal/pycore_code.h" + exclude header "/internal/pycore_unionobject.h" + exclude header "/internal/pycore_pyhash.h" + exclude header "/internal/pycore_tuple.h" + exclude header "/internal/pycore_long.h" + exclude header "/internal/pycore_ucnhash.h" + exclude header "/internal/pycore_fileutils.h" + exclude header "/internal/pycore_floatobject.h" + exclude header "/internal/pycore_call.h" + exclude header "/internal/pycore_hashtable.h" + exclude header "/internal/pycore_abstract.h" + exclude header "/internal/pycore_list.h" + exclude header "/internal/pycore_pymem.h" + exclude header "/internal/pycore_structseq.h" + exclude header "/internal/pycore_bitutils.h" + exclude header "/internal/pycore_interpreteridobject.h" + exclude header "/internal/pycore_pystate.h" + exclude header "/internal/pycore_gc.h" + exclude header "/internal/pycore_traceback.h" + exclude header "/internal/pycore_dict.h" + exclude header "/internal/pycore_strhex.h" + exclude header "/internal/pycore_atomic.h" + exclude header "/internal/pycore_import.h" + exclude header "/internal/pycore_symtable.h" + exclude header "/internal/pycore_sysmodule.h" + exclude header "/internal/pycore_ast_state.h" + exclude header "/internal/pycore_pyarena.h" + exclude header "/internal/pycore_initconfig.h" + exclude header "/internal/pycore_bytesobject.h" + exclude header "/internal/pycore_frame.h" + exclude header "/internal/pycore_opcode.h" + exclude header "/internal/pycore_interp.h" + exclude header "/internal/pycore_dtoa.h" + exclude header "/internal/pycore_format.h" + exclude header "/internal/pycore_function.h" + exclude header "/internal/pycore_condvar.h" + exclude header "/internal/pycore_typeobject.h" + exclude header "/internal/pycore_bytes_methods.h" + exclude header "/internal/pycore_object.h" + exclude header "/internal/pycore_ceval.h" + exclude header "/internal/pycore_pathconfig.h" + exclude header "/internal/pycore_moduleobject.h" + exclude header "/internal/pycore_global_objects.h" + exclude header "/internal/pycore_unicodeobject.h" + exclude header "/internal/pycore_blocks_output_buffer.h" + exclude header "/internal/pycore_context.h" + exclude header "/internal/pycore_ast.h" + exclude header "datetime.h" + exclude header "pyexpat.h" + exclude header "/cpython/pthread_stubs.h" + exclude header "/cpython/frameobject.h" + exclude header "token.h" + exclude header "osdefs.h" + exclude header "opcode.h" + exclude header "pydtrace.h" + exclude header "dynamic_annotations.h" +} diff --git a/Python/site-packages/install.sh b/Python/site-packages/install.sh deleted file mode 100755 index 97ce7cda..00000000 --- a/Python/site-packages/install.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/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 deleted file mode 100644 index 214e842d..00000000 --- a/Python/site-packages/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -openai -langchain -readability-lxml -tiktoken diff --git a/README.md b/README.md index 7111dbcf..561f4eb6 100644 --- a/README.md +++ b/README.md @@ -251,10 +251,11 @@ This feature is recommended when you need to update a specific piece of code. So You can create custom commands that run Chat and Prompt to Code with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the circular widget. There are 3 types of custom commands: - Prompt to Code: Run Prompt to Code with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field. -- Open Chat: Open the chat window and immediately send a message, if provided. You can provide more information through the extra system prompt field. +- Send Message: 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. +- Single Round Dialog: Send a message to a temporary chat. Useful when you want to run a terminal command with `/run`. -For Open Chat and Custom Chat commands, you can use the following template arguments: +For Send Message, Single Round Dialog and Custom Chat commands, you can use the following template arguments: | Argument | Description | | ----------------------------- | ---------------------------------------------- | diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 7bb78eff..6b9a6a3a 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -77,6 +77,27 @@ "identifier" : "OpenAIServiceTests", "name" : "OpenAIServiceTests" } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" + } } ], "version" : 1 diff --git a/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme b/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme new file mode 100644 index 00000000..3bb0323b --- /dev/null +++ b/Tool/.swiftpm/xcode/xcshareddata/xcschemes/Tool-Package.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tool/Package.swift b/Tool/Package.swift index 337eed1f..2de22106 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -8,17 +8,20 @@ let package = Package( platforms: [.macOS(.v12)], products: [ .library(name: "Terminal", targets: ["Terminal"]), - .library(name: "LangChain", targets: ["LangChain", "PythonHelper", "BingSearchService"]), + .library(name: "LangChain", targets: ["LangChain"]), + .library(name: "ExternalServices", targets: ["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"), - // TODO: Switch to Tiktoken. https://github.com/aespinilla/Tiktoken - .package(url: "https://github.com/alfianlosari/GPTEncoder", from: "1.0.4"), + // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. + .package(url: "https://github.com/intitni/Tiktoken", branch: "main"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), - .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1") + .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), + .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.6.0"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), + .package(url: "https://github.com/unum-cloud/usearch", from: "0.19.1"), ], targets: [ // MARK: - Helpers @@ -31,27 +34,42 @@ let package = Package( .target(name: "Logger"), - // MARK: - Services + .target(name: "ObjectiveCExceptionHandling"), + + .target(name: "USearchIndex", dependencies: [ + "ObjectiveCExceptionHandling", + .product(name: "USearch", package: "usearch"), + ]), .target( - name: "LangChain", + name: "TokenEncoder", dependencies: [ - "PythonHelper", - "OpenAIService", - .product(name: "Parsing", package: "swift-parsing"), - .product(name: "PythonKit", package: "PythonKit"), + .product(name: "Tiktoken", package: "Tiktoken"), + ], + resources: [ + .copy("Resources/cl100k_base.tiktoken"), ] ), + .testTarget( + name: "TokenEncoderTests", + dependencies: ["TokenEncoder"] + ), - .target(name: "BingSearchService"), + // MARK: - Services .target( - name: "PythonHelper", + name: "LangChain", dependencies: [ - .product(name: "PythonKit", package: "PythonKit"), + "OpenAIService", + "ObjectiveCExceptionHandling", + "USearchIndex", + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "SwiftSoup", package: "SwiftSoup"), ] ), + .target(name: "BingSearchService"), + // MARK: - OpenAI .target( @@ -59,7 +77,8 @@ let package = Package( dependencies: [ "Logger", "Preferences", - .product(name: "GPTEncoder", package: "GPTEncoder"), + "TokenEncoder", + .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), diff --git a/Tool/Sources/BingSearchService/BingSearchService.swift b/Tool/Sources/BingSearchService/BingSearchService.swift index a2d3f847..13fe2b18 100644 --- a/Tool/Sources/BingSearchService/BingSearchService.swift +++ b/Tool/Sources/BingSearchService/BingSearchService.swift @@ -30,11 +30,14 @@ struct BingSearchResponseError: Codable, Error, LocalizedError { enum BingSearchError: Error, LocalizedError { case searchURLFormatIncorrect(String) + case subscriptionKeyNotAvailable var errorDescription: String? { switch self { case let .searchURLFormatIncorrect(url): return "The search URL format is incorrect: \(url)" + case .subscriptionKeyNotAvailable: + return "The user doesn't provide a subscription key to use Bing search." } } } @@ -48,7 +51,12 @@ public struct BingSearchService { self.searchURL = searchURL } - public func search(query: String, numberOfResult: Int) async throws -> BingSearchResult { + public func search( + query: String, + numberOfResult: Int, + freshness: String? = nil + ) async throws -> BingSearchResult { + guard !subscriptionKey.isEmpty else { throw BingSearchError.subscriptionKeyNotAvailable } guard let url = URL(string: searchURL) else { throw BingSearchError.searchURLFormatIncorrect(searchURL) } @@ -56,7 +64,8 @@ public struct BingSearchService { components?.queryItems = [ .init(name: "q", value: query), .init(name: "count", value: String(numberOfResult)), - ] + freshness.map { .init(name: "freshness", value: $0) }, + ].compactMap { $0 } var request = URLRequest(url: components?.url ?? url) request.httpMethod = "GET" request.addValue(subscriptionKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key") diff --git a/Tool/Sources/LangChain/Agent.swift b/Tool/Sources/LangChain/Agent.swift index 242416a2..b5da456a 100644 --- a/Tool/Sources/LangChain/Agent.swift +++ b/Tool/Sources/LangChain/Agent.swift @@ -20,6 +20,20 @@ public struct AgentAction: Equatable { } } +public extension CallbackEvents { + struct AgentDidFinish: CallbackEvent { + public let info: AgentFinish + } + + struct AgentActionDidStart: CallbackEvent { + public let info: AgentAction + } + + struct AgentActionDidEnd: CallbackEvent { + public let info: AgentAction + } +} + public struct AgentFinish: Equatable { public var returnValue: String public var log: String @@ -38,12 +52,12 @@ public enum AgentNextStep: Equatable { public enum AgentScratchPad: Equatable { case text(String) case messages([String]) - + var isEmpty: Bool { switch self { - case .text(let text): + case let .text(text): return text.isEmpty - case .messages(let messages): + case let .messages(messages): return messages.isEmpty } } @@ -86,7 +100,7 @@ public extension Agent { func plan( input: Input, intermediateSteps: [AgentAction], - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> AgentNextStep { let input = getFullInputs(input: input, intermediateSteps: intermediateSteps) let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) @@ -97,7 +111,7 @@ public extension Agent { input: Input, earlyStoppedHandleType: AgentEarlyStopHandleType, intermediateSteps: [AgentAction], - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> AgentFinish { switch earlyStoppedHandleType { case .force: @@ -108,7 +122,7 @@ public extension Agent { case .generate: var thoughts = constructBaseScratchpad(intermediateSteps: intermediateSteps) thoughts += """ - + \(llmPrefix)I now need to return a final answer based on the previous steps: (Please continue with `Final Answer:`) """ diff --git a/Tool/Sources/LangChain/AgentExecutor.swift b/Tool/Sources/LangChain/AgentExecutor.swift index 02d2dc33..40540f79 100644 --- a/Tool/Sources/LangChain/AgentExecutor.swift +++ b/Tool/Sources/LangChain/AgentExecutor.swift @@ -1,13 +1,5 @@ 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 { @@ -39,7 +31,7 @@ public actor AgentExecutor: Chain where InnerAgent.Input == S public func callLogic( _ input: Input, - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> Output { try agent.validateTools(tools: Array(tools.values)) @@ -89,7 +81,7 @@ public actor AgentExecutor: Chain where InnerAgent.Input == S } iterations += 1 } - + let output = try await agent.returnStoppedResponse( input: input, earlyStoppedHandleType: earlyStopHandleType, @@ -106,7 +98,7 @@ public actor AgentExecutor: Chain where InnerAgent.Input == S public nonisolated func parseOutput(_ output: Output) -> String { output.finalOutput } - + public func cancel() { isCancelled = true earlyStopHandleType = .force @@ -119,10 +111,10 @@ extension AgentExecutor { func end( output: AgentFinish, intermediateSteps: [AgentAction], - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) -> Output { for callbackManager in callbackManagers { - callbackManager.onAgentFinish(output: output) + callbackManager.send(CallbackEvents.AgentDidFinish(info: output)) } let finalOutput = output.returnValue return .init(finalOutput: finalOutput, intermediateSteps: intermediateSteps) @@ -131,7 +123,7 @@ extension AgentExecutor { func takeNextStep( input: Input, intermediateSteps: [AgentAction], - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> AgentNextStep { let output = try await agent.plan( input: input, @@ -144,7 +136,8 @@ extension AgentExecutor { let completedActions = try await withThrowingTaskGroup(of: AgentAction.self) { taskGroup in for action in actions { - callbackManagers.forEach { $0.onAgentActionStart(action: action) } + callbackManagers + .forEach { $0.send(CallbackEvents.AgentActionDidStart(info: action)) } guard let tool = tools[action.toolName] else { throw InvalidToolError() } taskGroup.addTask { let observation = try await tool.run(input: action.toolInput) @@ -154,7 +147,8 @@ extension AgentExecutor { var completedActions = [AgentAction]() for try await action in taskGroup { completedActions.append(action) - callbackManagers.forEach { $0.onAgentActionEnd(action: action) } + callbackManagers + .forEach { $0.send(CallbackEvents.AgentActionDidEnd(info: action)) } } return completedActions } diff --git a/Tool/Sources/LangChain/Agents/ChatAgent.swift b/Tool/Sources/LangChain/Agents/ChatAgent.swift index 0196e1ac..fb24dc4a 100644 --- a/Tool/Sources/LangChain/Agents/ChatAgent.swift +++ b/Tool/Sources/LangChain/Agents/ChatAgent.swift @@ -102,7 +102,7 @@ public class ChatAgent: Agent { (Please continue with `Thought:` or `Final Answer:`) """) } - + public func validateTools(tools: [AgentTool]) throws { // no validation } diff --git a/Tool/Sources/LangChain/Callback.swift b/Tool/Sources/LangChain/Callback.swift new file mode 100644 index 00000000..c6550e77 --- /dev/null +++ b/Tool/Sources/LangChain/Callback.swift @@ -0,0 +1,43 @@ +import Foundation + +public protocol CallbackEvent { + associatedtype Info + var info: Info { get } +} + +public enum CallbackEvents {} + +public struct CallbackManager { + fileprivate var observers = [Any]() + + public init() {} + + public init(observers: (inout CallbackManager) -> Void) { + var manager = CallbackManager() + observers(&manager) + self = manager + } + + public mutating func on( + _: Event.Type = Event.self, + _ handler: @escaping (Event.Info) -> Void + ) { + observers.append(handler) + } + + public func send(_ event: Event) { + for case let observer as ((Event.Info) -> Void) in observers { + observer(event.info) + } + } +} + +public extension [CallbackManager] { + func send(_ event: Event) { + for cb in self { + for case let observer as ((Event.Info) -> Void) in cb.observers { + observer(event.info) + } + } + } +} diff --git a/Tool/Sources/LangChain/Chain.swift b/Tool/Sources/LangChain/Chain.swift index 1db6ee75..6ff4cd8f 100644 --- a/Tool/Sources/LangChain/Chain.swift +++ b/Tool/Sources/LangChain/Chain.swift @@ -3,24 +3,40 @@ import Foundation public protocol Chain { associatedtype Input associatedtype Output - func callLogic(_ input: Input, callbackManagers: [ChainCallbackManager]) async throws -> Output + func callLogic(_ input: Input, callbackManagers: [CallbackManager]) async throws -> Output func parseOutput(_ output: Output) -> String } public extension Chain { - func run(_ input: Input, callbackManagers: [ChainCallbackManager] = []) async throws -> String { + typealias ChainDidStart = CallbackEvents.ChainDidStart + typealias ChainDidEnd = CallbackEvents.ChainDidEnd + + func run(_ input: Input, callbackManagers: [CallbackManager] = []) async throws -> String { let output = try await call(input, callbackManagers: callbackManagers) return parseOutput(output) } - func call(_ input: Input, callbackManagers: [ChainCallbackManager]) async throws -> Output { - for callbackManager in callbackManagers { - callbackManager.onChainStart(type: Self.self, input: input) + func call(_ input: Input, callbackManagers: [CallbackManager] = []) async throws -> Output { + callbackManagers + .send(CallbackEvents.ChainDidStart(info: (type: Self.self, input: input))) + defer { + callbackManagers + .send(CallbackEvents.ChainDidEnd(info: (type: Self.self, input: input))) } return try await callLogic(input, callbackManagers: callbackManagers) } } +public extension CallbackEvents { + struct ChainDidStart: CallbackEvent { + public let info: (type: T.Type, input: T.Input) + } + + struct ChainDidEnd: CallbackEvent { + public let info: (type: T.Type, input: T.Input) + } +} + public struct SimpleChain: Chain { let block: (Input) async throws -> Output let parseOutputBlock: (Output) -> String @@ -35,7 +51,7 @@ public struct SimpleChain: Chain { public func callLogic( _ input: Input, - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> Output { return try await block(input) } @@ -54,7 +70,7 @@ public struct ConnectedChain: Chain where B.Input == A.Outpu public func callLogic( _ input: Input, - callbackManagers: [ChainCallbackManager] = [] + callbackManagers: [CallbackManager] = [] ) async throws -> Output { let a = try await chainA.call(input, callbackManagers: callbackManagers) let b = try await chainB.call(a, callbackManagers: callbackManagers) @@ -75,7 +91,7 @@ public struct PairedChain: Chain { public func callLogic( _ input: Input, - callbackManagers: [ChainCallbackManager] = [] + callbackManagers: [CallbackManager] = [] ) async throws -> Output { async let a = chainA.call(input.0, callbackManagers: callbackManagers) async let b = chainB.call(input.1, callbackManagers: callbackManagers) @@ -96,7 +112,7 @@ public struct MappedChain: Chain { public func callLogic( _ input: Input, - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> Output { let output = try await chain.call(input, callbackManagers: callbackManagers) return map(output) diff --git a/Tool/Sources/LangChain/Chains/LLMChain.swift b/Tool/Sources/LangChain/Chains/LLMChain.swift index bb81f18c..c7152325 100644 --- a/Tool/Sources/LangChain/Chains/LLMChain.swift +++ b/Tool/Sources/LangChain/Chains/LLMChain.swift @@ -19,7 +19,7 @@ public class ChatModelChain: Chain { public func callLogic( _ input: Input, - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> Output { let prompt = promptTemplate(input) let output = try await chatModel.generate( diff --git a/Tool/Sources/LangChain/Chains/RetrievalQA.swift b/Tool/Sources/LangChain/Chains/RetrievalQA.swift new file mode 100644 index 00000000..023681a0 --- /dev/null +++ b/Tool/Sources/LangChain/Chains/RetrievalQA.swift @@ -0,0 +1,141 @@ +import Foundation + +public final class RetrievalQAChain: Chain { + let vectorStore: VectorStore + let embedding: Embeddings + let chatModelFactory: () -> ChatModel + + public struct Output { + public var answer: String + public var sourceDocuments: [Document] + } + + public init( + vectorStore: VectorStore, + embedding: Embeddings, + chatModelFactory: @escaping () -> ChatModel + ) { + self.vectorStore = vectorStore + self.embedding = embedding + self.chatModelFactory = chatModelFactory + } + + public func callLogic( + _ input: String, + callbackManagers: [CallbackManager] + ) async throws -> Output { + let embeddedQuestion = try await embedding.embed(query: input) + let documents = try await vectorStore.searchWithDistance( + embeddings: embeddedQuestion, + count: 5 + ) + let refinementChain = RefineDocumentChain(chatModelFactory: chatModelFactory) + let answer = try await refinementChain.run( + .init(question: input, documents: documents), + callbackManagers: callbackManagers + ) + + return .init(answer: answer, sourceDocuments: documents.map(\.document)) + } + + public func parseOutput(_ output: Output) -> String { + return output.answer + } +} + +public extension CallbackEvents { + struct RetrievalQADidGenerateIntermediateAnswer: CallbackEvent { + public let info: String + } +} + +public final class RefineDocumentChain: Chain { + public struct Input { + var question: String + var documents: [(document: Document, distance: Float)] + } + + struct InitialInput { + var question: String + var document: String + var distance: Float + } + + struct RefinementInput { + var question: String + var previousAnswer: String + var document: String + var distance: Float + } + + let initialChatModel: ChatModelChain + let refinementChatModel: ChatModelChain + + public init(chatModelFactory: () -> ChatModel) { + initialChatModel = .init( + chatModel: chatModelFactory(), + promptTemplate: { input in [ + .init(role: .system, content: """ + The user will send you a question, you must answer it at your best. + You can use the following document as a reference:### + \(input.document) + ### + """), + .init(role: .user, content: input.question), + ] } + ) + refinementChatModel = .init( + chatModel: chatModelFactory(), + promptTemplate: { input in [ + .init(role: .system, content: """ + The user will send you a question, you must refine your previous answer to it at your best. + You should focus on answering the question, there is no need to add extra details in other topics. + Previous answer:### + \(input.previousAnswer) + ### + You can use the following document as a reference:### + \(input.document) + ### + """), + .init(role: .user, content: input.question), + ] } + ) + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> String { + guard let firstDocument = input.documents.first else { + return "" + } + var output = try await initialChatModel.call( + .init( + question: input.question, + document: firstDocument.document.pageContent, + distance: firstDocument.distance + ), + callbackManagers: callbackManagers + ) + callbackManagers.send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: output)) + for document in input.documents.dropFirst(1) { + output = try await refinementChatModel.call( + .init( + question: input.question, + previousAnswer: output, + document: document.document.pageContent, + distance: document.distance + ), + callbackManagers: callbackManagers + ) + callbackManagers + .send(CallbackEvents.RetrievalQADidGenerateIntermediateAnswer(info: output)) + } + return output + } + + public func parseOutput(_ output: String) -> String { + return output + } +} + diff --git a/Tool/Sources/LangChain/ChatModel/ChatModel.swift b/Tool/Sources/LangChain/ChatModel/ChatModel.swift index 283bfcfc..b58bafc5 100644 --- a/Tool/Sources/LangChain/ChatModel/ChatModel.swift +++ b/Tool/Sources/LangChain/ChatModel/ChatModel.swift @@ -4,7 +4,7 @@ public protocol ChatModel { func generate( prompt: [ChatMessage], stops: [String], - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) async throws -> String } @@ -23,3 +23,9 @@ public struct ChatMessage { self.content = content } } + +public extension CallbackEvents { + struct LLMDidProduceNewToken: CallbackEvent { + public let info: String + } +} diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index c619d093..51bb023d 100644 --- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -2,44 +2,49 @@ import Foundation import OpenAIService public struct OpenAIChat: ChatModel { - public var temperature: Double + public var configuration: ChatGPTConfiguration public var stream: Bool public init( - temperature: Double = 0.7, - stream: Bool = false + configuration: ChatGPTConfiguration, + stream: Bool ) { - self.temperature = temperature + self.configuration = configuration self.stream = stream } public func generate( prompt: [ChatMessage], stops: [String], - callbackManagers: [ChainCallbackManager] + callbackManagers: [CallbackManager] ) 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)) - } + let memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() + ) + let service = ChatGPTService(memory: memory, configuration: configuration) + for message in prompt { + let role: OpenAIService.ChatMessage.Role = { + switch message.role { + case .system: + return .system + case .user: + return .user + case .assistant: + return .assistant + } + }() + await memory.appendMessage(.init(role: role, content: message.content)) } + 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) } + callbackManagers + .forEach { $0.send(CallbackEvents.LLMDidProduceNewToken(info: trunk)) } } return message } else { diff --git a/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift b/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift new file mode 100644 index 00000000..1c9b4627 --- /dev/null +++ b/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift @@ -0,0 +1,26 @@ +import Foundation +import JSONRPC + +public struct Document: Codable { + public typealias Metadata = [String: JSONValue] + public var pageContent: String + public var metadata: Metadata + public init(pageContent: String, metadata: Metadata) { + self.pageContent = pageContent + self.metadata = metadata + } +} + +public protocol DocumentLoader { + func load() async throws -> [Document] +} + +extension DocumentLoader { + func loadAndSplit( + with textSplitter: TextSplitter = RecursiveCharacterTextSplitter() + ) async throws -> [Document] { + let docs = try await load() + return try await textSplitter.splitDocuments(docs) + } +} + diff --git a/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift b/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift new file mode 100644 index 00000000..f506c4ad --- /dev/null +++ b/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift @@ -0,0 +1,44 @@ +import AppKit +import Foundation + +/// Load a text document from local file. +public struct TextLoader: DocumentLoader { + enum MetadataKeys { + static let filename = "filename" + static let `extension` = "extension" + static let contentModificationDate = "contentModificationDate" + } + + let url: URL + let encoding: String.Encoding + let options: [NSAttributedString.DocumentReadingOptionKey: Any] + + public init( + url: URL, + encoding: String.Encoding = .utf8, + options: [NSAttributedString.DocumentReadingOptionKey: Any] = [:] + ) { + self.url = url + self.encoding = encoding + self.options = options + } + + public func load() async throws -> [Document] { + let data = try Data(contentsOf: url) + let attributedString = try NSAttributedString( + data: data, + options: options, + documentAttributes: nil + ) + let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + return [Document(pageContent: attributedString.string, metadata: [ + MetadataKeys.filename: .string(url.lastPathComponent), + MetadataKeys.extension: .string(url.pathExtension), + MetadataKeys.contentModificationDate: .number( + (modificationDate ?? Date()).timeIntervalSince1970 + ), + ])] + } +} + diff --git a/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift new file mode 100644 index 00000000..494f91e2 --- /dev/null +++ b/Tool/Sources/LangChain/DocumentLoader/WebLoader.swift @@ -0,0 +1,239 @@ +import Foundation +import Logger +import SwiftSoup +import WebKit + +/// Load the body of a web page. +public struct WebLoader: DocumentLoader { + enum MetadataKeys { + static let title = "title" + static let url = "url" + static let date = "date" + } + + var downloadHTML: (_ url: URL, _ strategy: LoadWebPageMainContentStrategy) async throws + -> (url: URL, html: String, strategy: LoadWebPageMainContentStrategy) = { url, strategy in + let html = try await WebScrapper(strategy: strategy).fetch(url: url) + return (url, html, strategy) + } + + public var urls: [URL] + + public init(urls: [URL]) { + self.urls = urls + } + + public init(url: URL) { + urls = [url] + } + + public func load() async throws -> [Document] { + try await withThrowingTaskGroup(of: ( + url: URL, + html: String, + strategy: LoadWebPageMainContentStrategy + ).self) { group in + for url in urls { + let strategy: LoadWebPageMainContentStrategy = { + switch url { + case let url + where url.absoluteString.contains("developer.apple.com/documentation"): + return Developer_Apple_Documentation_LoadContentStrategy() + default: + return DefaultLoadContentStrategy() + } + }() + group.addTask { + try await downloadHTML(url, strategy) + } + } + var documents: [Document] = [] + for try await result in group { + do { + let parsed = try SwiftSoup.parse(result.html, result.url.path) + + let title = (try? parsed.title()) ?? "Untitled" + let parsedDocuments = try result.strategy.load( + parsed, + metadata: [ + MetadataKeys.title: .string(title), + MetadataKeys.url: .string(result.url.absoluteString), + MetadataKeys.date: .number(Date().timeIntervalSince1970), + ] + ) + documents.append(contentsOf: parsedDocuments) + } catch let Exception.Error(_, message) { + Logger.langchain.error(message) + } catch { + Logger.langchain.error(error.localizedDescription) + } + } + return documents + } + } +} + +// MARK: - WebScrapper + +@MainActor +public final class WebScrapper: NSObject, WKNavigationDelegate { + public var webView: WKWebView + + let strategy: LoadWebPageMainContentStrategy + let retryLimit: Int + var webViewDidFinishLoading = false + var navigationError: (any Error)? + + enum WebScrapperError: Error { + case retry + } + + init( + retryLimit: Int = 10, + strategy: LoadWebPageMainContentStrategy + ) { + self.retryLimit = retryLimit + self.strategy = strategy + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.preferredContentMode = .desktop + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration + .applicationNameForUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15" + // The web page need the web view to have a size to load correctly. + let webView = WKWebView( + frame: .init(x: 0, y: 0, width: 500, height: 500), + configuration: configuration + ) + self.webView = webView + super.init() + webView.navigationDelegate = self + } + + func fetch(url: URL) async throws -> String { + webViewDidFinishLoading = false + navigationError = nil + var retryCount = 0 + _ = webView.load(.init(url: url)) + while !webViewDidFinishLoading { + try await Task.sleep(nanoseconds: 10_000_000) + } + if let navigationError { throw navigationError } + while retryCount < retryLimit { + if let html = try? await getHTML(), !html.isEmpty, + let document = try? SwiftSoup.parse(html, url.path), + strategy.validate(document) + { + return html + } + retryCount += 1 + try await Task.sleep(nanoseconds: 100_000_000) + } + + throw CancellationError() + } + + public nonisolated func webView(_: WKWebView, didFinish _: WKNavigation!) { + Task { @MainActor in + self.webViewDidFinishLoading = true + } + } + + public nonisolated func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError error: Error + ) { + Task { @MainActor in + self.navigationError = error + self.webViewDidFinishLoading = true + } + } + + func getHTML() async throws -> String { + do { + let isReady = try await webView.evaluateJavaScript(checkIfReady) as? Bool ?? false + if !isReady { throw WebScrapperError.retry } + return try await webView.evaluateJavaScript(getHTMLText) as? String ?? "" + } catch { + throw WebScrapperError.retry + } + } +} + +private let getHTMLText = """ +document.documentElement.outerHTML; +""" + +private let checkIfReady = """ +document.readyState === "ready" || document.readyState === "complete"; +""" + +// MARK: - LoadWebPageMainContentStrategy + +protocol LoadWebPageMainContentStrategy { + /// Load the web content into several documents. + func load(_ document: SwiftSoup.Document, metadata: Document.Metadata) throws -> [Document] + /// Validate if the web page is fully loaded. + func validate(_ document: SwiftSoup.Document) -> Bool +} + +extension LoadWebPageMainContentStrategy { + func text(inFirstTag tagName: String, from document: SwiftSoup.Document) -> String? { + if let tag = try? document.getElementsByTag(tagName).first(), + let text = try? tag.text() + { + return text + } + return nil + } +} + +extension WebLoader { + struct DefaultLoadContentStrategy: LoadWebPageMainContentStrategy { + func load( + _ document: SwiftSoup.Document, + metadata: Document.Metadata + ) throws -> [Document] { + if let mainContent = try? { + if let article = text(inFirstTag: "article", from: document) { return article } + if let main = text(inFirstTag: "main", from: document) { return main } + let body = try document.body()?.text() + return body + }() { + return [.init(pageContent: mainContent, metadata: metadata)] + } + return [] + } + + func validate(_: SwiftSoup.Document) -> Bool { + return true + } + } + + /// https://developer.apple.com/documentation + struct Developer_Apple_Documentation_LoadContentStrategy: LoadWebPageMainContentStrategy { + func load( + _ document: SwiftSoup.Document, + metadata: Document.Metadata + ) throws -> [Document] { + if let mainContent = try? { + if let main = text(inFirstTag: "main", from: document) { return main } + let body = try document.body()?.text() + return body + }() { + return [.init(pageContent: mainContent, metadata: metadata)] + } + return [] + } + + func validate(_ document: SwiftSoup.Document) -> Bool { + do { + return !(try document.getElementsByTag("main").isEmpty()) + } catch { + return false + } + } + } +} + diff --git a/Tool/Sources/LangChain/DocumentTransformer/DocumentTransformer.swift b/Tool/Sources/LangChain/DocumentTransformer/DocumentTransformer.swift new file mode 100644 index 00000000..ac81334d --- /dev/null +++ b/Tool/Sources/LangChain/DocumentTransformer/DocumentTransformer.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol DocumentTransformer { + func transformDocuments(_ documents: [Document]) async throws -> [Document] +} diff --git a/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift new file mode 100644 index 00000000..da19b80e --- /dev/null +++ b/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Implementation of splitting text that looks at characters. +/// Recursively tries to split by different characters to find one that works. +public class RecursiveCharacterTextSplitter: TextSplitter { + public var chunkSize: Int + public var chunkOverlap: Int + public var lengthFunction: (String) -> Int + + /// A list of separators to try. They will be used in order. Supports regular expressions. + public var separators: [String] + + /// Create a new splitter + /// - Parameters: + /// - separators: A list of separators to try. They will be used in order. Supports regular + /// expressions. + /// - chunkSize: The maximum size of chunks. Don't use chunk size larger than 8191, because + /// length safe embedding is not implemented. + /// - chunkOverlap: The maximum overlap between chunks. + /// - lengthFunction: A function to compute the length of text. + public init( + separators: [String] = ["\n\n", "\n", " ", ""], + chunkSize: Int = 4000, + chunkOverlap: Int = 200, + lengthFunction: @escaping (String) -> Int = { $0.count } + ) { + assert(chunkOverlap <= chunkSize) + self.chunkSize = chunkSize + self.chunkOverlap = chunkOverlap + self.lengthFunction = lengthFunction + self.separators = separators + } + + // Create a new splitter + /// - Parameters: + /// - separatorSet: A set of separators to try. + /// - chunkSize: The maximum size of chunks. Don't use chunk size larger than 8191, because + /// length safe embedding is not implemented. + /// - chunkOverlap: The maximum overlap between chunks. + /// - lengthFunction: A function to compute the length of text. + public init( + separatorSet: TextSplitterSeparatorSet, + chunkSize: Int = 4000, + chunkOverlap: Int = 200, + lengthFunction: @escaping (String) -> Int = { $0.count } + ) { + assert(chunkOverlap <= chunkSize) + self.chunkSize = chunkSize + self.chunkOverlap = chunkOverlap + self.lengthFunction = lengthFunction + separators = separatorSet.separators + } + + public func split(text: String) async throws -> [String] { + return split(text: text, separators: separators) + } + + private func split(text: String, separators: [String]) -> [String] { + var finalChunks = [String]() + + // Get appropriate separator to use + let firstSeparatorIndex = separators.firstIndex { + let pattern = "(\($0))" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } + return regex.firstMatch( + in: text, + options: [], + range: NSRange(text.startIndex..., in: text) + ) != nil + } + var separator: String + var nextSeparators: [String] + + if let index = firstSeparatorIndex { + separator = separators[index] + if index < separators.endIndex - 1 { + nextSeparators = Array(separators[(index + 1)...]) + } else { + nextSeparators = [] + } + } else { + separator = "" + nextSeparators = [] + } + + let splits = split(text: text, separator: separator) + + // Now go merging things, recursively splitting longer texts. + var goodSplits = [String]() + for s in splits { + if lengthFunction(s) < chunkSize { + goodSplits.append(s) + } else { + if !goodSplits.isEmpty { + let mergedText = mergeSplits(goodSplits) + finalChunks.append(contentsOf: mergedText) + goodSplits.removeAll() + } + if nextSeparators.isEmpty { + finalChunks.append(s) + } else { + let other_info = split(text: s, separators: nextSeparators) + finalChunks.append(contentsOf: other_info) + } + } + } + if !goodSplits.isEmpty { + let merged_text = mergeSplits(goodSplits) + finalChunks.append(contentsOf: merged_text) + } + return finalChunks + } +} + diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift new file mode 100644 index 00000000..e72a3ecc --- /dev/null +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -0,0 +1,136 @@ +import Foundation +import JSONRPC + +/// Split text into multiple components. +public protocol TextSplitter: DocumentTransformer { + /// The maximum size of chunks. + var chunkSize: Int { get } + /// The maximum overlap between chunks. + var chunkOverlap: Int { get } + /// A function to compute the length of text. + var lengthFunction: (String) -> Int { get } + + /// Split text into multiple components. + func split(text: String) async throws -> [String] +} + +public extension TextSplitter { + /// Create documents from a list of texts. + func createDocuments( + texts: [String], + metadata: [Document.Metadata] = [] + ) async throws -> [Document] { + var documents = [Document]() + let paddingLength = texts.count - metadata.count + let metadata = metadata + .init(repeating: [:], count: paddingLength) + for (text, metadata) in zip(texts, metadata) { + let trunks = try await split(text: text) + for trunk in trunks { + let document = Document(pageContent: trunk, metadata: metadata) + documents.append(document) + } + } + return documents + } + + /// Split documents. + func splitDocuments(_ documents: [Document]) async throws -> [Document] { + var texts = [String]() + var metadata = [Document.Metadata]() + for document in documents { + texts.append(document.pageContent) + metadata.append(document.metadata) + } + return try await createDocuments(texts: texts, metadata: metadata) + } + + /// Transform sequence of documents by splitting them. + func transformDocuments(_ documents: [Document]) async throws -> [Document] { + return try await splitDocuments(documents) + } +} + +public extension TextSplitter { + /// Merge small splits to just fit in the chunk size. + func mergeSplits(_ splits: [String]) -> [String] { + let chunkOverlap = chunkOverlap < chunkSize ? chunkOverlap : 0 + + var chunks = [String]() + var currentChunk = [String]() + var overlappingChunks = [String]() + var currentChunkSize = 0 + + func join(_ a: [String], _ b: [String]) -> String { + return (a + b).joined().trimmingCharacters(in: .whitespaces) + } + + for text in splits { + let textLength = lengthFunction(text) + if currentChunkSize + textLength > chunkSize { + let currentChunkText = join(overlappingChunks, currentChunk) + chunks.append(currentChunkText) + + overlappingChunks = [] + var overlappingSize = 0 + // use small chunks as overlap if possible + for chunk in currentChunk.reversed() { + let length = lengthFunction(chunk) + if overlappingSize + length > chunkOverlap { break } + if overlappingSize + length + textLength > chunkSize { break } + overlappingSize += length + overlappingChunks.insert(chunk, at: 0) + } +// // fallback to use suffix if no small chunk found +// if overlappingChunks.isEmpty { +// let suffix = String( +// currentChunkText.suffix(min(chunkOverlap, chunkSize - textLength)) +// ) +// overlappingChunks.append(suffix) +// overlappingSize = lengthFunction(suffix) +// } + + currentChunkSize = overlappingSize + textLength + currentChunk = [text] + } else { + currentChunkSize += textLength + currentChunk.append(text) + } + } + + if !currentChunk.isEmpty { + chunks.append(join(overlappingChunks, currentChunk)) + } + + return chunks + } + + /// Split the text by separator. + func split(text: String, separator: String) -> [String] { + guard !separator.isEmpty else { + return [text] + } + + let pattern = "(\(separator))" + if let regex = try? NSRegularExpression(pattern: pattern) { + let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) + var all = [String]() + var start = text.startIndex + for match in matches { + guard let range = Range(match.range, in: text) else { break } + guard range.lowerBound > start else { break } + let result = text[start..", + "
", + "

", + "
", + "

  • ", + "

    ", + "

    ", + "

    ", + "

    ", + "

    ", + "
    ", + "", + "", + "", + "
    ", + "", + "
      ", + "
        ", + "
        ", + "