diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 50fd7406..759f2944 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* RealtimeSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */; }; C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; + C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */; }; C81291D72994FE6900196E12 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C81291D52994FE6900196E12 /* Main.storyboard */; }; C814588F2939EFDC00135263 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C814588E2939EFDC00135263 /* Cocoa.framework */; }; C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458932939EFDC00135263 /* SourceEditorExtension.swift */; }; @@ -139,6 +140,7 @@ C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeSuggestionCommand.swift; sourceTree = ""; }; C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; }; + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptPromptToCodeCommand.swift; sourceTree = ""; }; C81291D52994FE6900196E12 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; C81291D92994FE7900196E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C814588C2939EFDC00135263 /* Copilot.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Copilot.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -241,6 +243,7 @@ C8520300293C4D9000460097 /* Helpers.swift */, C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */, C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */, + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */, C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */, C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */, C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */, @@ -557,6 +560,7 @@ C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */, C8520301293C4D9000460097 /* Helpers.swift in Sources */, C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */, + C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */, C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */, C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */, C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9d688e1b..99ca18ea 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "1.2.0" } }, + { + "identity" : "codablewrappers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GottaGetSwifty/CodableWrappers", + "state" : { + "revision" : "4eb46a4c656333e8514db8aad204445741de7d40", + "version" : "2.0.7" + } + }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", diff --git a/Core/Package.swift b/Core/Package.swift index a87d2b6c..e9620e51 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -50,7 +50,6 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), - .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), ].pro, targets: [ // MARK: - Main @@ -158,9 +157,12 @@ let package = Package( .target( name: "PromptToCodeService", dependencies: [ + .product(name: "FocusedCodeFinder", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget(name: "PromptToCodeServiceTests", dependencies: ["PromptToCodeService"]), @@ -227,6 +229,7 @@ let package = Package( .target( name: "SuggestionWidget", dependencies: [ + "PromptToCodeService", "ChatGPTChatTab", .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), @@ -258,6 +261,13 @@ let package = Package( dependencies: [ "GitHubCopilotService", .product(name: "Preferences", package: "Tool"), + .product(name: "Keychain", package: "Tool"), + ] + ), + .testTarget( + name: "ServiceUpdateMigrationTests", + dependencies: [ + "ServiceUpdateMigration", ] ), .target( @@ -361,12 +371,9 @@ let package = Package( name: "ActiveDocumentChatContextCollector", dependencies: [ "ChatContextCollector", - .product(name: "LangChain", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), - .product(name: "ASTParser", package: "Tool"), - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "FocusedCodeFinder", package: "Tool"), ], path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" ), diff --git a/Core/Sources/ChatContextCollector/ChatContextCollector.swift b/Core/Sources/ChatContextCollector/ChatContextCollector.swift index dd4ad75a..e890a882 100644 --- a/Core/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Core/Sources/ChatContextCollector/ChatContextCollector.swift @@ -14,7 +14,8 @@ public protocol ChatContextCollector { func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 5e29ed6a..a3a32ff4 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -1,5 +1,6 @@ import ASTParser import ChatContextCollector +import FocusedCodeFinder import Foundation import OpenAIService import Preferences @@ -14,7 +15,8 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { guard let info = getEditorInformation() else { return nil } let context = getActiveDocumentContext(info) @@ -188,107 +190,3 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { } } -struct ActiveDocumentContext { - var filePath: String - var relativePath: String - var language: CodeLanguage - var fileContent: String - var lines: [String] - var selectedCode: String - var selectionRange: CursorRange - var lineAnnotations: [EditorInformation.LineAnnotation] - var imports: [String] - - struct FocusedContext { - var context: [String] - var contextRange: CursorRange - var codeRange: CursorRange - var code: String - var lineAnnotations: [EditorInformation.LineAnnotation] - var otherLineAnnotations: [EditorInformation.LineAnnotation] - } - - var focusedContext: FocusedContext? - - mutating func moveToFocusedCode() { - moveToCodeContainingRange(selectionRange) - } - - mutating func moveToCodeAroundLine(_ line: Int) { - moveToCodeContainingRange(.init( - start: .init(line: line, character: 0), - end: .init(line: line, character: 0) - )) - } - - mutating func expandFocusedRangeToContextRange() { - guard let focusedContext else { return } - moveToCodeContainingRange(focusedContext.contextRange) - } - - mutating func moveToCodeContainingRange(_ range: CursorRange) { - let finder: FocusedCodeFinder = { - switch language { - case .builtIn(.swift): - return SwiftFocusedCodeFinder() - default: - return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) - } - }() - - let codeContext = finder.findFocusedCode( - containingRange: range, - activeDocumentContext: self - ) - - imports = codeContext.imports - - let startLine = codeContext.focusedRange.start.line - let endLine = codeContext.focusedRange.end.line - var matchedAnnotations = [EditorInformation.LineAnnotation]() - var otherAnnotations = [EditorInformation.LineAnnotation]() - for annotation in lineAnnotations { - if annotation.line >= startLine, annotation.line <= endLine { - matchedAnnotations.append(annotation) - } else { - otherAnnotations.append(annotation) - } - } - - focusedContext = .init( - context: codeContext.scopeSignatures, - contextRange: codeContext.contextRange, - codeRange: codeContext.focusedRange, - code: codeContext.focusedCode, - lineAnnotations: matchedAnnotations, - otherLineAnnotations: otherAnnotations - ) - } - - mutating func update(_ info: EditorInformation) { - /// Whenever the file content, relative path, or selection range changes, - /// we should reset the context. - let changed: Bool = { - if info.relativePath != relativePath { return true } - if info.editorContent?.content != fileContent { return true } - if let range = info.editorContent?.selections.first, - range != selectionRange { return true } - return false - }() - - filePath = info.documentURL.path - relativePath = info.relativePath - language = info.language - fileContent = info.editorContent?.content ?? "" - lines = info.editorContent?.lines ?? [] - selectedCode = info.selectedContent - selectionRange = info.editorContent?.selections.first ?? .zero - lineAnnotations = info.editorContent?.lineAnnotations ?? [] - imports = [] - - if changed { - moveToFocusedCode() - } - } -} - diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 7c88aa27..3907568a 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -3,39 +3,6 @@ import SuggestionModel import XcodeInspector func getEditorInformation() -> EditorInformation? { - guard !XcodeInspector.shared.xcodes.isEmpty else { return nil } - - let editorContent = XcodeInspector.shared.focusedEditor?.content - let documentURL = XcodeInspector.shared.activeDocumentURL - let projectURL = XcodeInspector.shared.activeProjectURL - let language = languageIdentifierFromFileURL(documentURL) - let relativePath = documentURL.path - .replacingOccurrences(of: projectURL.path, with: "") - - if let editorContent, let range = editorContent.selections.first { - let (selectedContent, selectedLines) = EditorInformation.code( - in: editorContent.lines, - inside: range - ) - return .init( - editorContent: editorContent, - selectedContent: selectedContent, - selectedLines: selectedLines, - documentURL: documentURL, - projectURL: projectURL, - relativePath: relativePath, - language: language - ) - } - - return .init( - editorContent: editorContent, - selectedContent: "", - selectedLines: [], - documentURL: documentURL, - projectURL: projectURL, - relativePath: relativePath, - language: language - ) + return XcodeInspector.shared.focusedEditorContent } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index 43460b22..3e3af26b 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -11,7 +11,8 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { guard let content = getEditorInformation() else { return nil } let relativePath = content.relativePath diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift index fd137a24..19aab821 100644 --- a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift @@ -14,7 +14,8 @@ public final class SystemInfoChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { return .init( systemPrompt: """ diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 8bb360dd..348753b3 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -28,6 +28,8 @@ struct SearchFunction: ChatGPTFunction { }.joined(separator: "\n") } } + + let maxTokens: Int var reportProgress: (String) async -> Void = { _ in } @@ -72,9 +74,10 @@ struct SearchFunction: ChatGPTFunction { 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, + numberOfResult: maxTokens > 5000 ? 5 : 3, freshness: arguments.freshness ) diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift index 4035d44b..bf72667f 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -10,12 +10,13 @@ public final class WebChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { guard scopes.contains("web") || scopes.contains("w") else { return nil } let links = Self.detectLinks(from: history) + Self.detectLinks(from: content) let functions: [(any ChatGPTFunction)?] = [ - SearchFunction(), + SearchFunction(maxTokens: configuration.maxTokens), // allow this function only when there is a link in the memory. links.isEmpty ? nil : QueryWebsiteFunction(), ] diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index 238f1b01..6ead6a06 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -2,20 +2,26 @@ import AppKit import SharedUIComponents import SwiftUI +struct ChatTabItemView: View { + @ObservedObject var chat: ChatProvider + + var body: some View { + Text(chat.title) + } +} + struct ChatContextMenu: View { @ObservedObject var chat: ChatProvider @AppStorage(\.customCommands) var customCommands var body: some View { - Group { - currentSystemPrompt - currentExtraSystemPrompt - resetPrompt + currentSystemPrompt + currentExtraSystemPrompt + resetPrompt - Divider() + Divider() - customCommandMenu - } + customCommandMenu } @ViewBuilder diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 85413186..8c72e72b 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -41,6 +41,10 @@ public class ChatGPTChatTab: ChatTab { ChatPanel(chat: provider) } + public func buildTabItem() -> any View { + ChatTabItemView(chat: provider) + } + public func buildMenu() -> any View { ChatContextMenu(chat: provider) } diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index 761ea855..147c2446 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -398,6 +398,13 @@ struct ChatPanelInputArea: View { EmptyView() } .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + + Button(action: { + isInputAreaFocused = true + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) } } diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift index a5a39040..0a22051c 100644 --- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift @@ -35,6 +35,7 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { contextController = DynamicContextController( memory: memory, functionProvider: functionProvider, + configuration: configuration, contextCollectors: allContextCollectors ) self.functionProvider = functionProvider diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index cc53bd88..5c1cc81a 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -9,16 +9,19 @@ final class DynamicContextController { let contextCollectors: [ChatContextCollector] let memory: AutoManagedChatGPTMemory let functionProvider: ChatFunctionProvider + let configuration: ChatGPTConfiguration var defaultScopes = [] as Set convenience init( memory: AutoManagedChatGPTMemory, functionProvider: ChatFunctionProvider, + configuration: ChatGPTConfiguration, contextCollectors: ChatContextCollector... ) { self.init( memory: memory, functionProvider: functionProvider, + configuration: configuration, contextCollectors: contextCollectors ) } @@ -26,10 +29,12 @@ final class DynamicContextController { init( memory: AutoManagedChatGPTMemory, functionProvider: ChatFunctionProvider, + configuration: ChatGPTConfiguration, contextCollectors: [ChatContextCollector] ) { self.memory = memory self.functionProvider = functionProvider + self.configuration = configuration self.contextCollectors = contextCollectors } @@ -37,12 +42,17 @@ final class DynamicContextController { var content = content var scopes = Self.parseScopes(&content) scopes.formUnion(defaultScopes) - + functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) let oldMessages = await memory.history let contexts = contextCollectors.compactMap { - $0.generateContext(history: oldMessages, scopes: scopes, content: content) + $0.generateContext( + history: oldMessages, + scopes: scopes, + content: content, + configuration: configuration + ) } let contextualSystemPrompt = """ \(language.isEmpty ? "" : "You must always reply in \(language)") diff --git a/Core/Sources/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift index 8398e464..bd366d0f 100644 --- a/Core/Sources/Client/AsyncXPCService.swift +++ b/Core/Sources/Client/AsyncXPCService.swift @@ -85,6 +85,16 @@ public struct AsyncXPCService { { $0.getRealtimeSuggestedCode } ) } + + public func getPromptToCodeAcceptedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + connection, + editorContent, + { $0.getPromptToCodeAcceptedCode } + ) + } public func toggleRealtimeSuggestion() async throws { try await withXPCServiceConnected(connection: connection) { diff --git a/Core/Sources/CodeiumService/CodeiumAuthService.swift b/Core/Sources/CodeiumService/CodeiumAuthService.swift index 0d2f3765..9141a572 100644 --- a/Core/Sources/CodeiumService/CodeiumAuthService.swift +++ b/Core/Sources/CodeiumService/CodeiumAuthService.swift @@ -34,7 +34,14 @@ public final class CodeiumAuthService { } func generate(token: String) async throws -> String { - var request = URLRequest(url: URL(string: "https://api.codeium.com/register_user/")!) + var registerUserUrl = URL(string: "https://api.codeium.com/register_user/") + let apiUrl = UserDefaults.shared.value(for: \.codeiumApiUrl) + if UserDefaults.shared.value(for: \.codeiumEnterpriseMode), apiUrl != "" { + registerUserUrl = + URL(string: apiUrl + "/exa.api_server_pb.ApiServerService/RegisterUser") + } + + var request = URLRequest(url: registerUserUrl!) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") let requestBody = GenerateKeyRequestBody(firebase_id_token: token) diff --git a/Core/Sources/CodeiumService/CodeiumLanguageServer.swift b/Core/Sources/CodeiumService/CodeiumLanguageServer.swift index cd099943..ad0916e8 100644 --- a/Core/Sources/CodeiumService/CodeiumLanguageServer.swift +++ b/Core/Sources/CodeiumService/CodeiumLanguageServer.swift @@ -42,13 +42,23 @@ final class CodeiumLanguageServer { process.executableURL = languageServerExecutableURL + let isEnterpriseMode = UserDefaults.shared.value(for: \.codeiumEnterpriseMode) + var apiServerUrl = "https://server.codeium.com" + if isEnterpriseMode, UserDefaults.shared.value(for: \.codeiumApiUrl) != "" { + apiServerUrl = UserDefaults.shared.value(for: \.codeiumApiUrl) + } + process.arguments = [ "--api_server_url", - "https://server.codeium.com", + apiServerUrl, "--manager_dir", managerDirectoryURL.path, ] + if isEnterpriseMode { + process.arguments?.append("--enterprise_mode") + } + process.currentDirectoryURL = supportURL process.terminationHandler = { [weak self] task in @@ -120,7 +130,7 @@ final class CodeiumLanguageServer { self.port = port launchHandler?() } - + func terminate() { process.terminationHandler = nil if process.isRunning { diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift new file mode 100644 index 00000000..101c4e49 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -0,0 +1,139 @@ +import ComposableArchitecture +import SwiftUI + +struct APIKeyManagementView: View { + let store: StoreOf + + var body: some View { + VStack(spacing: 0) { + HStack { + Button(action: { + store.send(.closeButtonClicked) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("API Keys") + Spacer() + Button(action: { + store.send(.addButtonClicked) + }) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + } + .background(Color(nsColor: .separatorColor)) + + List { + WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in + ForEach(viewStore.state, id: \.self) { name in + HStack { + Text(name) + .contextMenu { + Button("Remove") { + viewStore.send(.deleteButtonClicked(name: name)) + } + } + Spacer() + + Button(action: { + viewStore.send(.deleteButtonClicked(name: name)) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + } + .removeBackground() + .overlay { + WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in + if viewStore.state.isEmpty { + Text(""" + Empty + Add a new key by clicking the add button + """) + .multilineTextAlignment(.center) + .padding() + } + } + } + } + .focusable(false) + .frame(width: 300, height: 400) + .background(.thickMaterial) + .onAppear { + store.send(.appear) + } + .sheet(store: store.scope( + state: \.$apiKeySubmission, + action: APIKeyManagement.Action.apiKeySubmission + )) { store in + APIKeySubmissionView(store: store) + .frame(minWidth: 400) + } + } +} + +struct APIKeySubmissionView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Form { + WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in + TextField("Name", text: viewStore.$name) + } + WithViewStore(store, removeDuplicates: { $0.key == $1.key }) { viewStore in + SecureField("Key", text: viewStore.$key) + } + }.padding() + + Divider() + + HStack { + Spacer() + + Button("Cancel") { store.send(.cancelButtonClicked) } + .keyboardShortcut(.cancelAction) + + Button("Save", action: { store.send(.saveButtonClicked) }) + .keyboardShortcut(.defaultAction) + }.padding() + } + } + .textFieldStyle(.roundedBorder) + } +} + +class APIKeyManagementView_Preview: PreviewProvider { + static var previews: some View { + APIKeyManagementView( + store: .init( + initialState: .init( + availableAPIKeyNames: ["test1", "test2"] + ), + reducer: APIKeyManagement() + ) + ) + } +} + +class APIKeySubmissionView_Preview: PreviewProvider { + static var previews: some View { + APIKeySubmissionView( + store: .init( + initialState: .init(), + reducer: APIKeySubmission() + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift new file mode 100644 index 00000000..3ff3188e --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import Foundation + +struct APIKeyManagement: ReducerProtocol { + struct State: Equatable { + var availableAPIKeyNames: [String] = [] + @PresentationState var apiKeySubmission: APIKeySubmission.State? + } + + enum Action: Equatable { + case appear + case closeButtonClicked + case addButtonClicked + case deleteButtonClicked(name: String) + case refreshAvailableAPIKeyNames + + case apiKeySubmission(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + case .closeButtonClicked: + return .none + + case .addButtonClicked: + state.apiKeySubmission = .init() + + return .none + + case let .deleteButtonClicked(name): + do { + try keychain.remove(name) + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + } catch { + toast(error.localizedDescription, .error) + return .none + } + + case .refreshAvailableAPIKeyNames: + do { + let pairs = try keychain.getAll() + state.availableAPIKeyNames = Array(pairs.keys).sorted() + } catch { + toast(error.localizedDescription, .error) + } + + return .none + + case .apiKeySubmission(.presented(.saveFinished)): + state.apiKeySubmission = nil + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + + case .apiKeySubmission(.presented(.cancelButtonClicked)): + state.apiKeySubmission = nil + return .none + + case .apiKeySubmission: + return .none + } + } + .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) { + APIKeySubmission() + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift new file mode 100644 index 00000000..a18e0a4c --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -0,0 +1,47 @@ +import ComposableArchitecture +import SwiftUI + +struct APIKeyPicker: View { + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + HStack { + Picker( + selection: viewStore.$apiKeyName, + content: { + Text("No API Key").tag("") + if viewStore.state.availableAPIKeyNames.isEmpty { + Text("No API key found, please add a new one →") + } + + if !viewStore.state.availableAPIKeyNames.contains(viewStore.state.apiKeyName), + !viewStore.state.apiKeyName.isEmpty { + Text("Key not found: \(viewStore.state.apiKeyName)") + .tag(viewStore.state.apiKeyName) + } + + ForEach(viewStore.state.availableAPIKeyNames, id: \.self) { name in + Text(name).tag(name) + } + + }, + label: { Text("API Key") } + ) + + Button(action: { store.send(.manageAPIKeysButtonClicked) }) { + Text(Image(systemName: "key")) + } + }.sheet(isPresented: viewStore.$isAPIKeyManagementPresented) { + APIKeyManagementView(store: store.scope( + state: \.apiKeyManagement, + action: APIKeySelection.Action.apiKeyManagement + )) + } + } + .onAppear { + store.send(.appear) + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift new file mode 100644 index 00000000..75e2d77c --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift @@ -0,0 +1,56 @@ +import Foundation +import SwiftUI +import ComposableArchitecture + +struct APIKeySelection: ReducerProtocol { + struct State: Equatable { + @BindingState var apiKeyName: String = "" + var availableAPIKeyNames: [String] { + apiKeyManagement.availableAPIKeyNames + } + var apiKeyManagement: APIKeyManagement.State = .init() + @BindingState var isAPIKeyManagementPresented: Bool = false + } + + enum Action: Equatable, BindableAction { + case appear + case manageAPIKeysButtonClicked + + case binding(BindingAction) + case apiKeyManagement(APIKeyManagement.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + BindingReducer() + + Scope(state: \.apiKeyManagement, action: /Action.apiKeyManagement) { + APIKeyManagement() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.apiKeyManagement(.refreshAvailableAPIKeyNames)) + } + + case .manageAPIKeysButtonClicked: + state.isAPIKeyManagementPresented = true + return .none + + case .binding: + return .none + + case .apiKeyManagement(.closeButtonClicked): + state.isAPIKeyManagementPresented = false + return .none + + case .apiKeyManagement: + return .none + } + } + } +} diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift new file mode 100644 index 00000000..64f16b7d --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift @@ -0,0 +1,59 @@ +import ComposableArchitecture +import Foundation + +struct APIKeySubmission: ReducerProtocol { + struct State: Equatable { + @BindingState var name: String = "" + @BindingState var key: String = "" + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case saveButtonClicked + case cancelButtonClicked + case saveFinished + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + enum E: Error, LocalizedError { + case nameIsEmpty + case keyIsEmpty + } + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .saveButtonClicked: + do { + guard !state.name.isEmpty else { throw E.nameIsEmpty } + guard !state.key.isEmpty else { throw E.keyIsEmpty } + + try keychain.update( + state.key, + key: state.name.trimmingCharacters(in: .whitespacesAndNewlines) + ) + return .run { send in + await send(.saveFinished) + } + } catch { + toast(error.localizedDescription, .error) + return .none + } + + case .cancelButtonClicked: + return .none + + case .saveFinished: + return .none + + case .binding: + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/AzureView.swift b/Core/Sources/HostApp/AccountSettings/AzureView.swift deleted file mode 100644 index cfe34903..00000000 --- a/Core/Sources/HostApp/AccountSettings/AzureView.swift +++ /dev/null @@ -1,84 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionModel -import SwiftUI - -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() {} -} - -struct AzureView: View { - @Environment(\.toast) var toast - @State var isTesting = false - @StateObject var settings = AzureViewSettings() - - var body: some View { - Form { - SecureField(text: $settings.azureOpenAIAPIKey, prompt: Text("")) { - Text("OpenAI Service API Key") - } - .textFieldStyle(.roundedBorder) - - TextField( - text: $settings.azureOpenAIBaseURL, - prompt: Text("https://XXXXXX.openai.azure.com") - ) { - Text("OpenAI Service Base URL") - }.textFieldStyle(.roundedBorder) - - HStack { - TextField( - text: $settings.azureChatGPTDeployment, - prompt: Text("") - ) { - Text("Chat Model Deployment Name") - }.textFieldStyle(.roundedBorder) - - Button("Test") { - Task { @MainActor in - isTesting = true - defer { isTesting = false } - do { - let reply = - try await ChatGPTService( - configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(featureProvider: .azureOpenAI)) - ) - .sendAndWait(content: "Hello", summary: nil) - toast("ChatGPT replied: \(reply ?? "N/A")", .info) - } catch { - toast(error.localizedDescription, .error) - } - } - } - .disabled(isTesting) - } - - HStack { - TextField( - text: $settings.azureEmbeddingDeployment, - prompt: Text("") - ) { - Text("Embedding Model Deployment Name") - }.textFieldStyle(.roundedBorder) - } - } - } -} - -struct AzureView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - AzureView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift new file mode 100644 index 00000000..d43793c0 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -0,0 +1,184 @@ +import AIModel +import ComposableArchitecture +import Dependencies +import Keychain +import OpenAIService +import Preferences +import SwiftUI + +struct ChatModelEdit: ReducerProtocol { + struct State: Equatable, Identifiable { + var id: String + @BindingState var name: String + @BindingState var format: ChatModel.Format + @BindingState var maxTokens: Int = 4000 + @BindingState var supportsFunctionCalling: Bool = true + @BindingState var modelName: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } + var availableModelNames: [String] = [] + var availableAPIKeys: [String] = [] + var isTesting = false + var suggestedMaxTokens: Int? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case cancelButtonClicked + case refreshAvailableModelNames + case testButtonClicked + case testSucceeded(String) + case testFailed(String) + case checkSuggestedMaxTokens + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + BindingReducer() + + Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .saveButtonClicked: + return .none + + case .cancelButtonClicked: + return .none + + case .testButtonClicked: + guard !state.isTesting else { return .none } + state.isTesting = true + let model = ChatModel( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL, + maxTokens: state.maxTokens, + supportsFunctionCalling: state.supportsFunctionCalling, + modelName: state.modelName + ) + ) + return .run { send in + do { + let reply = + try await ChatGPTService( + configuration: UserPreferenceChatGPTConfiguration() + .overriding { + $0.model = model + } + ).sendAndWait(content: "Hello") + await send(.testSucceeded(reply ?? "No Message")) + } catch { + await send(.testFailed(error.localizedDescription)) + } + } + + case let .testSucceeded(message): + state.isTesting = false + toast(message, .info) + return .none + + case let .testFailed(message): + state.isTesting = false + toast(message, .error) + return .none + + case .refreshAvailableModelNames: + if state.format == .openAI { + state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue) + } + + return .none + + case .checkSuggestedMaxTokens: + guard state.format == .openAI, + let knownModel = ChatGPTModel(rawValue: state.modelName) + else { + state.suggestedMaxTokens = nil + return .none + } + state.suggestedMaxTokens = knownModel.maxToken + return .none + + case .apiKeySelection: + return .none + + case .baseURLSelection: + return .none + + case .binding(\.$format): + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .binding(\.$modelName): + return .run { send in + await send(.checkSuggestedMaxTokens) + } + + case .binding: + return .none + } + } + } +} + +extension ChatModelEdit.State { + init(model: ChatModel) { + self.init( + id: model.id, + name: model.name, + format: model.format, + maxTokens: model.info.maxTokens, + supportsFunctionCalling: model.info.supportsFunctionCalling, + modelName: model.info.modelName, + apiKeySelection: .init( + apiKeyName: model.info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: model.info.baseURL) + ) + } +} + +extension ChatModel { + init(state: ChatModelEdit.State) { + self.init( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + maxTokens: state.maxTokens, + supportsFunctionCalling: state.supportsFunctionCalling, + modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift new file mode 100644 index 00000000..2427b442 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -0,0 +1,276 @@ +import AIModel +import ComposableArchitecture +import Preferences +import SwiftUI + +@MainActor +struct ChatModelEditView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Form { + nameTextField + formatPicker + + WithViewStore(store, observe: { $0.format }) { viewStore in + switch viewStore.state { + case .openAI: + openAI + case .azureOpenAI: + azureOpenAI + case .openAICompatible: + openAICompatible + } + } + } + .padding() + + Divider() + + HStack { + WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack(spacing: 8) { + Button("Test") { + store.send(.testButtonClicked) + } + .disabled(viewStore.state) + + if viewStore.state { + ProgressView() + .controlSize(.small) + } + } + } + + Spacer() + + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) + + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + } + + var nameTextField: some View { + WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in + TextField("Name", text: viewStore.$name) + } + } + + var formatPicker: some View { + WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in + Picker( + selection: viewStore.$format, + content: { + ForEach( + ChatModel.Format.allCases, + id: \.rawValue + ) { format in + switch format { + case .openAI: + Text("OpenAI").tag(format) + case .azureOpenAI: + Text("Azure OpenAI").tag(format) + case .openAICompatible: + Text("OpenAI Compatible").tag(format) + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + } + + func baseURLTextField(prompt: Text?) -> some View { + BaseURLPicker( + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: ChatModelEdit.Action.baseURLSelection + ) + ) + } + + var supportsFunctionCallingToggle: some View { + WithViewStore( + store, + removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling } + ) { viewStore in + Toggle( + "Supports Function Calling", + isOn: viewStore.$supportsFunctionCalling + ) + } + } + + struct MaxTokensTextField: Equatable { + @BindingViewState var maxTokens: Int + var suggestedMaxTokens: Int? + } + + var maxTokensTextField: some View { + WithViewStore( + store, + observe: { + MaxTokensTextField( + maxTokens: $0.$maxTokens, + suggestedMaxTokens: $0.suggestedMaxTokens + ) + } + ) { viewStore in + HStack { + let textFieldBinding = Binding( + get: { String(viewStore.state.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + viewStore.$maxTokens.wrappedValue = selectionMaxToken + } else { + viewStore.$maxTokens.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Max Tokens (Including Reply)") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: viewStore.$maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = viewStore.state.suggestedMaxTokens else { + return .primary + } + if viewStore.state.maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = viewStore.state.suggestedMaxTokens { + Text("Max: \(max)") + } + } + } + } + + struct APIKeyState: Equatable { + @BindingViewState var apiKeyName: String + var availableAPIKeys: [String] + } + + @ViewBuilder + var apiKeyNamePicker: some View { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: ChatModelEdit.Action.apiKeySelection + )) + } + + @ViewBuilder + var openAI: some View { + baseURLTextField(prompt: Text("https://api.openai.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$modelName, + content: { + if ChatGPTModel(rawValue: viewStore.state.modelName) == nil { + Text("Custom Model").tag(viewStore.state.modelName) + } + ForEach(ChatGPTModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + } + + maxTokensTextField + supportsFunctionCallingToggle + } + + @ViewBuilder + var azureOpenAI: some View { + baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Deployment Name", text: viewStore.$modelName) + } + + maxTokensTextField + supportsFunctionCallingToggle + } + + @ViewBuilder + var openAICompatible: some View { + baseURLTextField(prompt: Text("https://")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + } + + maxTokensTextField + supportsFunctionCallingToggle + } +} + +class ChatModelManagementView_Editing_Previews: PreviewProvider { + static var previews: some View { + ChatModelEditView( + store: .init( + initialState: .init(model: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + )), + reducer: ChatModelEdit() + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift new file mode 100644 index 00000000..182536c1 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -0,0 +1,161 @@ +import AIModel +import ComposableArchitecture +import Keychain +import Preferences +import SwiftUI + +extension ChatModel: ManageableAIModel { + var formatName: String { + switch format { + case .openAI: return "OpenAI" + case .azureOpenAI: return "Azure OpenAI" + case .openAICompatible: return "OpenAI Compatible" + } + } + + @ViewBuilder + var infoDescriptors: some View { + Text(info.modelName) + + if !info.baseURL.isEmpty { + Image(systemName: "line.diagonal") + Text(info.baseURL) + } + + Image(systemName: "line.diagonal") + + Text("\(info.maxTokens) tokens") + + Image(systemName: "line.diagonal") + + Text( + "function calling \(info.supportsFunctionCalling ? Image(systemName: "checkmark.square") : Image(systemName: "xmark.square"))" + ) + } +} + +struct ChatModelManagement: AIModelManagement { + typealias Model = ChatModel + + struct State: Equatable, AIModelManagementState { + typealias Model = ChatModel + var models: IdentifiedArrayOf = [] + @PresentationState var editingModel: ChatModelEdit.State? + var selectedModelId: String? { editingModel?.id } + } + + enum Action: Equatable, AIModelManagementAction { + typealias Model = ChatModel + case appear + case createModel + case removeModel(id: Model.ID) + case selectModel(id: Model.ID) + case duplicateModel(id: Model.ID) + case moveModel(from: IndexSet, to: Int) + case chatModelItem(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + state.models = .init( + userDefaults.value(for: \.chatModels), + id: \.id, + uniquingIDsWith: { a, _ in a } + ) + + return .none + + case .createModel: + state.editingModel = .init( + id: UUID().uuidString, + name: "New Model", + format: .openAI + ) + return .none + + case let .removeModel(id): + state.models.remove(id: id) + persist(state) + return .none + + case let .selectModel(id): + guard let model = state.models[id: id] else { return .none } + state.editingModel = .init(model: model) + return .none + + case let .duplicateModel(id): + guard var model = state.models[id: id] else { return .none } + model.id = UUID().uuidString + model.name += " (Copy)" + + if let index = state.models.index(id: id) { + state.models.insert(model, at: index + 1) + } else { + state.models.append(model) + } + persist(state) + return .none + + case let .moveModel(from, to): + state.models.move(fromOffsets: from, toOffset: to) + persist(state) + return .none + + case .chatModelItem(.presented(.saveButtonClicked)): + guard let editingModel = state.editingModel, validateModel(editingModel) + else { return .none } + + if let index = state.models + .firstIndex(where: { $0.id == editingModel.id }) + { + state.models[index] = .init(state: editingModel) + } else { + state.models.append(.init(state: editingModel)) + } + persist(state) + return .run { send in + await send(.chatModelItem(.dismiss)) + } + + case .chatModelItem(.presented(.cancelButtonClicked)): + return .run { send in + await send(.chatModelItem(.dismiss)) + } + + case .chatModelItem: + return .none + } + }.ifLet(\.$editingModel, action: /Action.chatModelItem) { + ChatModelEdit() + } + } + + func persist(_ state: State) { + let models = state.models + userDefaults.set(Array(models), for: \.chatModels) + } + + func validateModel(_ chatModel: ChatModelEdit.State) -> Bool { + guard !chatModel.name.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + guard !chatModel.id.isEmpty else { + toast("Model ID cannot be empty", .error) + return false + } + + guard !chatModel.modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + return true + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift new file mode 100644 index 00000000..cac7184f --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -0,0 +1,84 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +struct ChatModelManagementView: View { + let store: StoreOf + + var body: some View { + AIModelManagementView(store: store) + .sheet(store: store.scope( + state: \.$editingModel, + action: ChatModelManagement.Action.chatModelItem + )) { store in + ChatModelEditView(store: store) + .frame(minWidth: 400) + } + } +} + +// MARK: - Previews + +class ChatModelManagementView_Previews: PreviewProvider { + static var previews: some View { + ChatModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: .init( + model: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ) + ) + ), + reducer: ChatModelManagement() + ) + ) + } +} diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift index 53e1f76e..2ca683c4 100644 --- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift +++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift @@ -10,6 +10,9 @@ struct CodeiumView: View { @Published var installationStatus: CodeiumInstallationManager.InstallationStatus @Published var installationStep: CodeiumInstallationManager.InstallationStep? @AppStorage(\.codeiumVerboseLog) var codeiumVerboseLog + @AppStorage(\.codeiumEnterpriseMode) var codeiumEnterpriseMode + @AppStorage(\.codeiumPortalUrl) var codeiumPortalUrl + @AppStorage(\.codeiumApiUrl) var codeiumApiUrl init() { isSignedIn = codeiumAuthService.isSignedIn @@ -28,6 +31,13 @@ struct CodeiumView: View { } func generateAuthURL() -> URL { + if codeiumEnterpriseMode && (codeiumPortalUrl != "") { + return URL( + string: codeiumPortalUrl + + "/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query" + )! + } + return URL( string: "https://www.codeium.com/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query" )! @@ -149,10 +159,10 @@ struct CodeiumView: View { updateButton } } - + if viewModel.isSignedIn { Text("Status: Signed In") - + Button(action: { Task { do { @@ -166,7 +176,7 @@ struct CodeiumView: View { } } else { Text("Status: Not Signed In") - + Button(action: { isSignInPanelPresented = true }) { @@ -197,9 +207,23 @@ struct CodeiumView: View { } } } - + + Divider() + + Form { + Toggle("Codeium Enterprise Mode", isOn: $viewModel.codeiumEnterpriseMode) + TextField("Codeium Portal URL", text: $viewModel.codeiumPortalUrl) + TextField("Codeium API URL", text: $viewModel.codeiumApiUrl) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) + } + Divider() - + Form { Toggle("Verbose Log", isOn: $viewModel.codeiumVerboseLog) } @@ -233,13 +257,13 @@ struct CodeiumSignInView: View { HStack { Spacer() - + Button(action: { isPresented = false }) { Text("Cancel") } - + Button(action: { isGeneratingKey = true Task { diff --git a/Core/Sources/HostApp/AccountSettings/CopilotView.swift b/Core/Sources/HostApp/AccountSettings/CopilotView.swift index bc53db71..eec77dff 100644 --- a/Core/Sources/HostApp/AccountSettings/CopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/CopilotView.swift @@ -326,6 +326,7 @@ struct CopilotView: View { } } } + .textFieldStyle(.roundedBorder) } func checkStatus() { diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift new file mode 100644 index 00000000..f2c917d6 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift @@ -0,0 +1,2 @@ +import SwiftUI +import Keychain diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift new file mode 100644 index 00000000..6b0d772b --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -0,0 +1,180 @@ +import AIModel +import ComposableArchitecture +import Dependencies +import Keychain +import OpenAIService +import Preferences +import SwiftUI + +struct EmbeddingModelEdit: ReducerProtocol { + struct State: Equatable, Identifiable { + var id: String + @BindingState var name: String + @BindingState var format: EmbeddingModel.Format + @BindingState var maxTokens: Int = 8191 + @BindingState var modelName: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } + var availableModelNames: [String] = [] + var availableAPIKeys: [String] = [] + var isTesting = false + var suggestedMaxTokens: Int? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case cancelButtonClicked + case refreshAvailableModelNames + case testButtonClicked + case testSucceeded(String) + case testFailed(String) + case checkSuggestedMaxTokens + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + BindingReducer() + + Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .saveButtonClicked: + return .none + + case .cancelButtonClicked: + return .none + + case .testButtonClicked: + guard !state.isTesting else { return .none } + state.isTesting = true + let model = EmbeddingModel( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL, + maxTokens: state.maxTokens, + modelName: state.modelName + ) + ) + return .run { send in + do { + let tokenUsage = + try await EmbeddingService( + configuration: UserPreferenceEmbeddingConfiguration() + .overriding { + $0.model = model + } + ).embed(text: "Hello").usage.total_tokens + await send(.testSucceeded("Used \(tokenUsage) tokens.")) + } catch { + await send(.testFailed(error.localizedDescription)) + } + } + + case let .testSucceeded(message): + state.isTesting = false + toast(message, .info) + return .none + + case let .testFailed(message): + state.isTesting = false + toast(message, .error) + return .none + + case .refreshAvailableModelNames: + if state.format == .openAI { + state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue) + } + + return .none + + case .checkSuggestedMaxTokens: + guard state.format == .openAI, + let knownModel = OpenAIEmbeddingModel(rawValue: state.modelName) + else { + state.suggestedMaxTokens = nil + return .none + } + state.suggestedMaxTokens = knownModel.maxToken + return .none + + case .apiKeySelection: + return .none + + case .baseURLSelection: + return .none + + case .binding(\.$format): + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .binding(\.$modelName): + return .run { send in + await send(.checkSuggestedMaxTokens) + } + + case .binding: + return .none + } + } + } +} + +extension EmbeddingModelEdit.State { + init(model: EmbeddingModel) { + self.init( + id: model.id, + name: model.name, + format: model.format, + maxTokens: model.info.maxTokens, + modelName: model.info.modelName, + apiKeySelection: .init( + apiKeyName: model.info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: model.info.baseURL) + ) + } +} + +extension EmbeddingModel { + init(state: EmbeddingModelEdit.State) { + self.init( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + maxTokens: state.maxTokens, + modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift new file mode 100644 index 00000000..c1162181 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -0,0 +1,260 @@ +import AIModel +import ComposableArchitecture +import Preferences +import SwiftUI + +@MainActor +struct EmbeddingModelEditView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Form { + nameTextField + formatPicker + + WithViewStore(store, observe: { $0.format }) { viewStore in + switch viewStore.state { + case .openAI: + openAI + case .azureOpenAI: + azureOpenAI + case .openAICompatible: + openAICompatible + } + } + } + .padding() + + Divider() + + HStack { + WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack(spacing: 8) { + Button("Test") { + store.send(.testButtonClicked) + } + .disabled(viewStore.state) + + if viewStore.state { + ProgressView() + .controlSize(.small) + } + } + } + + Spacer() + + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) + + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + } + + var nameTextField: some View { + WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in + TextField("Name", text: viewStore.$name) + } + } + + var formatPicker: some View { + WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in + Picker( + selection: viewStore.$format, + content: { + ForEach( + EmbeddingModel.Format.allCases, + id: \.rawValue + ) { format in + switch format { + case .openAI: + Text("OpenAI").tag(format) + case .azureOpenAI: + Text("Azure OpenAI").tag(format) + case .openAICompatible: + Text("OpenAI Compatible").tag(format) + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + } + + func baseURLTextField(prompt: Text?) -> some View { + BaseURLPicker( + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: EmbeddingModelEdit.Action.baseURLSelection + ) + ) + } + + struct MaxTokensTextField: Equatable { + @BindingViewState var maxTokens: Int + var suggestedMaxTokens: Int? + } + + var maxTokensTextField: some View { + WithViewStore( + store, + observe: { + MaxTokensTextField( + maxTokens: $0.$maxTokens, + suggestedMaxTokens: $0.suggestedMaxTokens + ) + } + ) { viewStore in + HStack { + let textFieldBinding = Binding( + get: { String(viewStore.state.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + viewStore.$maxTokens.wrappedValue = selectionMaxToken + } else { + viewStore.$maxTokens.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Max Input Tokens") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: viewStore.$maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = viewStore.state.suggestedMaxTokens else { + return .primary + } + if viewStore.state.maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = viewStore.state.suggestedMaxTokens { + Text("Max: \(max)") + } + } + } + } + + struct APIKeyState: Equatable { + @BindingViewState var apiKeyName: String + var availableAPIKeys: [String] + } + + @ViewBuilder + var apiKeyNamePicker: some View { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: EmbeddingModelEdit.Action.apiKeySelection + )) + } + + @ViewBuilder + var openAI: some View { + baseURLTextField(prompt: Text("https://api.openai.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$modelName, + content: { + if OpenAIEmbeddingModel(rawValue: viewStore.state.modelName) == nil { + Text("Custom Model").tag(viewStore.state.modelName) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + } + + maxTokensTextField + } + + @ViewBuilder + var azureOpenAI: some View { + baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Deployment Name", text: viewStore.$modelName) + } + + maxTokensTextField + } + + @ViewBuilder + var openAICompatible: some View { + baseURLTextField(prompt: Text("https://")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + } + + maxTokensTextField + } +} + +class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { + static var previews: some View { + EmbeddingModelEditView( + store: .init( + initialState: .init(model: EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + )), + reducer: EmbeddingModelEdit() + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift new file mode 100644 index 00000000..eda907d3 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -0,0 +1,155 @@ +import AIModel +import ComposableArchitecture +import Keychain +import Preferences +import SwiftUI + +extension EmbeddingModel: ManageableAIModel { + var formatName: String { + switch format { + case .openAI: return "OpenAI" + case .azureOpenAI: return "Azure OpenAI" + case .openAICompatible: return "OpenAI Compatible" + } + } + + @ViewBuilder + var infoDescriptors: some View { + Text(info.modelName) + + if !info.baseURL.isEmpty { + Image(systemName: "line.diagonal") + Text(info.baseURL) + } + + Image(systemName: "line.diagonal") + + Text("\(info.maxTokens) tokens") + } +} + +struct EmbeddingModelManagement: AIModelManagement { + typealias Model = EmbeddingModel + + struct State: Equatable, AIModelManagementState { + typealias Model = EmbeddingModel + var models: IdentifiedArrayOf = [] + @PresentationState var editingModel: EmbeddingModelEdit.State? + var selectedModelId: Model.ID? { editingModel?.id } + } + + enum Action: Equatable, AIModelManagementAction { + typealias Model = EmbeddingModel + case appear + case createModel + case removeModel(id: Model.ID) + case selectModel(id: Model.ID) + case duplicateModel(id: Model.ID) + case moveModel(from: IndexSet, to: Int) + case embeddingModelItem(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + state.models = .init( + userDefaults.value(for: \.embeddingModels), + id: \.id, + uniquingIDsWith: { a, _ in a } + ) + + return .none + + case .createModel: + state.editingModel = .init( + id: UUID().uuidString, + name: "New Model", + format: .openAI + ) + return .none + + case let .removeModel(id): + state.models.remove(id: id) + persist(state) + return .none + + case let .selectModel(id): + guard let model = state.models[id: id] else { return .none } + state.editingModel = .init(model: model) + return .none + + case let .duplicateModel(id): + guard var model = state.models[id: id] else { return .none } + model.id = UUID().uuidString + model.name += " (Copy)" + + if let index = state.models.index(id: id) { + state.models.insert(model, at: index + 1) + } else { + state.models.append(model) + } + persist(state) + return .none + + case let .moveModel(from, to): + state.models.move(fromOffsets: from, toOffset: to) + persist(state) + return .none + + case .embeddingModelItem(.presented(.saveButtonClicked)): + guard let editingModel = state.editingModel, validateModel(editingModel) + else { return .none } + + if let index = state.models + .firstIndex(where: { $0.id == editingModel.id }) + { + state.models[index] = .init(state: editingModel) + } else { + state.models.append(.init(state: editingModel)) + } + persist(state) + return .run { send in + await send(.embeddingModelItem(.dismiss)) + } + + case .embeddingModelItem(.presented(.cancelButtonClicked)): + return .run { send in + await send(.embeddingModelItem(.dismiss)) + } + + case .embeddingModelItem: + return .none + } + }.ifLet(\.$editingModel, action: /Action.embeddingModelItem) { + EmbeddingModelEdit() + } + } + + func persist(_ state: State) { + let models = state.models + userDefaults.set(Array(models), for: \.embeddingModels) + } + + func validateModel(_ chatModel: EmbeddingModelEdit.State) -> Bool { + guard !chatModel.name.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + guard !chatModel.id.isEmpty else { + toast("Model ID cannot be empty", .error) + return false + } + + guard !chatModel.modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + return true + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift new file mode 100644 index 00000000..a71b356d --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift @@ -0,0 +1,80 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +struct EmbeddingModelManagementView: View { + let store: StoreOf + + var body: some View { + AIModelManagementView(store: store) + .sheet(store: store.scope( + state: \.$editingModel, + action: EmbeddingModelManagement.Action.embeddingModelItem + )) { store in + EmbeddingModelEditView(store: store) + .frame(minWidth: 400) + } + } +} + +// MARK: - Previews + +class EmbeddingModelManagementView_Previews: PreviewProvider { + static var previews: some View { + EmbeddingModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + EmbeddingModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + EmbeddingModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: .init( + model: EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ) + ) + ), + reducer: EmbeddingModelManagement() + ) + ) + } +} diff --git a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift deleted file mode 100644 index ddb21afd..00000000 --- a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift +++ /dev/null @@ -1,119 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionModel -import SwiftUI - -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" - )! - @Environment(\.openURL) var openURL - @Environment(\.toast) var toast - @State var isTesting = false - @StateObject var settings = Settings() - - var body: some View { - Form { - HStack { - SecureField(text: $settings.openAIAPIKey, prompt: Text("sk-*")) { - Text("OpenAI API Key") - } - .textFieldStyle(.roundedBorder) - Button(action: { - openURL(apiKeyURL) - }) { - Image(systemName: "questionmark.circle.fill") - }.buttonStyle(.plain) - } - - HStack { - TextField( - text: $settings.openAIBaseURL, - prompt: Text("https://api.openai.com") - ) { - Text("OpenAI Base URL") - }.textFieldStyle(.roundedBorder) - - Button("Test") { - Task { @MainActor in - isTesting = true - defer { isTesting = false } - do { - let reply = - try await ChatGPTService( - configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(featureProvider: .openAI)) - ) - .sendAndWait(content: "Hello", summary: nil) - toast("ChatGPT replied: \(reply ?? "N/A")", .info) - } catch { - toast(error.localizedDescription, .error) - } - } - }.disabled(isTesting) - } - - HStack { - Picker(selection: $settings.chatGPTModel) { - if !settings.chatGPTModel.isEmpty, - ChatGPTModel(rawValue: settings.chatGPTModel) == nil - { - Text(settings.chatGPTModel).tag(settings.chatGPTModel) - } - ForEach(ChatGPTModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } label: { - Text("ChatGPT Model") - }.pickerStyle(.menu) - Button(action: { - openURL(modelURL) - }) { - 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) - } - } - } -} - -struct OpenAIView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - OpenAIView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift new file mode 100644 index 00000000..37bcac29 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -0,0 +1,273 @@ +import AIModel +import ComposableArchitecture +import PlusFeatureFlag +import SwiftUI + +protocol AIModelManagementAction { + associatedtype Model: ManageableAIModel + static var appear: Self { get } + static var createModel: Self { get } + static func removeModel(id: Model.ID) -> Self + static func selectModel(id: Model.ID) -> Self + static func duplicateModel(id: Model.ID) -> Self + static func moveModel(from: IndexSet, to: Int) -> Self +} + +protocol AIModelManagementState: Equatable { + associatedtype Model: ManageableAIModel + var models: IdentifiedArrayOf { get } + var selectedModelId: Model.ID? { get } +} + +protocol AIModelManagement: ReducerProtocol where + Action: AIModelManagementAction, + State: AIModelManagementState, + Action.Model == Self.Model, + State.Model == Self.Model +{ + associatedtype Model: ManageableAIModel +} + +protocol ManageableAIModel: Identifiable { + associatedtype V: View + var name: String { get } + var formatName: String { get } + var infoDescriptors: V { get } +} + +struct AIModelManagementView: View + where Management.Model == Model +{ + let store: StoreOf + + var body: some View { + VStack(spacing: 0) { + HStack { + Spacer() + if isFeatureAvailable(\.unlimitedChatAndEmbeddingModels) { + Button("Add Model") { + store.send(.createModel) + } + } else { + WithViewStore(store, observe: { $0.models.count }) { viewStore in + Text("\(viewStore.state) / 2") + .foregroundColor(.secondary) + + let disabled = viewStore.state >= 2 + + Button(disabled ? "Add More Model (Plus)" : "Add Model") { + store.send(.createModel) + }.disabled(disabled) + } + } + }.padding(4) + + Divider() + + ModelList(store: store) + } + .onAppear { + store.send(.appear) + } + } + + struct ModelList: View { + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + List { + ForEach(viewStore.state.models) { model in + let isSelected = viewStore.state.selectedModelId == model.id + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") + + Button(action: { + viewStore.send(.selectModel(id: model.id)) + }) { + Cell(model: model, isSelected: isSelected) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + Button("Duplicate") { + store.send(.duplicateModel(id: model.id)) + } + Button("Remove") { + store.send(.removeModel(id: model.id)) + } + } + } + } + .onMove(perform: { indices, newOffset in + viewStore.send(.moveModel(from: indices, to: newOffset)) + }) + } + .removeBackground() + .listStyle(.plain) + .listRowInsets(EdgeInsets()) + .overlay { + if viewStore.state.models.isEmpty { + Text("No model found, please add a new one.") + .foregroundColor(.secondary) + } + } + } + } + } + + struct Cell: View { + let model: Model + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(model.formatName) + .foregroundColor(isSelected ? .white : .primary) + .font(.subheadline.bold()) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + RoundedRectangle(cornerRadius: 4) + .fill( + isSelected + ? .white.opacity(0.2) + : Color.primary.opacity(0.1) + ) + } + + Text(model.name) + .font(.headline) + } + + HStack(spacing: 4) { + model.infoDescriptors + } + .font(.subheadline) + .opacity(0.7) + .padding(.leading, 2) + } + Spacer() + } + .onHover(perform: { + isHovered = $0 + }) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill({ + switch (isSelected, isHovered) { + case (true, _): + return Color.accentColor + case (_, true): + return Color.primary.opacity(0.1) + case (_, false): + return Color.clear + } + }() as Color) + } + .foregroundColor(isSelected ? .white : .primary) + .animation(.easeInOut(duration: 0.1), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: isHovered) + } + } +} + +// MARK: - Previews + +class AIModelManagement_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: .init( + model: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ) + ) + ), + reducer: ChatModelManagement() + ) + ) + } +} + +class AIModelManagement_Empty_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView( + store: .init( + initialState: .init(models: []), + reducer: ChatModelManagement() + ) + ) + } +} + +class AIModelManagement_Cell_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView.Cell(model: ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), isSelected: false) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift new file mode 100644 index 00000000..47f5144a --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift @@ -0,0 +1,38 @@ +import ComposableArchitecture +import SwiftUI + +struct BaseURLPicker: View { + let prompt: Text? + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + TextField("Base URL", text: viewStore.$baseURL, prompt: prompt) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$baseURL, + content: { + if !viewStore.state.availableBaseURLs + .contains(viewStore.state.baseURL), + !viewStore.state.baseURL.isEmpty + { + Text("Custom Value").tag(viewStore.state.baseURL) + } + + Text("Empty (Default Value)").tag("") + + ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in + Text(baseURL).tag(baseURL) + } + } + ) + .frame(width: 20) + } + .onAppear { + viewStore.send(.appear) + } + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift new file mode 100644 index 00000000..c4cd4b96 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -0,0 +1,50 @@ +import ComposableArchitecture +import Foundation +import Preferences +import SwiftUI + +struct BaseURLSelection: ReducerProtocol { + struct State: Equatable { + @BindingState var baseURL: String = "" + var availableBaseURLs: [String] = [] + } + + enum Action: Equatable, BindableAction { + case appear + case refreshAvailableBaseURLNames + case binding(BindingAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableBaseURLNames) + } + + case .refreshAvailableBaseURLNames: + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + var allBaseURLs = Set( + chatModels.map(\.info.baseURL) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + embeddingModels.map(\.info.baseURL) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + ) + allBaseURLs.remove("") + state.availableBaseURLs = Array(allBaseURLs).sorted() + return .none + + case .binding: + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 06de9c24..52f6834f 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -9,8 +9,10 @@ extension List { func removeBackground() -> some View { if #available(macOS 13.0, *) { scrollContentBackground(.hidden) + .listRowBackground(EmptyView()) } else { background(Color.clear) + .listRowBackground(EmptyView()) } } } diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 96a3844a..142be6ca 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -14,6 +14,7 @@ final class DebugSettings: ObservableObject { @AppStorage(\.disableFunctionCalling) var disableFunctionCalling @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) var disableGitHubCopilotSettingsAutoRefreshOnAppear + @AppStorage(\.useUserDefaultsBaseAPIKeychain) var useUserDefaultsBaseAPIKeychain init() {} } @@ -41,17 +42,33 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.triggerActionWithAccessibilityAPI) { Text("Trigger command with AccessibilityAPI") } - Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) { - Text("Always accept suggestion with Accessibility API") - } - Toggle(isOn: $settings.enableXcodeInspectorDebugMenu) { - Text("Enable Xcode inspector debug menu") - } - Toggle(isOn: $settings.disableFunctionCalling) { - Text("Disable function calling for chat feature") - } - Toggle(isOn: $settings.disableGitHubCopilotSettingsAutoRefreshOnAppear) { - Text("Disable GitHub Copilot settings auto refresh status on appear") + Group { + Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) { + Text("Always accept suggestion with Accessibility API") + } + Toggle(isOn: $settings.enableXcodeInspectorDebugMenu) { + Text("Enable Xcode inspector debug menu") + } + Toggle(isOn: $settings.disableFunctionCalling) { + Text("Disable function calling for chat feature") + } + Toggle(isOn: $settings.disableGitHubCopilotSettingsAutoRefreshOnAppear) { + Text("Disable GitHub Copilot settings auto refresh status on appear") + } + Toggle(isOn: $settings.useUserDefaultsBaseAPIKeychain) { + Text("Store API keys in UserDefaults") + } + + Button("Reset Migration Version to 0") { + UserDefaults.shared.set(nil, forKey: "OldMigrationVersion") + } + + Button("Reset 0.23.0 migration") { + UserDefaults.shared.set("239", forKey: "OldMigrationVersion") + UserDefaults.shared.set(nil, forKey: "MigrateTo240Finished") + UserDefaults.shared.set(nil, forKey: "ChatModels") + UserDefaults.shared.set(nil, forKey: "EmbeddingModels") + } } } .padding() diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index b288f736..6a4c83fc 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -5,7 +5,6 @@ struct ChatSettingsView: View { class Settings: ObservableObject { static let availableLocalizedLocales = Locale.availableLocalizedLocales @AppStorage(\.chatGPTLanguage) var chatGPTLanguage - @AppStorage(\.chatGPTMaxToken) var chatGPTMaxToken @AppStorage(\.chatGPTTemperature) var chatGPTTemperature @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount @AppStorage(\.chatFontSize) var chatFontSize @@ -14,13 +13,12 @@ struct ChatSettingsView: View { var maxFocusedCodeLineCount @AppStorage(\.useCodeScopeByDefaultInChatContext) var useCodeScopeByDefaultInChatContext - - @AppStorage(\.chatFeatureProvider) var chatFeatureProvider - @AppStorage(\.chatGPTModel) var chatGPTModel + @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations - - @AppStorage(\.embeddingFeatureProvider) var embeddingFeatureProvider + @AppStorage(\.defaultChatFeatureEmbeddingModelId) var defaultChatFeatureEmbeddingModelId + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.embeddingModels) var embeddingModels init() {} } @@ -47,18 +45,40 @@ struct ChatSettingsView: View { Form { Picker( "Chat Feature Provider", - selection: $settings.chatFeatureProvider + selection: $settings.defaultChatFeatureChatModelId ) { - Text("OpenAI").tag(ChatFeatureProvider.openAI) - Text("Azure OpenAI").tag(ChatFeatureProvider.azureOpenAI) + if !settings.chatModels + .contains(where: { $0.id == settings.defaultChatFeatureChatModelId }) + { + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.defaultChatFeatureChatModelId) + } + + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } } - + Picker( "Embedding Feature Provider", - selection: $settings.embeddingFeatureProvider + selection: $settings.defaultChatFeatureEmbeddingModelId ) { - Text("OpenAI").tag(EmbeddingFeatureProvider.openAI) - Text("Azure OpenAI").tag(EmbeddingFeatureProvider.azureOpenAI) + if !settings.embeddingModels + .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId }) + { + Text( + (settings.embeddingModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.defaultChatFeatureEmbeddingModelId) + } + + ForEach(settings.embeddingModels, id: \.id) { embeddingModel in + Text(embeddingModel.name).tag(embeddingModel.id) + } } if #available(macOS 13.0, *) { @@ -72,39 +92,6 @@ struct ChatSettingsView: View { } } - let binding = Binding( - get: { String(settings.chatGPTMaxToken) }, - set: { - if let selectionMaxToken = Int($0) { - settings.chatGPTMaxToken = selectionMaxToken - } else { - settings.chatGPTMaxToken = 0 - } - } - ) - HStack { - Stepper( - value: $settings.chatGPTMaxToken, - in: 0...Int.max, - step: 1 - ) { - Text("Max Token (Including Reply)") - .multilineTextAlignment(.trailing) - } - TextField(text: binding) { - EmptyView() - } - .labelsHidden() - .textFieldStyle(.roundedBorder) - .foregroundColor(maxTokenOverLimit ? .red : .primary) - - if let model = ChatGPTModel(rawValue: settings.chatGPTModel), - settings.chatFeatureProvider == .openAI - { - Text("Max: \(model.maxToken)") - } - } - HStack { Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) { Text("Temperature") @@ -141,14 +128,6 @@ struct ChatSettingsView: View { .lineLimit(6) } .padding(.vertical, 4) - }.onAppear { - checkMaxToken() - }.onChange(of: settings.chatFeatureProvider) { _ in - checkMaxToken() - }.onChange(of: settings.chatGPTModel) { _ in - checkMaxToken() - }.onChange(of: settings.chatGPTMaxToken) { _ in - checkMaxToken() } } @@ -251,19 +230,6 @@ struct ChatSettingsView: View { ) } } - - func checkMaxToken() { - switch settings.chatFeatureProvider { - case .openAI: - if let model = ChatGPTModel(rawValue: settings.chatGPTModel) { - maxTokenOverLimit = model.maxToken < settings.chatGPTMaxToken - } else { - maxTokenOverLimit = false - } - case .azureOpenAI: - maxTokenOverLimit = false - } - } } // MARK: - Preview diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 7b96456b..65f0ab1d 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -9,12 +9,16 @@ import LicenseManagement struct HostApp: ReducerProtocol { struct State: Equatable { var general = General.State() + var chatModelManagement = ChatModelManagement.State() + var embeddingModelManagement = EmbeddingModelManagement.State() } enum Action: Equatable { case appear case informExtensionServiceAboutLicenseKeyChange case general(General.Action) + case chatModelManagement(ChatModelManagement.Action) + case embeddingModelManagement(EmbeddingModelManagement.Action) } @Dependency(\.toast) var toast @@ -23,11 +27,20 @@ struct HostApp: ReducerProtocol { Scope(state: \.general, action: /Action.general) { General() } + + Scope(state: \.chatModelManagement, action: /Action.chatModelManagement) { + ChatModelManagement() + } + + Scope(state: \.embeddingModelManagement, action: /Action.embeddingModelManagement) { + EmbeddingModelManagement() + } Reduce { _, action in switch action { case .appear: return .none + case .informExtensionServiceAboutLicenseKeyChange: #if canImport(LicenseManagement) return .run { _ in @@ -42,10 +55,54 @@ struct HostApp: ReducerProtocol { #else return .none #endif + case .general: return .none + + case .chatModelManagement: + return .none + + case .embeddingModelManagement: + return .none } } } } +import Dependencies +import Preferences +import Keychain + +struct UserDefaultsDependencyKey: DependencyKey { + static var liveValue: UserDefaultsType = UserDefaults.shared + static var previewValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppPreview")! + it.removePersistentDomain(forName: "HostAppPreview") + return it + }() + static var testValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppTest")! + it.removePersistentDomain(forName: "HostAppTest") + return it + }() +} + +extension DependencyValues { + var userDefaults: UserDefaultsType { + get { self[UserDefaultsDependencyKey.self] } + set { self[UserDefaultsDependencyKey.self] = newValue } + } +} + +struct APIKeyKeychainDependencyKey: DependencyKey { + static var liveValue: KeychainType = Keychain.apiKey + static var previewValue: KeychainType = FakeKeyChain() + static var testValue: KeychainType = FakeKeyChain() +} + +extension DependencyValues { + var apiKeyKeychain: KeychainType { + get { self[APIKeyKeychainDependencyKey.self] } + set { self[APIKeyKeychainDependencyKey.self] = newValue } + } +} diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 5960e849..ef259ee6 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -1,7 +1,10 @@ import SwiftUI +import ComposableArchitecture struct ServiceView: View { + let store: StoreOf @State var tag = 0 + var body: some View { SidebarTabView(tag: $tag) { ScrollView { @@ -22,20 +25,22 @@ struct ServiceView: View { image: "globe" ) - ScrollView { - OpenAIView().padding() - }.sidebarItem( + ChatModelManagementView(store: store.scope( + state: \.chatModelManagement, + action: HostApp.Action.chatModelManagement + )).sidebarItem( tag: 2, - title: "OpenAI", + title: "Chat Models", subtitle: "Chat, Prompt to Code", image: "globe" ) - ScrollView { - AzureView().padding() - }.sidebarItem( + EmbeddingModelManagementView(store: store.scope( + state: \.embeddingModelManagement, + action: HostApp.Action.embeddingModelManagement + )).sidebarItem( tag: 3, - title: "Azure", + title: "Embedding Models", subtitle: "Chat, Prompt to Code", image: "globe" ) @@ -54,6 +59,6 @@ struct ServiceView: View { struct AccountView_Previews: PreviewProvider { static var previews: some View { - ServiceView() + ServiceView(store: .init(initialState: .init(), reducer: HostApp())) } } diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 77c62a2c..02083499 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -43,7 +43,7 @@ public struct TabContainer: View { title: "General", image: "app.gift" ) - ServiceView().tabBarItem( + ServiceView(store: store).tabBarItem( tag: 1, title: "Service", image: "globe" diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift similarity index 67% rename from Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift rename to Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index bcd54a28..064399fa 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -2,25 +2,22 @@ import Foundation import OpenAIService import Preferences import SuggestionModel +import XcodeInspector -final class OpenAIPromptToCodeAPI: PromptToCodeAPI { +public final class OpenAIPromptToCodeService: PromptToCodeServiceType { var service: (any ChatGPTServiceType)? - func stopResponding() { - Task { - await service?.stopReceivingMessage() - } + public init() {} + + public func stopResponding() { + Task { await service?.stopReceivingMessage() } } - func modifyCode( + public func modifyCode( code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, + source: PromptToCodeSource, + isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { @@ -31,9 +28,25 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { { return "" } - return userPreferredLanguage.isEmpty ? "" : "in \(userPreferredLanguage)" + return userPreferredLanguage.isEmpty ? "" : " in \(userPreferredLanguage)" }() + let editor: EditorInformation = XcodeInspector.shared.focusedEditorContent ?? .init( + editorContent: .init( + content: source.allCode, + lines: [], + selections: [source.range], + cursorPosition: .outOfScope, + lineAnnotations: [] + ), + selectedContent: code, + selectedLines: [], + documentURL: source.documentURL, + projectURL: source.projectRootURL, + relativePath: "", + language: source.language + ) + let rule: String = { func generateDescription(index: Int) -> String { let generateDescription = generateDescriptionRequirement ?? UserDefaults.shared @@ -46,7 +59,7 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { """ : "\(index). Reply with the result." } - switch language { + switch editor.language { case .builtIn(.markdown), .plaintext: if code.isEmpty { return """ @@ -82,20 +95,20 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { }() let systemPrompt = { - switch language { + switch editor.language { case .builtIn(.markdown), .plaintext: if code.isEmpty { return """ - You are good at writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are good at writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) """ } else { return """ - You are good at writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are good at writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) @@ -104,16 +117,16 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { default: if code.isEmpty { return """ - You are a senior programer in writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are a senior programer in writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) """ } else { return """ - You are a senior programer in writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are a senior programer in writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) @@ -122,31 +135,42 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { } }() + let annotations = isDetached + ? "" + : extractAnnotations(editorInformation: editor, source: source) + let firstMessage: String? = { if code.isEmpty { return nil } - switch language { + switch editor.language { case .builtIn(.markdown), .plaintext: return """ ``` \(code) ``` + + \(annotations) """ default: return """ ``` \(code) ``` + + \(annotations) """ } }() + let indentation = getCommonLeadingSpaceCount(code) + let secondMessage = """ - Requirements:### - \(requirement) - ### + I will update the code you just provided. + It looks like every line has an indentation of \(indentation) spaces, I will keep that. + + What is your requirement? """ - let configuration = UserPreferenceChatGPTConfiguration() + let configuration = UserPreferenceChatGPTConfiguration() .overriding(.init(temperature: 0)) let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, @@ -161,9 +185,10 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { if let firstMessage { await memory.mutateHistory { history in history.append(.init(role: .user, content: firstMessage)) + history.append(.init(role: .assistant, content: secondMessage)) } } - let stream = try await chatGPTService.send(content: secondMessage) + let stream = try await chatGPTService.send(content: requirement) return .init { continuation in Task { var content = "" @@ -231,5 +256,38 @@ final class OpenAIPromptToCodeAPI: PromptToCodeAPI { return (code, description) } + + func getCommonLeadingSpaceCount(_ code: String) -> Int { + let lines = code.split(separator: "\n") + guard !lines.isEmpty else { return 0 } + var commonCount = Int.max + for line in lines { + let count = line.prefix(while: { $0 == " " }).count + commonCount = min(commonCount, count) + if commonCount == 0 { break } + } + return commonCount + } + + func extractAnnotations( + editorInformation: EditorInformation, + source: PromptToCodeSource + ) -> String { + guard let annotations = editorInformation.editorContent?.lineAnnotations else { return "" } + let all = annotations + .lazy + .filter { annotation in + annotation.line >= source.range.start.line + 1 + && annotation.line <= source.range.end.line + 1 + }.map { annotation in + let relativeLine = annotation.line - source.range.start.line + return "line \(relativeLine): \(annotation.type) \(annotation.message)" + } + guard !all.isEmpty else { return "" } + return """ + line annotations found: + \(annotations.map { "- \($0)" }.joined(separator: "\n")) + """ + } } diff --git a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift new file mode 100644 index 00000000..cff916f3 --- /dev/null +++ b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift @@ -0,0 +1,48 @@ +import Foundation +import SuggestionModel + +public final class PreviewPromptToCodeService: PromptToCodeServiceType { + public init() {} + + public func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + return AsyncThrowingStream { continuation in + Task { + let code = """ + struct Cat { + var name: String + } + + print("Hello world!") + """ + let description = "I have created a struct `Cat`." + var resultCode = "" + var resultDescription = "" + do { + for character in code { + try await Task.sleep(nanoseconds: 50_000_000) + resultCode.append(character) + continuation.yield((resultCode, resultDescription)) + } + for character in description { + try await Task.sleep(nanoseconds: 50_000_000) + resultDescription.append(character) + continuation.yield((resultCode, resultDescription)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + public func stopResponding() {} +} + diff --git a/Core/Sources/PromptToCodeService/PromptToCodeService.swift b/Core/Sources/PromptToCodeService/PromptToCodeService.swift deleted file mode 100644 index a293629f..00000000 --- a/Core/Sources/PromptToCodeService/PromptToCodeService.swift +++ /dev/null @@ -1,156 +0,0 @@ -import SuggestionModel -import Foundation -import OpenAIService - -public final class PromptToCodeService: ObservableObject { - var designatedPromptToCodeAPI: PromptToCodeAPI? - var promptToCodeAPI: PromptToCodeAPI { - if let designatedPromptToCodeAPI { - return designatedPromptToCodeAPI - } - - return OpenAIPromptToCodeAPI() - } - - var runningAPI: PromptToCodeAPI? - - public indirect enum HistoryNode: Equatable { - case empty - case node(code: String, description: String, previous: HistoryNode) - - mutating func enqueue(code: String, description: String) { - let current = self - self = .node(code: code, description: description, previous: current) - } - - mutating func pop() -> (code: String, description: String)? { - switch self { - case .empty: - return nil - case let .node(code, description, previous): - self = previous - return (code, description) - } - } - } - - @Published public var history: HistoryNode - @Published public var code: String - @Published public var isResponding: Bool = false - @Published public var description: String = "" - @Published public var isContinuous = false - public var canRevert: Bool { history != .empty } - public var selectionRange: CursorRange - public var language: CodeLanguage - public var indentSize: Int - public var usesTabsForIndentation: Bool - public var projectRootURL: URL - public var fileURL: URL - public var allCode: String - public var extraSystemPrompt: String? - public var generateDescriptionRequirement: Bool? - - public init( - code: String, - selectionRange: CursorRange, - language: CodeLanguage, - identSize: Int, - usesTabsForIndentation: Bool, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String? = nil, - generateDescriptionRequirement: Bool? - ) { - self.code = code - self.selectionRange = selectionRange - self.language = language - indentSize = identSize - self.usesTabsForIndentation = usesTabsForIndentation - self.projectRootURL = projectRootURL - self.fileURL = fileURL - self.allCode = allCode - self.history = .empty - self.extraSystemPrompt = extraSystemPrompt - self.generateDescriptionRequirement = generateDescriptionRequirement - } - - public func modifyCode(prompt: String) async throws { - let api = promptToCodeAPI - runningAPI = api - isResponding = true - let toBeModified = code - history.enqueue(code: code, description: description) - code = "" - description = "" - defer { isResponding = false } - do { - let stream = try await api.modifyCode( - code: toBeModified, - language: language, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - requirement: prompt, - projectRootURL: projectRootURL, - fileURL: fileURL, - allCode: allCode, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescriptionRequirement - ) - for try await fragment in stream { - code = fragment.code - description = fragment.description - } - if code.isEmpty, description.isEmpty { - revert() - } - } catch is CancellationError { - return - } catch { - if (error as NSError).code == NSURLErrorCancelled { - return - } - - revert() - throw error - } - } - - public func revert() { - guard let (code, description) = history.pop() else { return } - self.code = code - self.description = description - } - - public func generateCompletion() -> CodeSuggestion { - .init( - text: code, - position: selectionRange.start, - uuid: UUID().uuidString, - range: selectionRange, - displayText: code - ) - } - - public func stopResponding() { - runningAPI?.stopResponding() - isResponding = false - } -} - -protocol PromptToCodeAPI { - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> - - func stopResponding() -} diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift new file mode 100644 index 00000000..01519d18 --- /dev/null +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -0,0 +1,61 @@ +import Dependencies +import Foundation +import SuggestionModel + +public protocol PromptToCodeServiceType { + func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> + + func stopResponding() +} + +public struct PromptToCodeSource { + public var language: CodeLanguage + public var documentURL: URL + public var projectRootURL: URL + public var allCode: String + public var range: CursorRange + + public init( + language: CodeLanguage, + documentURL: URL, + projectRootURL: URL, + allCode: String, + range: CursorRange + ) { + self.language = language + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.allCode = allCode + self.range = range + } +} + +public struct PromptToCodeServiceDependencyKey: DependencyKey { + public static let liveValue: PromptToCodeServiceType = PreviewPromptToCodeService() + public static let previewValue: PromptToCodeServiceType = PreviewPromptToCodeService() +} + +public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { + public static let liveValue: () -> PromptToCodeServiceType = { OpenAIPromptToCodeService() } + public static let previewValue: () -> PromptToCodeServiceType = { PreviewPromptToCodeService() } +} + +public extension DependencyValues { + var promptToCodeService: PromptToCodeServiceType { + get { self[PromptToCodeServiceDependencyKey.self] } + set { self[PromptToCodeServiceDependencyKey.self] = newValue } + } + + var promptToCodeServiceFactory: () -> PromptToCodeServiceType { + get { self[PromptToCodeServiceFactoryDependencyKey.self] } + set { self[PromptToCodeServiceFactoryDependencyKey.self] = newValue } + } +} + diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift index 843133bc..b574ff5b 100644 --- a/Core/Sources/Service/GUI/ChatTabFactory.swift +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -83,20 +83,27 @@ enum ChatTabFactory { prompt: prompt ) case let .promptToCode(extraSystemPrompt, instruction, _, _): - let service = PromptToCodeService( + let service = OpenAIPromptToCodeService() + + let result = try await service.modifyCode( code: prompt, - selectionRange: .outOfScope, - language: .plaintext, - identSize: 4, - usesTabsForIndentation: true, - projectRootURL: .init(fileURLWithPath: "/"), - fileURL: .init(fileURLWithPath: "/"), - allCode: prompt, + requirement: instruction ?? "Modify content.", + source: .init( + language: .plaintext, + documentURL: .init(fileURLWithPath: "/"), + projectRootURL: .init(fileURLWithPath: "/"), + allCode: prompt, + range: .outOfScope + ), + isDetached: true, extraSystemPrompt: extraSystemPrompt, generateDescriptionRequirement: false ) - try await service.modifyCode(prompt: instruction ?? "Modify content.") - return service.code + var code = "" + for try await (newCode, _) in result { + code = newCode + } + return code } } ) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index d54c5e7d..77d4e0fa 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -1,3 +1,4 @@ +import ActiveApplicationMonitor import AppKit import ChatGPTChatTab import ChatTab @@ -24,6 +25,11 @@ struct GUI: ReducerProtocol { set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } } + var promptToCodeGroup: PromptToCodeGroup.State { + get { suggestionWidgetState.panelState.content.promptToCodeGroup } + set { suggestionWidgetState.panelState.content.promptToCodeGroup = newValue } + } + #if canImport(ChatTabPersistent) var persistentState: ChatTabPersistent.State { get { @@ -44,6 +50,10 @@ struct GUI: ReducerProtocol { case suggestionWidget(WidgetFeature.Action) + static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { + .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) + } + #if canImport(ChatTabPersistent) case persistent(ChatTabPersistent.Action) #endif @@ -217,6 +227,19 @@ public final class GraphicalUserInterfaceController { dependencies.suggestionWidgetUserDefaultsObservers = .init() dependencies.chatTabPool = chatTabPool dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection + dependencies.promptToCodeAcceptHandler = { promptToCode in + Task { + let handler = PseudoCommandHandler() + await handler.acceptPromptToCode() + if let app = ActiveApplicationMonitor.shared.previousApp, + app.isXcode, + !promptToCode.isContinuous + { + try await Task.sleep(nanoseconds: 200_000_000) + app.activate() + } + } + } #if canImport(ChatTabPersistent) && canImport(ProChatTabs) dependencies.restoreChatTabInPool = { @@ -269,7 +292,7 @@ public final class GraphicalUserInterfaceController { } } } - + func start() { store.send(.start) } diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift deleted file mode 100644 index 73187619..00000000 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ /dev/null @@ -1,85 +0,0 @@ -import ActiveApplicationMonitor -import Combine -import PromptToCodeService -import SuggestionWidget - -extension PromptToCodeProvider { - convenience init( - service: PromptToCodeService, - name: String?, - onClosePromptToCode: @escaping () -> Void - ) { - self.init( - code: service.code, - language: service.language.rawValue, - description: "", - startLineIndex: service.selectionRange.start.line, - startLineColumn: service.selectionRange.start.character, - name: name - ) - - var cancellables = Set() - - service.$code.sink(receiveValue: set(\.code)).store(in: &cancellables) - service.$isResponding.sink(receiveValue: set(\.isResponding)).store(in: &cancellables) - service.$description.sink(receiveValue: set(\.description)).store(in: &cancellables) - service.$isContinuous.sink(receiveValue: set(\.isContinuous)).store(in: &cancellables) - service.$history.map { $0 != .empty } - .sink(receiveValue: set(\.canRevert)).store(in: &cancellables) - - onCancelTapped = { [cancellables] in - _ = cancellables - service.stopResponding() - onClosePromptToCode() - } - - onRevertTapped = { - service.revert() - } - - onRequirementSent = { [weak self] requirement in - Task { [weak self] in - do { - try await service.modifyCode(prompt: requirement) - } catch is CancellationError { - return - } catch { - Task { @MainActor [weak self] in - self?.errorMessage = error.localizedDescription - } - } - } - } - - onStopRespondingTap = { - service.stopResponding() - } - - onAcceptSuggestionTapped = { [weak self] in - Task { [weak self] in - let handler = PseudoCommandHandler() - await handler.acceptSuggestion() - if let app = ActiveApplicationMonitor.shared.previousApp, - app.isXcode, - !(self?.isContinuous ?? false) - { - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() - } - } - } - - onContinuousToggleClick = { - service.isContinuous.toggle() - } - } - - func set(_ keyPath: WritableKeyPath) -> (T) -> Void { - return { [weak self] value in - Task { @MainActor [weak self] in - self?[keyPath: keyPath] = value - } - } - } -} - diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index edd72bd2..a6517f51 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -9,81 +9,7 @@ import SuggestionModel import SuggestionWidget @MainActor -final class WidgetDataSource { - final class PromptToCode { - let promptToCodeService: PromptToCodeService - let provider: PromptToCodeProvider - public init( - promptToCodeService: PromptToCodeService, - provider: PromptToCodeProvider - ) { - self.promptToCodeService = promptToCodeService - self.provider = provider - } - } - - private(set) var promptToCodes = [URL: PromptToCode]() - - init() {} - - @discardableResult - func createPromptToCode( - for url: URL, - projectURL: URL, - selectedCode: String, - allCode: String, - selectionRange: CursorRange, - language: CodeLanguage, - identSize: Int = 4, - usesTabsForIndentation: Bool = false, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool?, - name: String? - ) async -> PromptToCodeService { - let build = { - let service = PromptToCodeService( - code: selectedCode, - selectionRange: selectionRange, - language: language, - identSize: identSize, - usesTabsForIndentation: usesTabsForIndentation, - projectRootURL: projectURL, - fileURL: url, - allCode: allCode, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescriptionRequirement - ) - let provider = PromptToCodeProvider( - service: service, - name: name, - onClosePromptToCode: { [weak self] in - self?.removePromptToCode(for: url) - let presenter = PresentInWindowSuggestionPresenter() - presenter.closePromptToCode(fileURL: url) - if let app = ActiveApplicationMonitor.shared.previousApp, app.isXcode { - Task { @MainActor in - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() - } - } - } - ) - return PromptToCode(promptToCodeService: service, provider: provider) - } - - let newPromptToCode = build() - promptToCodes[url] = newPromptToCode - return newPromptToCode.promptToCodeService - } - - func removePromptToCode(for url: URL) { - promptToCodes[url] = nil - } - - func cleanup(for url: URL) { - removePromptToCode(for: url) - } -} +final class WidgetDataSource {} extension WidgetDataSource: SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> SuggestionProvider? { @@ -138,9 +64,5 @@ extension WidgetDataSource: SuggestionWidgetDataSource { } return nil } - - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { - return promptToCodes[url]?.provider - } } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 1f971e05..35a86543 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -17,7 +17,7 @@ public final class ScheduledCleaner { self.workspacePool = workspacePool self.guiController = guiController } - + func start() { // occasionally cleanup workspaces. Task { @ServiceActor in @@ -58,9 +58,13 @@ public final class ScheduledCleaner { for (url, workspace) in workspacePool.workspaces { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") - for url in workspace.filespaces.keys { - await guiController.widgetDataSource.cleanup(for: url) - } + _ = await Task { @MainActor in + guiController.viewStore.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: Array( + workspace.filespaces.keys + ))) + ) + }.result await workspace.cleanUp(availableTabs: []) workspacePool.removeWorkspace(url: url) } else { @@ -74,7 +78,11 @@ public final class ScheduledCleaner { availableTabs: tabs ) { Logger.service.info("Remove idle filespace") - await guiController.widgetDataSource.cleanup(for: url) + _ = await Task { @MainActor in + guiController.viewStore.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: [url])) + ) + }.result } } // cleanup workspace diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index e40f8261..99a2e269 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -161,6 +161,45 @@ struct PseudoCommandHandler { } } + func acceptPromptToCode() async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + try await Environment.triggerAction("Accept Prompt to Code") + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = application.focusedElement, + focusElement.description == "Source Editor" + else { return } + guard let (content, lines, _, cursorPosition) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") + return + } + let handler = WindowBaseCommandHandler() + do { + guard let result = try await handler.acceptPromptToCode(editor: .init( + content: content, + lines: lines, + uti: "", + cursorPosition: cursorPosition, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) else { return } + + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) + } catch { + PresentInWindowSuggestionPresenter().presentError(error) + } + } + } + func acceptSuggestion() async { do { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { @@ -174,12 +213,7 @@ struct PseudoCommandHandler { guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" else { return } - guard let ( - content, - lines, - _, - cursorPosition - ) = await getFileContent(sourceEditor: nil) + guard let (content, lines, _, cursorPosition) = await getFileContent(sourceEditor: nil) else { PresentInWindowSuggestionPresenter() .presentErrorMessage("Unable to get file content.") @@ -198,52 +232,7 @@ struct PseudoCommandHandler { usesTabsForIndentation: false )) else { return } - let oldPosition = focusElement.selectedTextRange - let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue - - let error = AXUIElementSetAttributeValue( - focusElement, - kAXValueAttribute as CFString, - result.content as CFTypeRef - ) - - if error != AXError.success { - PresentInWindowSuggestionPresenter() - .presentErrorMessage("Fail to set editor content.") - } - - if let selection = result.newSelection { - var range = convertCursorRangeToRange(selection, in: result.content) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } - } else if let oldPosition { - var range = CFRange( - location: oldPosition.lowerBound, - length: 0 - ) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } - } - - if let oldScrollPosition, - let scrollBar = focusElement.parent?.verticalScrollBar - { - AXUIElementSetAttributeValue( - scrollBar, - kAXValueAttribute as CFString, - oldScrollPosition as CFTypeRef - ) - } + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) } catch { PresentInWindowSuggestionPresenter().presentError(error) } @@ -252,6 +241,64 @@ struct PseudoCommandHandler { } extension PseudoCommandHandler { + /// When Xcode commands are not available, we can fallback to directly + /// set the value of the editor with Accessibility API. + func injectUpdatedCodeWithAccessibilityAPI( + _ result: UpdatedContent, + focusElement: AXUIElement + ) throws { + let oldPosition = focusElement.selectedTextRange + let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue + + let error = AXUIElementSetAttributeValue( + focusElement, + kAXValueAttribute as CFString, + result.content as CFTypeRef + ) + + if error != AXError.success { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Fail to set editor content.") + } + + // recover selection range + + if let selection = result.newSelection { + var range = convertCursorRangeToRange(selection, in: result.content) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } else if let oldPosition { + var range = CFRange( + location: oldPosition.lowerBound, + length: 0 + ) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } + + // recover scroll position + + if let oldScrollPosition, + let scrollBar = focusElement.parent?.verticalScrollBar + { + AXUIElementSetAttributeValue( + scrollBar, + kAXValueAttribute as CFString, + oldScrollPosition as CFTypeRef + ) + } + } + func getFileContent(sourceEditor: AXUIElement?) async -> ( content: String, diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index aac57389..35eec4fb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -13,6 +13,8 @@ protocol SuggestionCommandHandler { @ServiceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 5707ed32..f7cd2227 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -136,14 +136,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let dataSource = Service.shared.guiController.widgetDataSource - - if await dataSource.promptToCodes[fileURL]?.promptToCodeService != nil { - await dataSource.removePromptToCode(for: fileURL) - presenter.closePromptToCode(fileURL: fileURL) - return - } - let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) @@ -164,56 +156,93 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - let dataSource = Service.shared.guiController.widgetDataSource - - if let service = await dataSource.promptToCodes[fileURL]?.promptToCodeService { - let suggestion = CodeSuggestion( - text: service.code, - position: service.selectionRange.start, - uuid: UUID().uuidString, - range: service.selectionRange, - displayText: service.code - ) - + if let acceptedSuggestion = workspace.acceptSuggestion( + forFileAt: fileURL, + editor: editor + ) { injector.acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursorPosition, - completion: suggestion, + completion: acceptedSuggestion, extraInfo: &extraInfo ) - if service.isContinuous { - service.selectionRange = .init( - start: service.selectionRange.start, - end: cursorPosition - ) - presenter.presentPromptToCode(fileURL: fileURL) - } else { - await dataSource.removePromptToCode(for: fileURL) - presenter.closePromptToCode(fileURL: fileURL) - } + presenter.discardSuggestion(fileURL: fileURL) return .init( content: String(lines.joined(separator: "")), - newSelection: .init(start: service.selectionRange.start, end: cursorPosition), + newSelection: .cursor(cursorPosition), modifications: extraInfo.modifications ) - } else if let acceptedSuggestion = workspace.acceptSuggestion( - forFileAt: fileURL, - editor: editor - ) { + } + + return nil + } + + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { + presenter.markAsProcessing(true) + defer { presenter.markAsProcessing(false) } + + let fileURL = try await Environment.fetchCurrentFileURL() + + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + let viewStore = Service.shared.guiController.viewStore + + if let promptToCode = viewStore.state.promptToCodeGroup.activePromptToCode { + if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL { + return nil + } + + let range = { + if promptToCode.isAttachedToSelectionRange, + let range = promptToCode.selectionRange + { + return range + } + return editor.selections.first.map { + CursorRange(start: $0.start, end: $0.end) + } ?? CursorRange( + start: editor.cursorPosition, + end: editor.cursorPosition + ) + }() + + let suggestion = CodeSuggestion( + text: promptToCode.code, + position: range.start, + uuid: UUID().uuidString, + range: range, + displayText: promptToCode.code + ) + injector.acceptSuggestion( intoContentWithoutSuggestion: &lines, cursorPosition: &cursorPosition, - completion: acceptedSuggestion, + completion: suggestion, extraInfo: &extraInfo ) - presenter.discardSuggestion(fileURL: fileURL) + _ = await Task { @MainActor [cursorPosition] in + viewStore.send( + .promptToCodeGroup(.updatePromptToCodeRange( + id: promptToCode.id, + range: .init(start: range.start, end: cursorPosition) + )) + ) + viewStore.send( + .promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous( + id: promptToCode.id + )) + ) + }.result return .init( content: String(lines.joined(separator: "")), - newSelection: .cursor(cursorPosition), + newSelection: .init(start: range.start, end: cursorPosition), modifications: extraInfo.modifications ) } @@ -335,7 +364,7 @@ extension WindowBaseCommandHandler { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, _) = try await Service.shared.workspacePool + let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else { presenter.presentErrorMessage("Prompt to code is disabled for this project") @@ -378,26 +407,25 @@ extension WindowBaseCommandHandler { ) }() as (String, CursorRange) - let dataSource = Service.shared.guiController.widgetDataSource - - let promptToCode = await dataSource.createPromptToCode( - for: fileURL, - projectURL: workspace.projectRootURL, - selectedCode: code, - allCode: editor.content, - selectionRange: selection, - language: codeLanguage, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescription, - name: name - ) - - promptToCode.isContinuous = isContinuous - if let prompt, !prompt.isEmpty { - Task { try await promptToCode.modifyCode(prompt: prompt) } - } - - presenter.presentPromptToCode(fileURL: fileURL) + let viewStore = Service.shared.guiController.viewStore + + _ = await Task { @MainActor in + viewStore.send(.promptToCodeGroup(.createPromptToCode(.init( + code: code, + selectionRange: selection, + language: codeLanguage, + identSize: filespace.codeMetadata.indentSize ?? 4, + usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, + documentURL: fileURL, + projectRootURL: workspace.projectRootURL, + allCode: editor.content, + isContinuous: isContinuous, + commandName: name, + defaultPrompt: prompt ?? "", + extraSystemPrompt: extraSystemPrompt, + generateDescriptionRequirement: generateDescription + )))) + }.result } func executeSingleRoundDialog( diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 9215551f..e2568f9f 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -55,19 +55,5 @@ struct PresentInWindowSuggestionPresenter { controller.presentChatRoom() } } - - func presentPromptToCode(fileURL: URL) { - Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.presentPromptToCode() - } - } - - func closePromptToCode(fileURL: URL) { - Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.discardPromptToCode() - } - } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 07af3b57..3e5d8d06 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -102,6 +102,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.acceptSuggestion(editor: editor) } } + + public func getPromptToCodeAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptPromptToCode(editor: editor) + } + } public func getRealtimeSuggestedCode( editorContent: Data, diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift new file mode 100644 index 00000000..40b58c94 --- /dev/null +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift @@ -0,0 +1,47 @@ +import Foundation +import GitHubCopilotService + +func migrateFromLowerThanOrEqualToVersion135() throws { + // 0. Create the application support folder if it doesn't exist + + let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() + + // 1. Move the undefined folder in application support into a sub folder called `GitHub + // Copilot/support` + + let undefinedFolderURL = urls.applicationSupportURL.appendingPathComponent("undefined") + var isUndefinedADirectory: ObjCBool = false + let isUndefinedExisted = FileManager.default.fileExists( + atPath: undefinedFolderURL.path, + isDirectory: &isUndefinedADirectory + ) + if isUndefinedExisted, isUndefinedADirectory.boolValue { + try FileManager.default.moveItem( + at: undefinedFolderURL, + to: urls.supportURL.appendingPathComponent("undefined") + ) + } + + // 2. Copy the GitHub copilot language service to `GitHub Copilot/executable` + + let copilotFolderURL = urls.executableURL.appendingPathComponent("copilot") + var copilotIsFolder: ObjCBool = false + let executable = Bundle.main.resourceURL?.appendingPathComponent("copilot") + if let executable, + FileManager.default.fileExists(atPath: executable.path, isDirectory: &copilotIsFolder), + !FileManager.default.fileExists(atPath: copilotFolderURL.path) + { + try FileManager.default.copyItem( + at: executable, + to: urls.executableURL.appendingPathComponent("copilot") + ) + } + + // 3. Use chmod to change the permission of the executable to 755 + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: copilotFolderURL.path + ) +} + diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift new file mode 100644 index 00000000..b926b482 --- /dev/null +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift @@ -0,0 +1,116 @@ +import AIModel +import Foundation +import Keychain +import Preferences + +func migrateTo240( + defaults: UserDefaults = .shared, + keychain: KeychainType = Keychain.apiKey +) throws { + let finishedMigrationKey = "MigrateTo240Finished" + if defaults.bool(forKey: finishedMigrationKey) { return } + + do { + let chatModelOpenAIId = UUID().uuidString + let chatModelAzureOpenAIId = UUID().uuidString + let embeddingModelOpenAIId = UUID().uuidString + let embeddingModelAzureOpenAIId = UUID().uuidString + + let openAIAPIKeyName = "OpenAI" + let openAIAPIKey = defaults.deprecatedValue(for: \.openAIAPIKey) + if !openAIAPIKey.isEmpty { + try keychain.update(openAIAPIKey, key: openAIAPIKeyName) + } + + let azureOpenAIAPIKeyName = "Azure OpenAI" + let azureOpenAIAPIKey = defaults.deprecatedValue(for: \.azureOpenAIAPIKey) + if !azureOpenAIAPIKey.isEmpty { + try keychain.update(azureOpenAIAPIKey, key: azureOpenAIAPIKeyName) + } + + defaults.set({ + let openAIModel = ChatGPTModel(rawValue: defaults.deprecatedValue(for: \.chatGPTModel)) + + let openAI = ChatModel( + id: chatModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? defaults + .deprecatedValue(for: \.chatGPTMaxToken), + modelName: openAIModel?.rawValue ?? defaults + .deprecatedValue(for: \.chatGPTModel) + ) + ) + let azureOpenAI = ChatModel( + id: chatModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: defaults.deprecatedValue(for: \.chatGPTMaxToken), + modelName: defaults + .deprecatedValue(for: \.azureChatGPTDeployment) + ) + ) + + return [openAI, azureOpenAI] + }(), for: \.chatModels) + + defaults.set({ + if defaults.deprecatedValue(for: \.chatFeatureProvider) == .azureOpenAI { + return chatModelAzureOpenAIId + } + return chatModelOpenAIId + }(), for: \.defaultChatFeatureChatModelId) + + defaults.set({ + let openAIModel = OpenAIEmbeddingModel( + rawValue: defaults.deprecatedValue(for: \.embeddingModel) + ) + + let openAI = EmbeddingModel( + id: embeddingModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? 8191, + modelName: openAIModel?.rawValue ?? defaults.deprecatedValue(for: \.embeddingModel) + ) + ) + + let azureOpenAI = EmbeddingModel( + id: embeddingModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: 8191, + modelName: defaults + .deprecatedValue(for: \.azureEmbeddingDeployment) + ) + ) + + return [openAI, azureOpenAI] + }(), for: \.embeddingModels) + + defaults.set({ + if defaults.deprecatedValue(for: \.embeddingFeatureProvider) == .azureOpenAI { + return embeddingModelAzureOpenAIId + } + return embeddingModelOpenAIId + }(), for: \.defaultChatFeatureEmbeddingModelId) + + defaults.set(true, forKey: finishedMigrationKey) + } catch { + print(error.localizedDescription) + throw error + } +} + diff --git a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift index b47a4feb..6da2bcf3 100644 --- a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift +++ b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift @@ -1,6 +1,5 @@ import Configs import Foundation -import GitHubCopilotService import Preferences extension UserDefaultPreferenceKeys { @@ -23,54 +22,12 @@ public struct ServiceUpdateMigrator { } func migrate(from oldVersion: String, to currentVersion: String) async throws { - guard let old = Int(oldVersion) else { return } + guard let old = Int(oldVersion), old != 0 else { return } if old <= 135 { try migrateFromLowerThanOrEqualToVersion135() } + if old < 240 { + try migrateTo240() + } } } - -func migrateFromLowerThanOrEqualToVersion135() throws { - // 0. Create the application support folder if it doesn't exist - - let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() - - // 1. Move the undefined folder in application support into a sub folder called `GitHub - // Copilot/support` - - let undefinedFolderURL = urls.applicationSupportURL.appendingPathComponent("undefined") - var isUndefinedADirectory: ObjCBool = false - let isUndefinedExisted = FileManager.default.fileExists( - atPath: undefinedFolderURL.path, - isDirectory: &isUndefinedADirectory - ) - if isUndefinedExisted, isUndefinedADirectory.boolValue { - try FileManager.default.moveItem( - at: undefinedFolderURL, - to: urls.supportURL.appendingPathComponent("undefined") - ) - } - - // 2. Copy the GitHub copilot language service to `GitHub Copilot/executable` - - let copilotFolderURL = urls.executableURL.appendingPathComponent("copilot") - var copilotIsFolder: ObjCBool = false - let executable = Bundle.main.resourceURL?.appendingPathComponent("copilot") - if let executable, - FileManager.default.fileExists(atPath: executable.path, isDirectory: &copilotIsFolder), - !FileManager.default.fileExists(atPath: copilotFolderURL.path) - { - try FileManager.default.copyItem( - at: executable, - to: urls.executableURL.appendingPathComponent("copilot") - ) - } - - // 3. Use chmod to change the permission of the executable to 755 - - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: copilotFolderURL.path - ) -} - diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index 8558d16d..a84a2c74 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -174,72 +174,13 @@ public struct SuggestionInjector { ) } - // appending suffix text not in range if needed. - let leftoverCount: Int = { - let maxCount = lastRemovedLine?.count ?? 0 - guard let first = toBeInserted.first? - .dropLast((toBeInserted.first?.hasSuffix("\n") ?? false) ? 1 : 0), - !first.isEmpty else { return maxCount } - guard let last = toBeInserted.last? - .dropLast((toBeInserted.last?.hasSuffix("\n") ?? false) ? 1 : 0), - !last.isEmpty else { return maxCount } - let droppedLast = lastRemovedLine? - .dropLast((lastRemovedLine?.hasSuffix("\n") ?? false) ? 1 : 0) - guard let droppedLast, !droppedLast.isEmpty else { return maxCount } - // case 1: user keeps typing as the suggestion suggests. - if first.hasPrefix(droppedLast) { - return 0 - } - // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) - if end.character < droppedLast.count { - // locate the split index, the prefix of which matches the suggestion prefix. - var splitIndex: String.Index? = nil - for offset in end.character.. 0, - !lastRemovedLine.isEmptyOrNewLine, - end.character >= 0, - end.character - 1 < lastRemovedLine.count, - !toBeInserted.isEmpty - { - let leftoverRange = (lastRemovedLine.index( - lastRemovedLine.startIndex, - offsetBy: end.character, - limitedBy: lastRemovedLine.endIndex - ) ?? lastRemovedLine.endIndex).. Int { + // If there is no line removed, there is no need to recover anything. + guard let lastRemovedLine, !lastRemovedLine.isEmptyOrNewLine else { return 0 } + + let lastRemovedLineCleaned = lastRemovedLine.droppedLineBreak() + + // If the replaced range covers the whole line, return immediately. + guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.count else { return 0 } + + // if we are not inserting anything, return immediately. + guard !toBeInserted.isEmpty, + let first = toBeInserted.first?.droppedLineBreak(), !first.isEmpty, + let last = toBeInserted.last?.droppedLineBreak(), !last.isEmpty + else { return 0 } + + // case 1: user keeps typing as the suggestion suggests. + + if first.hasPrefix(lastRemovedLineCleaned) { + return 0 + } + + // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) + + // locate the split index, the prefix of which matches the suggestion prefix. + var splitIndex: String.Index? + + for offset in end.character..` + + let regex = try! NSRegularExpression(pattern: "\\s*?<#.*?#>") + + if let firstPlaceholderRange = regex.firstMatch( + in: suffix, + options: [], + range: NSRange(suffix.startIndex..., in: suffix) + )?.range, + firstPlaceholderRange.location == 0, + let r = Range(firstPlaceholderRange, in: suffix) + { + suffix.removeSubrange(r) + } + + let lastInsertingLine = toBeInserted[toBeInserted.endIndex - 1] + .droppedLineBreak() + .appending(suffix) + .recoveredLineBreak() + + toBeInserted[toBeInserted.endIndex - 1] = lastInsertingLine + + return suffix.count + } } public struct SuggestionAnalyzer { @@ -284,6 +297,20 @@ extension String { var isEmptyOrNewLine: Bool { isEmpty || self == "\n" } + + func droppedLineBreak() -> String { + if hasSuffix("\n") { + return String(dropLast(1)) + } + return self + } + + func recoveredLineBreak() -> String { + if hasSuffix("\n") { + return self + } + return self + "\n" + } } func longestCommonPrefix(of a: String, and b: String) -> String { diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index b69cb619..0b610bae 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -175,18 +175,19 @@ struct ChatTabBar: View { ScrollView(.horizontal) { HStack(spacing: 0) { ForEach(viewStore.state.tabInfo, id: \.id) { info in - ChatTabBarButton( - store: store, - info: info, - isSelected: info.id == viewStore.state.selectedTabId - ) - .id(info.id) - .contextMenu { - if let tab = chatTabPool.getTab(of: info.id) { + if let tab = chatTabPool.getTab(of: info.id) { + ChatTabBarButton( + store: store, + info: info, + content: { tab.tabItem }, + isSelected: info.id == viewStore.state.selectedTabId + ) + .contextMenu { tab.menu - } else { - EmptyView() } + .id(info.id) + } else { + EmptyView() } } } @@ -263,9 +264,10 @@ struct ChatTabBar: View { } } -struct ChatTabBarButton: View { +struct ChatTabBarButton: View { let store: StoreOf let info: ChatTabInfo + let content: () -> Content let isSelected: Bool @State var isHovered: Bool = false @@ -274,7 +276,7 @@ struct ChatTabBarButton: View { Button(action: { store.send(.tabClicked(id: info.id)) }) { - Text(info.title) + content() .font(.callout) .lineLimit(1) .frame(maxWidth: 120) @@ -360,60 +362,8 @@ struct CreateOtherChatTabMenuStyle: MenuStyle { } } -class FakeChatTab: ChatTab { - static var name: String { "Fake" } - static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { [Builder()] } - - struct Builder: ChatTabBuilder { - var title: String = "Title" - - func build(store: StoreOf) async -> (any ChatTab)? { - return FakeChatTab(store: store) - } - } - - func buildMenu() -> any View { - Text("Menu Item") - Text("Menu Item") - Text("Menu Item") - } - - func buildView() -> any View { - ChatPanel( - chat: .init( - history: [ - .init(id: "1", role: .assistant, text: "Hello World"), - ], - isReceivingMessage: false - ), - typedMessage: "Hello World!" - ) - } - - func restorableState() async -> Data { - return Data() - } - - static func restore( - from data: Data, - externalDependency: () - ) async throws -> any ChatTabBuilder { - return Builder() - } - - convenience init(id: String, title: String) { - self.init(store: .init( - initialState: .init(id: id, title: title), - reducer: ChatTabItem() - )) - } - - func start() {} -} - struct ChatWindowView_Previews: PreviewProvider { static let pool = ChatTabPool([ - "1": FakeChatTab(id: "1", title: "Hello I am a chatbot"), "2": EmptyChatTab(id: "2"), "3": EmptyChatTab(id: "3"), "4": EmptyChatTab(id: "4"), @@ -422,30 +372,31 @@ struct ChatWindowView_Previews: PreviewProvider { "7": EmptyChatTab(id: "7"), ]) - static var previews: some View { - ChatWindowView( - store: .init( - initialState: .init( - chatTabGroup: .init( - tabInfo: [ - .init(id: "1", title: "Fake"), - .init(id: "2", title: "Empty-2"), - .init(id: "3", title: "Empty-3"), - .init(id: "4", title: "Empty-4"), - .init(id: "5", title: "Empty-5"), - .init(id: "6", title: "Empty-6"), - .init(id: "7", title: "Empty-7"), - ], - selectedTabId: "1" - ), - isPanelDisplayed: true + static func createStore() -> StoreOf { + StoreOf( + initialState: .init( + chatTabGroup: .init( + tabInfo: [ + .init(id: "2", title: "Empty-2"), + .init(id: "3", title: "Empty-3"), + .init(id: "4", title: "Empty-4"), + .init(id: "5", title: "Empty-5"), + .init(id: "6", title: "Empty-6"), + .init(id: "7", title: "Empty-7"), + ], + selectedTabId: "2" ), - reducer: ChatPanelFeature() - ) + isPanelDisplayed: true + ), + reducer: ChatPanelFeature() ) - .xcodeStyleFrame() - .padding() - .environment(\.chatTabPool, pool) + } + + static var previews: some View { + ChatWindowView(store: createStore()) + .xcodeStyleFrame() + .padding() + .environment(\.chatTabPool, pool) } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 5c2387c4..df6fbdb7 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -4,11 +4,11 @@ import Foundation public struct PanelFeature: ReducerProtocol { public struct State: Equatable { - var content: SharedPanelFeature.Content? { - get { sharedPanelState.content ?? suggestionPanelState.content } + public var content: SharedPanelFeature.Content { + get { sharedPanelState.content } set { sharedPanelState.content = newValue - suggestionPanelState.content = newValue + suggestionPanelState.content = newValue.suggestion } } @@ -23,10 +23,11 @@ public struct PanelFeature: ReducerProtocol { public enum Action: Equatable { case presentSuggestion + case presentSuggestionProvider(SuggestionProvider, displayContent: Bool) case presentError(String) - case presentPromptToCode - case presentPanelContent(SharedPanelFeature.Content, shouldDisplay: Bool) - case discardPanelContent + case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) + case displayPanelContent + case discardSuggestion case removeDisplayedContent case switchToAnotherEditorAndUpdateContent @@ -54,93 +55,81 @@ public struct PanelFeature: ReducerProtocol { 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)) + await send(.presentSuggestionProvider(provider, displayContent: true)) + } + + case let .presentSuggestionProvider(provider, displayContent): + state.content.suggestion = provider + if displayContent { + return .run { send in + await send(.displayPanelContent) + }.animation(.easeInOut(duration: 0.2)) + } + return .none case let .presentError(errorDescription): + state.content.error = errorDescription return .run { send in - let content = SharedPanelFeature.Content.error(errorDescription) - await send(.presentPanelContent(content, shouldDisplay: true)) + await send(.displayPanelContent) }.animation(.easeInOut(duration: 0.2)) - case .presentPromptToCode: + case let .presentPromptToCode(initialState): 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 } + await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode(initialState)))) + } - 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: + case .displayPanelContent: + if !state.sharedPanelState.isEmpty { state.sharedPanelState.isPanelDisplayed = true } + if state.suggestionPanelState.content != nil { + state.suggestionPanelState.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 .discardSuggestion: + state.content.suggestion = nil + return .none case .switchToAnotherEditorAndUpdateContent: + state.content.error = nil 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) + if let suggestion = await fetchSuggestionProvider(fileURL: fileURL) { + await send(.presentSuggestionProvider(suggestion, displayContent: false)) } + + await send(.sharedPanel( + .promptToCodeGroup( + .updateActivePromptToCode(documentURL: fileURL) + ) + )) } case .removeDisplayedContent: - state.content = nil + state.content.error = nil + state.content.promptToCodeGroup.activePromptToCode = nil + state.content.suggestion = nil return .none + case .sharedPanel(.promptToCodeGroup(.createPromptToCode)): + let hasPromptToCode = state.content.promptToCode != nil + return .run { send in + await send(.displayPanelContent) + + if hasPromptToCode { + // 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 .sharedPanel: return .none + case .suggestionPanel: return .none } @@ -153,12 +142,5 @@ public struct PanelFeature: ReducerProtocol { .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/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift new file mode 100644 index 00000000..bfac06d0 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -0,0 +1,258 @@ +import AppKit +import ComposableArchitecture +import Dependencies +import Foundation +import PromptToCodeService +import SuggestionModel + +public struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { + public static let liveValue: (PromptToCode.State) -> Void = { _ in + assertionFailure("Please provide a handler") + } + + public static let previewValue: (PromptToCode.State) -> Void = { _ in + print("Accept Prompt to Code") + } +} + +public extension DependencyValues { + var promptToCodeAcceptHandler: (PromptToCode.State) -> Void { + get { self[PromptToCodeAcceptHandlerDependencyKey.self] } + set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue } + } +} + +public struct PromptToCode: ReducerProtocol { + public struct State: Equatable, Identifiable { + public indirect enum HistoryNode: Equatable { + case empty + case node(code: String, description: String, previous: HistoryNode) + + mutating func enqueue(code: String, description: String) { + let current = self + self = .node(code: code, description: description, previous: current) + } + + mutating func pop() -> (code: String, description: String)? { + switch self { + case .empty: + return nil + case let .node(code, description, previous): + self = previous + return (code, description) + } + } + } + + public var id: URL { documentURL } + public var history: HistoryNode + public var code: String + public var isResponding: Bool + public var description: String + public var error: String? + public var selectionRange: CursorRange? + public var language: CodeLanguage + public var indentSize: Int + public var usesTabsForIndentation: Bool + public var projectRootURL: URL + public var documentURL: URL + public var allCode: String + public var extraSystemPrompt: String? + public var generateDescriptionRequirement: Bool? + public var commandName: String? + @BindingState public var prompt: String + @BindingState public var isContinuous: Bool + @BindingState public var isAttachedToSelectionRange: Bool + + public var filename: String { documentURL.lastPathComponent } + public var canRevert: Bool { history != .empty } + + public init( + code: String, + prompt: String, + language: CodeLanguage, + indentSize: Int, + usesTabsForIndentation: Bool, + projectRootURL: URL, + documentURL: URL, + allCode: String, + commandName: String? = nil, + description: String = "", + isResponding: Bool = false, + isAttachedToSelectionRange: Bool = true, + error: String? = nil, + history: HistoryNode = .empty, + isContinuous: Bool = false, + selectionRange: CursorRange? = nil, + extraSystemPrompt: String? = nil, + generateDescriptionRequirement: Bool? = nil + ) { + self.history = history + self.code = code + self.prompt = prompt + self.isResponding = isResponding + self.description = description + self.error = error + self.isContinuous = isContinuous + self.selectionRange = selectionRange + self.language = language + self.indentSize = indentSize + self.usesTabsForIndentation = usesTabsForIndentation + self.projectRootURL = projectRootURL + self.documentURL = documentURL + self.allCode = allCode + self.extraSystemPrompt = extraSystemPrompt + self.generateDescriptionRequirement = generateDescriptionRequirement + self.isAttachedToSelectionRange = isAttachedToSelectionRange + self.commandName = commandName + + if selectionRange?.isEmpty ?? true { + self.isAttachedToSelectionRange = false + } + } + } + + public enum Action: Equatable, BindableAction { + case binding(BindingAction) + case selectionRangeToggleTapped + case modifyCodeButtonTapped + case revertButtonTapped + case stopRespondingButtonTapped + case modifyCodeFinished + case modifyCodeTrunkReceived(code: String, description: String) + case modifyCodeFailed(error: String) + case modifyCodeCancelled + case cancelButtonTapped + case acceptButtonTapped + case copyCodeButtonTapped + case appendNewLineToPromptButtonTapped + } + + @Dependency(\.promptToCodeService) var promptToCodeService + @Dependency(\.promptToCodeAcceptHandler) var promptToCodeAcceptHandler + + enum CancellationKey: Hashable { + case modifyCode(State.ID) + } + + public var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .selectionRangeToggleTapped: + state.isAttachedToSelectionRange.toggle() + return .none + + case .modifyCodeButtonTapped: + guard !state.isResponding else { return .none } + let copiedState = state + state.history.enqueue(code: state.code, description: state.description) + state.isResponding = true + state.code = "" + state.description = "" + state.error = nil + + return .run { send in + do { + let stream = try await promptToCodeService.modifyCode( + code: copiedState.code, + requirement: copiedState.prompt, + source: .init( + language: copiedState.language, + documentURL: copiedState.documentURL, + projectRootURL: copiedState.projectRootURL, + allCode: copiedState.allCode, + range: copiedState.selectionRange ?? .outOfScope + ), + isDetached: !copiedState.isAttachedToSelectionRange, + extraSystemPrompt: copiedState.extraSystemPrompt, + generateDescriptionRequirement: copiedState + .generateDescriptionRequirement + ) + for try await fragment in stream { + try Task.checkCancellation() + await send(.modifyCodeTrunkReceived( + code: fragment.code, + description: fragment.description + )) + } + try Task.checkCancellation() + await send(.modifyCodeFinished) + } catch is CancellationError { + try Task.checkCancellation() + await send(.modifyCodeCancelled) + } catch { + try Task.checkCancellation() + if (error as NSError).code == NSURLErrorCancelled { + await send(.modifyCodeCancelled) + return + } + + await send(.modifyCodeFailed(error: error.localizedDescription)) + } + }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true) + + case .revertButtonTapped: + guard let (code, description) = state.history.pop() else { return .none } + state.code = code + state.description = description + return .none + + case .stopRespondingButtonTapped: + state.isResponding = false + promptToCodeService.stopResponding() + return .none + + case let .modifyCodeTrunkReceived(code, description): + state.code = code + state.description = description + return .none + + case .modifyCodeFinished: + state.prompt = "" + state.isResponding = false + if state.code.isEmpty, state.description.isEmpty { + // if both code and description are empty, we treat it as failed + return .run { send in + await send(.revertButtonTapped) + } + } + + return .none + + case let .modifyCodeFailed(error): + state.error = error + state.isResponding = false + return .run { send in + await send(.revertButtonTapped) + } + + case .modifyCodeCancelled: + state.isResponding = false + return .none + + case .cancelButtonTapped: + promptToCodeService.stopResponding() + return .none + + case .acceptButtonTapped: + promptToCodeAcceptHandler(state) + return .none + + case .copyCodeButtonTapped: + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(state.code, forType: .string) + return .none + + case .appendNewLineToPromptButtonTapped: + state.prompt += "\n" + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift new file mode 100644 index 00000000..d6a05d69 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -0,0 +1,148 @@ +import ComposableArchitecture +import Foundation +import PromptToCodeService +import SuggestionModel +import Environment + +public struct PromptToCodeGroup: ReducerProtocol { + public struct State: Equatable { + public var promptToCodes: IdentifiedArrayOf = [] + public var activeDocumentURL: PromptToCode.State.ID? + public var activePromptToCode: PromptToCode.State? { + get { + if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) { + return detached + } + guard let id = activeDocumentURL else { return nil } + return promptToCodes[id: id] + } + set { activeDocumentURL = newValue?.id } + } + } + + public struct PromptToCodeInitialState: Equatable { + public var code: String + public var selectionRange: CursorRange? + public var language: CodeLanguage + public var identSize: Int + public var usesTabsForIndentation: Bool + public var documentURL: URL + public var projectRootURL: URL + public var allCode: String + public var isContinuous: Bool + public var commandName: String? + public var defaultPrompt: String + public var extraSystemPrompt: String? + public var generateDescriptionRequirement: Bool? + + public init( + code: String, + selectionRange: CursorRange?, + language: CodeLanguage, + identSize: Int, + usesTabsForIndentation: Bool, + documentURL: URL, + projectRootURL: URL, + allCode: String, + isContinuous: Bool, + commandName: String?, + defaultPrompt: String, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) { + self.code = code + self.selectionRange = selectionRange + self.language = language + self.identSize = identSize + self.usesTabsForIndentation = usesTabsForIndentation + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.allCode = allCode + self.isContinuous = isContinuous + self.commandName = commandName + self.defaultPrompt = defaultPrompt + self.extraSystemPrompt = extraSystemPrompt + self.generateDescriptionRequirement = generateDescriptionRequirement + } + } + + public enum Action: Equatable { + case createPromptToCode(PromptToCodeInitialState) + case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange) + case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID) + case updateActivePromptToCode(documentURL: URL) + case discardExpiredPromptToCode(documentURLs: [URL]) + case promptToCode(PromptToCode.State.ID, PromptToCode.Action) + } + + @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory + + public var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case let .createPromptToCode(s): + let newPromptToCode = PromptToCode.State( + code: s.code, + prompt: s.defaultPrompt, + language: s.language, + indentSize: s.identSize, + usesTabsForIndentation: s.usesTabsForIndentation, + projectRootURL: s.projectRootURL, + documentURL: s.documentURL, + allCode: s.allCode, + commandName: s.commandName, + isContinuous: s.isContinuous, + selectionRange: s.selectionRange, + extraSystemPrompt: s.extraSystemPrompt, + generateDescriptionRequirement: s.generateDescriptionRequirement + ) + // insert at 0 so it has high priority then the other detached prompt to codes + state.promptToCodes.insert(newPromptToCode, at: 0) + return .run { send in + if !newPromptToCode.prompt.isEmpty { + await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped)) + } + }.cancellable( + id: PromptToCode.CancellationKey.modifyCode(newPromptToCode.id), + cancelInFlight: true + ) + + case let .updatePromptToCodeRange(id, range): + if let p = state.promptToCodes[id: id], p.isAttachedToSelectionRange { + state.promptToCodes[id: id]?.selectionRange = range + } + return .none + + case let .discardAcceptedPromptToCodeIfNotContinuous(id): + state.promptToCodes.removeAll { $0.id == id && !$0.isContinuous } + return .none + + case let .updateActivePromptToCode(documentURL): + state.activeDocumentURL = documentURL + return .none + + case let .discardExpiredPromptToCode(documentURLs): + for url in documentURLs { + state.promptToCodes.remove(id: url) + } + return .none + + case let .promptToCode(id, action): + switch action { + case .cancelButtonTapped: + state.promptToCodes.remove(id: id) + return .run { _ in + try await Environment.makeXcodeActive() + } + default: + return .none + } + } + } + .forEach(\.promptToCodes, action: /Action.promptToCode, element: { + PromptToCode() + .dependency(\.promptToCodeService, promptToCodeServiceFactory()) + }) + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift index 3ce2f637..32657955 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift @@ -4,51 +4,53 @@ 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 Content: Equatable { + public var promptToCodeGroup = PromptToCodeGroup.State() + var suggestion: SuggestionProvider? + public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode } + var error: String? } public struct State: Equatable { - var content: Content? + var content: Content = .init() var colorScheme: ColorScheme = .light var alignTopToAnchor = false var isPanelDisplayed: Bool = false + var isEmpty: Bool { + if content.error != nil { return false } + if content.promptToCode != nil { return false } + if content.suggestion != nil, + UserDefaults.shared + .value(for: \.suggestionPresentationMode) == .floatingWidget { return false } + return true + } + var opacity: Double { guard isPanelDisplayed else { return 0 } - guard content != nil else { return 0 } + guard !isEmpty else { return 0 } return 1 } } public enum Action: Equatable { - case closeButtonTapped + case errorMessageCloseButtonTapped + case promptToCodeGroup(PromptToCodeGroup.Action) } public var body: some ReducerProtocol { + Scope(state: \.content.promptToCodeGroup, action: /Action.promptToCodeGroup) { + PromptToCodeGroup() + } + Reduce { state, action in switch action { - case .closeButtonTapped: - state.content = nil - state.isPanelDisplayed = false + case .errorMessageCloseButtonTapped: + state.content.error = nil + return .none + case .promptToCodeGroup: return .none } } } } + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift index b253d69a..dbd4034e 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -4,7 +4,7 @@ import SwiftUI public struct SuggestionPanelFeature: ReducerProtocol { public struct State: Equatable { - var content: SharedPanelFeature.Content? + var content: SuggestionProvider? var colorScheme: ColorScheme = .light var alignTopToAnchor = false var isPanelDisplayed: Bool = false diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 42537284..d89dfeb5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -53,7 +53,7 @@ public struct WidgetFeature: ReducerProtocol { return true } if panelState.sharedPanelState.isPanelDisplayed, - panelState.sharedPanelState.content != nil + !panelState.sharedPanelState.isEmpty { return true } @@ -65,7 +65,7 @@ public struct WidgetFeature: ReducerProtocol { return false }(), isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty - && panelState.sharedPanelState.content == nil, + && panelState.sharedPanelState.isEmpty, isChatPanelDetached: chatPanelState.chatPanelInASeparateWindow, isChatOpen: chatPanelState.isPanelDisplayed, animationProgress: circularWidgetState.animationProgress @@ -79,6 +79,8 @@ public struct WidgetFeature: ReducerProtocol { ) } } + + var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) public init() {} } @@ -109,6 +111,7 @@ public struct WidgetFeature: ReducerProtocol { case updateWindowLocation(animated: Bool) case updateWindowOpacity case updateFocusingDocumentURL + case updateWindowOpacityFinished case panel(PanelFeature.Action) case chatPanel(ChatPanelFeature.Action) @@ -311,7 +314,7 @@ public struct WidgetFeature: ReducerProtocol { let documentURL = state.focusingDocumentURL - return .run { send in + return .run { [app] send in await send(.observeEditorChange) let notifications = AXNotificationStream( @@ -513,9 +516,12 @@ public struct WidgetFeature: ReducerProtocol { case .updateWindowOpacity: let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty - return .run { _ in - try await mainQueue.sleep(for: .seconds(0.2)) - Task { @MainActor in + let shouldDebounce = Date().timeIntervalSince(state.lastUpdateWindowOpacityTime) < 1 + return .run { send in + if shouldDebounce { + try await mainQueue.sleep(for: .seconds(0.2)) + } + let task = 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. @@ -564,8 +570,14 @@ public struct WidgetFeature: ReducerProtocol { } } } + _ = await task.value + await send(.updateWindowOpacityFinished) } .cancellable(id: DebounceKey.updateWindowOpacity, cancelInFlight: true) + + case .updateWindowOpacityFinished: + state.lastUpdateWindowOpacityTime = Date() + return .none case .circularWidget: return .none diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index 304b4620..0a38ee6e 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -75,9 +75,7 @@ struct ActiveApplicationMonitorKey: DependencyKey { } struct ChatTabBuilderCollectionKey: DependencyKey { - static let liveValue: () -> [ChatTabBuilderCollection] = { - [.folder(title: "A", kinds: FakeChatTab.chatBuilders().map(ChatTabKind.init))] - } + static let liveValue: () -> [ChatTabBuilderCollection] = { [] } } struct ActivatePreviouslyActiveXcodeKey: DependencyKey { @@ -135,3 +133,4 @@ extension DependencyValues { set { self[ActivateExtensionServiceKey.self] = newValue } } } + diff --git a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift b/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift deleted file mode 100644 index 63e7f3f7..00000000 --- a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import SwiftUI - -public final class PromptToCodeProvider: ObservableObject { - let id = UUID() - let name: String? - - @Published public var code: String - @Published public var language: String - @Published public var description: String - @Published public var isResponding: Bool - @Published public var startLineIndex: Int - @Published public var startLineColumn: Int - @Published public var requirement: String - @Published public var errorMessage: String - @Published public var canRevert: Bool - @Published public var isContinuous: Bool - - public var onRevertTapped: () -> Void - public var onStopRespondingTap: () -> Void - public var onCancelTapped: () -> Void - public var onAcceptSuggestionTapped: () -> Void - public var onRequirementSent: (String) -> Void - public var onContinuousToggleClick: () -> Void - - public init( - code: String = "", - language: String = "", - description: String = "", - isResponding: Bool = false, - startLineIndex: Int = 0, - startLineColumn: Int = 0, - requirement: String = "", - errorMessage: String = "", - canRevert: Bool = false, - isContinuous: Bool = false, - name: String? = nil, - onRevertTapped: @escaping () -> Void = {}, - onStopRespondingTap: @escaping () -> Void = {}, - onCancelTapped: @escaping () -> Void = {}, - onAcceptSuggestionTapped: @escaping () -> Void = {}, - onRequirementSent: @escaping (String) -> Void = { _ in }, - onContinuousToggleClick: @escaping () -> Void = {} - ) { - self.code = code - self.language = language - self.description = description - self.isResponding = isResponding - self.startLineIndex = startLineIndex - self.startLineColumn = startLineColumn - self.requirement = requirement - self.errorMessage = errorMessage - self.canRevert = canRevert - self.isContinuous = isContinuous - self.name = name - self.onRevertTapped = onRevertTapped - self.onStopRespondingTap = onStopRespondingTap - self.onCancelTapped = onCancelTapped - self.onAcceptSuggestionTapped = onAcceptSuggestionTapped - self.onRequirementSent = onRequirementSent - self.onContinuousToggleClick = onContinuousToggleClick - } - - func revert() { - onRevertTapped() - errorMessage = "" - } - func stopResponding() { - onStopRespondingTap() - errorMessage = "" - } - func cancel() { onCancelTapped() } - func sendRequirement() { - guard !isResponding else { return } - guard !requirement.isEmpty else { return } - onRequirementSent(requirement) - requirement = "" - errorMessage = "" - } - - func acceptSuggestion() { onAcceptSuggestionTapped() } - - func toggleContinuous() { onContinuousToggleClick() } -} diff --git a/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift index 559540c7..b821d4a8 100644 --- a/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift @@ -1,7 +1,11 @@ import Foundation import SwiftUI -public final class SuggestionProvider: ObservableObject { +public final class SuggestionProvider: ObservableObject, Equatable { + public static func == (lhs: SuggestionProvider, rhs: SuggestionProvider) -> Bool { + lhs.code == rhs.code && lhs.language == rhs.language + } + @Published public var code: String = "" @Published public var language: String = "" @Published public var startLineIndex: Int = 0 @@ -41,3 +45,4 @@ public final class SuggestionProvider: ObservableObject { func rejectSuggestion() { onRejectSuggestionTapped() } func acceptSuggestion() { onAcceptSuggestionTapped() } } + diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index bb039c9d..cf4bac14 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -27,7 +27,6 @@ struct SharedPanelView: View { var isPanelDisplayed: Bool var opacity: Double var colorScheme: ColorScheme - var contentHash: String var alignTopToAnchor: Bool } @@ -38,42 +37,44 @@ struct SharedPanelView: View { isPanelDisplayed: $0.isPanelDisplayed, opacity: $0.opacity, colorScheme: $0.colorScheme, - contentHash: $0.content?.contentHash ?? "", alignTopToAnchor: $0.alignTopToAnchor ) } ) { viewStore in VStack(spacing: 0) { - if !viewStore.alignTopToAnchor { + if !viewStore.state.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } - 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) - ) + WithViewStore(store, observe: { $0.content }) { viewStore in + ZStack(alignment: .topLeading) { + if let error = viewStore.state.error { + ErrorPanel(description: error) { + viewStore.send( + .errorMessageCloseButtonTapped, + animation: .easeInOut(duration: 0.2) + ) + } + } else if let promptToCode = viewStore.state.promptToCode { + PromptToCodePanel(store: store.scope( + state: { _ in promptToCode }, + action: { + SharedPanelFeature.Action + .promptToCodeGroup(.promptToCode(promptToCode.id, $0)) } + )) + } else if let suggestion = viewStore.state.suggestion { + switch suggestionPresentationMode { + case .nearbyTextCursor: + EmptyView() + case .floatingWidget: + CodeBlockSuggestionPanel(suggestion: suggestion) } } - .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) - .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) } .allowsHitTesting(viewStore.isPanelDisplayed) .frame(maxWidth: .infinity) @@ -97,7 +98,8 @@ struct SharedPanelView: View { } struct CommandButtonStyle: ButtonStyle { - let color: Color + var color: Color + var cornerRadius: Double = 4 func makeBody(configuration: Configuration) -> some View { configuration.label @@ -105,12 +107,12 @@ struct CommandButtonStyle: ButtonStyle { .padding(.horizontal, 8) .foregroundColor(.white) .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(color.opacity(configuration.isPressed ? 0.8 : 1)) .animation(.easeOut(duration: 0.1), value: configuration.isPressed) ) .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) } } @@ -118,11 +120,11 @@ struct CommandButtonStyle: ButtonStyle { // MARK: - Previews -struct SuggestionPanelView_Error_Preview: PreviewProvider { +struct SharedPanelView_Error_Preview: PreviewProvider { static var previews: some View { SharedPanelView(store: .init( initialState: .init( - content: .error("This is an error\nerror"), + content: .init(error: "This is an error\nerror"), colorScheme: .light, isPanelDisplayed: true ), @@ -132,12 +134,12 @@ struct SuggestionPanelView_Error_Preview: PreviewProvider { } } -struct SuggestionPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { +struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { static var previews: some View { SharedPanelView(store: .init( initialState: .init( - content: .suggestion( - SuggestionProvider( + content: .init( + suggestion: .init( code: """ - (void)addSubview:(UIView *)view { [self addSubview:view]; diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 6904ed5f..c43c396d 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -72,6 +72,12 @@ struct CodeBlockSuggestionPanel: View { }.buttonStyle(.plain) Spacer() + + Button(action: { + suggestion.rejectSuggestion() + }) { + Image(systemName: "xmark") + }.buttonStyle(.plain) } .padding(4) .font(.caption) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 46407166..b20190aa 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -1,78 +1,141 @@ +import ComposableArchitecture import MarkdownUI import SharedUIComponents +import SuggestionModel import SwiftUI struct PromptToCodePanel: View { - @ObservedObject var provider: PromptToCodeProvider + let store: StoreOf var body: some View { VStack(spacing: 0) { - PromptToCodePanelContent(provider: provider) - .overlay(alignment: .topTrailing) { - if !provider.code.isEmpty { - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(provider.code, forType: .string) - } - .padding(.trailing, 2) - .padding(.top, 2) - } - } + TopBar(store: store) + + Content(store: store) .overlay(alignment: .bottom) { - HStack { - if provider.isResponding { - Button(action: { - provider.stopResponding() - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop") - } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ActionBar(store: store) + .padding(.bottom, 8) + } + + Divider() + + Toolbar(store: store) + } + .background(.ultraThickMaterial) + .xcodeStyleFrame() + } +} + +extension PromptToCodePanel { + struct TopBar: View { + let store: StoreOf + + struct AttachButtonState: Equatable { + var attachedToFilename: String + var isAttachedToSelectionRange: Bool + var selectionRange: CursorRange? + } + + var body: some View { + HStack { + Button(action: { + withAnimation(.linear(duration: 0.1)) { + store.send(.selectionRangeToggleTapped) + } + }) { + WithViewStore( + store, + observe: { AttachButtonState( + attachedToFilename: $0.filename, + isAttachedToSelectionRange: $0.isAttachedToSelectionRange, + selectionRange: $0.selectionRange + ) } + ) { viewStore in + let isAttached = viewStore.state.isAttachedToSelectionRange + let color: Color = isAttached ? .indigo : .secondary.opacity(0.6) + HStack(spacing: 4) { + Image( + systemName: isAttached ? "link" : "character.cursor.ibeam" + ) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(.white) + .background( + color, + in: RoundedRectangle( + cornerRadius: 4, + style: .continuous ) - .overlay { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } + ) + + if isAttached { + HStack(spacing: 4) { + Text(viewStore.state.attachedToFilename) + if let range = viewStore.state.selectionRange { + Text(range.description) + } + }.foregroundColor(.primary) + } else { + Text("current selection").foregroundColor(.secondary) } - .buttonStyle(.plain) } + .padding(2) + .padding(.trailing, 4) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(color, lineWidth: 1) + } + .background { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(color.opacity(0.2)) + } + .padding(2) + } + } + .keyboardShortcut("j", modifiers: [.command]) + .buttonStyle(.plain) - let isRespondingButCodeIsReady = provider.isResponding - && !provider.code.isEmpty - && !provider.description.isEmpty - - if !provider.isResponding || isRespondingButCodeIsReady { - HStack { - Toggle( - "Continuous Mode", - isOn: .init( - get: { provider.isContinuous }, - set: { _ in provider.toggleContinuous() } - ) - ) - .toggleStyle(.checkbox) + Spacer() - Button(action: { - provider.cancel() - }) { - Text("Cancel") - } - .buttonStyle(CommandButtonStyle(color: .gray)) - .keyboardShortcut("w", modifiers: [.command]) - - if !provider.code.isEmpty { - Button(action: { - provider.acceptSuggestion() - }) { - Text("Accept(⌘ + ⏎)") - } - .buttonStyle(CommandButtonStyle(color: .indigo)) - .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) - } + WithViewStore(store, observe: { $0.code }) { viewStore in + if !viewStore.state.isEmpty { + CopyButton { + viewStore.send(.copyCodeButtonTapped) + } + } + } + } + .padding(2) + } + } + + struct ActionBar: View { + let store: StoreOf + + struct ActionState: Equatable { + var isResponding: Bool + var isCodeEmpty: Bool + var isDescriptionEmpty: Bool + @BindingViewState var isContinuous: Bool + var isRespondingButCodeIsReady: Bool { + isResponding + && !isCodeEmpty + && !isDescriptionEmpty + } + } + + var body: some View { + HStack { + WithViewStore(store, observe: { $0.isResponding }) { viewStore in + if viewStore.state { + Button(action: { + viewStore.send(.stopRespondingButtonTapped) + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop") } .padding(8) .background( @@ -84,116 +147,231 @@ struct PromptToCodePanel: View { .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } } + .buttonStyle(.plain) } - .padding(.bottom, 8) } - PromptToCodePanelToolbar(provider: provider) - } - .background(Color.contentBackground) - .xcodeStyleFrame() - } -} + WithViewStore(store, observe: { + ActionState( + isResponding: $0.isResponding, + isCodeEmpty: $0.code.isEmpty, + isDescriptionEmpty: $0.description.isEmpty, + isContinuous: $0.$isContinuous + ) + }) { viewStore in + if !viewStore.state.isResponding || viewStore.state.isRespondingButCodeIsReady { + HStack { + Toggle("Continuous Mode", isOn: viewStore.$isContinuous) + .toggleStyle(.checkbox) -struct PromptToCodePanelContent: View { - @ObservedObject var provider: PromptToCodeProvider - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.suggestionCodeFontSize) var fontSize + Button(action: { + viewStore.send(.cancelButtonTapped) + }) { + Text("Cancel") + } + .buttonStyle(CommandButtonStyle(color: .gray)) + .keyboardShortcut("w", modifiers: [.command]) - var body: some View { - CustomScrollView { - VStack(spacing: 0) { - Spacer(minLength: 60) - - if !provider.errorMessage.isEmpty { - Text(provider.errorMessage) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) + if !viewStore.state.isCodeEmpty { + Button(action: { + viewStore.send(.acceptButtonTapped) + }) { + Text("Accept(⌘ + ⏎)") + } + .buttonStyle(CommandButtonStyle(color: .indigo)) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + } + } + .padding(8) .background( - Color.red, - in: RoundedRectangle(cornerRadius: 8, style: .continuous) + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) ) - .scaleEffect(x: 1, y: -1, anchor: .center) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } } + } + } + } - if !provider.description.isEmpty { - Markdown(provider.description) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - }) - .padding() - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } + struct Content: View { + let store: StoreOf + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.suggestionCodeFontSize) var fontSize - if provider.code.isEmpty { - Text( - provider.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." - ) - .foregroundColor(.secondary) - .padding() - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } else { - CodeBlock( - code: provider.code, - language: provider.language, - startLineIndex: provider.startLineIndex, - colorScheme: colorScheme, - firstLinePrecedingSpaceCount: provider.startLineColumn, - fontSize: fontSize - ) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } + struct CodeContent: Equatable { + var code: String + var language: String + var startLineIndex: Int + var firstLinePrecedingSpaceCount: Int + var isResponding: Bool + } + + var body: some View { + CustomScrollView { + VStack(spacing: 0) { + Spacer(minLength: 60) - if let name = provider.name { - Text(name) - .font(.footnote) - .foregroundColor(.secondary) - .padding(.top, 12) - .scaleEffect(x: 1, y: -1, anchor: .center) + WithViewStore(store, observe: { $0.error }) { viewStore in + if let errorMessage = viewStore.state, !errorMessage.isEmpty { + Text(errorMessage) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Color.red, + in: RoundedRectangle(cornerRadius: 4, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + } + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + + WithViewStore(store, observe: { $0.description }) { viewStore in + if !viewStore.state.isEmpty { + Markdown(viewStore.state) + .textSelection(.enabled) + .markdownTheme(.gitHub.text { + BackgroundColor(Color.clear) + }) + .padding() + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + + WithViewStore(store, observe: { + CodeContent( + code: $0.code, + language: $0.language.rawValue, + startLineIndex: $0.selectionRange?.start.line ?? 0, + firstLinePrecedingSpaceCount: $0.selectionRange?.start + .character ?? 0, + isResponding: $0.isResponding + ) + }) { viewStore in + if viewStore.state.code.isEmpty { + Text( + viewStore.state.isResponding + ? "Thinking..." + : "Enter your requirement to generate code." + ) + .foregroundColor(.secondary) + .padding() + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } else { + CodeBlock( + code: viewStore.state.code, + language: viewStore.state.language, + startLineIndex: viewStore.state.startLineIndex, + colorScheme: colorScheme, + firstLinePrecedingSpaceCount: viewStore.state + .firstLinePrecedingSpaceCount, + fontSize: fontSize + ) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } } } + .scaleEffect(x: 1, y: -1, anchor: .center) } - .scaleEffect(x: 1, y: -1, anchor: .center) } -} -struct PromptToCodePanelToolbar: View { - @ObservedObject var provider: PromptToCodeProvider - @FocusState var isInputAreaFocused: Bool + struct Toolbar: View { + let store: StoreOf + @FocusState var isInputAreaFocused: Bool - var body: some View { - HStack { - Button(action: { - provider.revert() - }) { - Group { - Image(systemName: "arrow.uturn.backward") + struct RevertButtonState: Equatable { + var isResponding: Bool + var canRevert: Bool + } + + struct InputFieldState: Equatable { + @BindingViewState var prompt: String + var isResponding: Bool + } + + var body: some View { + HStack { + revertButton + + HStack(spacing: 0) { + inputField + sendButton } - .padding(6) + .frame(maxWidth: .infinity) .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) } .overlay { - Circle() + RoundedRectangle(cornerRadius: 6) .stroke(Color(nsColor: .controlColor), lineWidth: 1) } + .background { + Button(action: { store.send(.appendNewLineToPromptButtonTapped) }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + } + .background { + Button(action: { isInputAreaFocused = true }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + } } - .buttonStyle(.plain) - .disabled(provider.isResponding || !provider.canRevert) + .onAppear { + isInputAreaFocused = true + } + .padding(8) + .background(.ultraThickMaterial) + } - HStack(spacing: 0) { + var revertButton: some View { + WithViewStore(store, observe: { + RevertButtonState(isResponding: $0.isResponding, canRevert: $0.canRevert) + }) { viewStore in + Button(action: { + viewStore.send(.revertButtonTapped) + }) { + Group { + Image(systemName: "arrow.uturn.backward") + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle() + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(viewStore.state.isResponding || !viewStore.state.canRevert) + } + } + + var inputField: some View { + WithViewStore( + store, + observe: { InputFieldState(prompt: $0.$prompt, isResponding: $0.isResponding) } + ) { viewStore in ZStack(alignment: .center) { // a hack to support dynamic height of TextEditor - Text(provider.requirement.isEmpty ? "Hi" : provider.requirement).opacity(0) + Text(viewStore.state.prompt.isEmpty ? "Hi" : viewStore.state.prompt) + .opacity(0) .font(.system(size: 14)) .frame(maxWidth: .infinity, maxHeight: 400) .padding(.top, 1) @@ -201,58 +379,43 @@ struct PromptToCodePanelToolbar: View { .padding(.horizontal, 4) CustomTextEditor( - text: $provider.requirement, + text: viewStore.$prompt, font: .systemFont(ofSize: 14), - onSubmit: { provider.sendRequirement() } + isEditable: !viewStore.state.isResponding, + onSubmit: { viewStore.send(.modifyCodeButtonTapped) } ) .padding(.top, 1) .padding(.bottom, -1) + .opacity(viewStore.state.isResponding ? 0.5 : 1) + .disabled(viewStore.state.isResponding) } - .focused($isInputAreaFocused) - .padding(8) - .fixedSize(horizontal: false, vertical: true) + } + .focused($isInputAreaFocused) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + } + var sendButton: some View { + WithViewStore(store, observe: { $0.isResponding }) { viewStore in Button(action: { - provider.sendRequirement() + viewStore.send(.modifyCodeButtonTapped) }) { Image(systemName: "paperplane.fill") .padding(8) } .buttonStyle(.plain) - .disabled(provider.isResponding) + .disabled(viewStore.state) .keyboardShortcut(KeyEquivalent.return, modifiers: []) } - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - .background { - Button(action: { - provider.requirement += "\n" - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - } } - .onAppear { - isInputAreaFocused = true - } - .padding(8) - .background(.ultraThickMaterial) } } // MARK: - Previews -struct PromptToCodePanel_Bright_Preview: PreviewProvider { +struct PromptToCodePanel_Preview: PreviewProvider { static var previews: some View { - PromptToCodePanel(provider: PromptToCodeProvider( + PromptToCodePanel(store: .init(initialState: .init( code: """ ForEach(0.. SuggestionProvider? - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? } struct MockWidgetDataSource: SuggestionWidgetDataSource { @@ -21,9 +20,5 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { currentSuggestionIndex: 0 ) } - - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { - return nil - } } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 5db67131..5323ffb1 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -300,7 +300,7 @@ enum UpdateLocationStrategy { return .init( frame: .init( x: x, - y: y + selectionFrame.height - Style.widgetPadding, + y: y + selectionFrame.height + Style.widgetPadding, width: Style.inlineSuggestionMinWidth, height: Style.inlineSuggestionMaxHeight ), diff --git a/Core/Sources/XPCShared/XPCServiceProtocol.swift b/Core/Sources/XPCShared/XPCServiceProtocol.swift index 1c567f3b..af74b11d 100644 --- a/Core/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Core/Sources/XPCShared/XPCServiceProtocol.swift @@ -27,6 +27,10 @@ public protocol XPCServiceProtocol { editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void ) + func getPromptToCodeAcceptedCode( + editorContent: Data, + withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void + ) func chatWithSelection( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift index 1c546a94..c145c5f6 100644 --- a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift @@ -1,6 +1,7 @@ import Foundation import SuggestionModel import XCTest +import FocusedCodeFinder @testable import ActiveDocumentChatContextCollector diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift index 90aa7dd9..2e840f5f 100644 --- a/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift @@ -1,5 +1,6 @@ import XCTest import Foundation +import FocusedCodeFinder @testable import ActiveDocumentChatContextCollector diff --git a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift index 3f5c6970..27bb7075 100644 --- a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift +++ b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift @@ -3,7 +3,7 @@ import XCTest final class ExtractCodeFromChatGPTTests: XCTestCase { func test_extract_from_no_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ hello world! """) @@ -13,7 +13,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_incomplete_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ```swift func foo() {} @@ -24,7 +24,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_complete_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ```swift func foo() {} @@ -40,7 +40,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_incomplete_code_block_without_language() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ``` func foo() {} @@ -51,7 +51,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_code_block_without_language() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ``` func foo() {} diff --git a/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift new file mode 100644 index 00000000..52f0e3be --- /dev/null +++ b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift @@ -0,0 +1,192 @@ +import Foundation +import Keychain +import XCTest + +@testable import ServiceUpdateMigration + +final class MigrateTo240Tests: XCTestCase { + let userDefaults = UserDefaults(suiteName: "MigrateTo240Tests")! + + override func tearDown() async throws { + userDefaults.removePersistentDomain(forName: "MigrateTo240Tests") + } + + func test_migrateTo240_no_data_to_migrate() async throws { + let keychain = FakeKeyChain() + + try migrateTo240(defaults: userDefaults, keychain: keychain) + + XCTAssertTrue(try keychain.getAll().isEmpty, "No api key to migrate") + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + for chatModel in chatModels { + switch chatModel.format { + case .openAI: + XCTAssertEqual(chatModel.name, "OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "", + maxTokens: 4096, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + )) + case .azureOpenAI: + XCTAssertEqual(chatModel.name, "Azure OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "", + maxTokens: 4000, + supportsFunctionCalling: true, + modelName: "" + )) + default: + XCTFail() + } + } + + for embeddingModel in embeddingModels { + switch embeddingModel.format { + case .openAI: + XCTAssertEqual(embeddingModel.name, "OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "", + maxTokens: 8191, + modelName: "text-embedding-ada-002" + )) + case .azureOpenAI: + XCTAssertEqual(embeddingModel.name, "Azure OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "", + maxTokens: 8191, + modelName: "" + )) + default: + XCTFail() + } + } + } + + func test_migrateTo240_migrate_data_use_openAI() async throws { + let keychain = FakeKeyChain() + + userDefaults.set("Key1", forKey: "OpenAIAPIKey") + userDefaults.set("openai.com", forKey: "OpenAIBaseURL") + userDefaults.set("gpt-500", forKey: "ChatGPTModel") + userDefaults.set(200, forKey: "ChatGPTMaxToken") + userDefaults.set("embedding-200", forKey: "OpenAIEmbeddingModel") + userDefaults.set("Key2", forKey: "AzureOpenAIAPIKey") + userDefaults.set("azure.com", forKey: "AzureOpenAIBaseURL") + userDefaults.set("gpt-800", forKey: "AzureChatGPTDeployment") + userDefaults.set("embedding-800", forKey: "AzureEmbeddingDeployment") + userDefaults.set("openAI", forKey: "ChatFeatureProvider") + userDefaults.set("openAI", forKey: "EmbeddingFeatureProvider") + + try migrateTo240(defaults: userDefaults, keychain: keychain) + + XCTAssertEqual(try keychain.getAll(), [ + "OpenAI": "Key1", + "Azure OpenAI": "Key2", + ]) + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + XCTAssertEqual(chatModels.count, 2) + XCTAssertEqual(embeddingModels.count, 2) + + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureChatModelId), + chatModels.first(where: { $0.format == .openAI })?.id + ) + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureEmbeddingModelId), + embeddingModels.first(where: { $0.format == .openAI })?.id + ) + + for chatModel in chatModels { + switch chatModel.format { + case .openAI: + XCTAssertEqual(chatModel.name, "OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "openai.com", + maxTokens: 200, + supportsFunctionCalling: true, + modelName: "gpt-500" + )) + case .azureOpenAI: + XCTAssertEqual(chatModel.name, "Azure OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "azure.com", + maxTokens: 200, + supportsFunctionCalling: true, + modelName: "gpt-800" + )) + default: + XCTFail() + } + } + + for embeddingModel in embeddingModels { + switch embeddingModel.format { + case .openAI: + XCTAssertEqual(embeddingModel.name, "OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "openai.com", + maxTokens: 8191, + modelName: "embedding-200" + )) + case .azureOpenAI: + XCTAssertEqual(embeddingModel.name, "Azure OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "azure.com", + maxTokens: 8191, + modelName: "embedding-800" + )) + default: + XCTFail() + } + } + } + + func test_migrateTo240_migrate_data_use_azureOpenAI() async throws { + let keychain = FakeKeyChain() + + userDefaults.set("Key1", forKey: "OpenAIAPIKey") + userDefaults.set("openai.com", forKey: "OpenAIBaseURL") + userDefaults.set("gpt-500", forKey: "ChatGPTModel") + userDefaults.set(200, forKey: "ChatGPTMaxToken") + userDefaults.set("embedding-200", forKey: "OpenAIEmbeddingModel") + userDefaults.set("Key2", forKey: "AzureOpenAIAPIKey") + userDefaults.set("azure.com", forKey: "AzureOpenAIBaseURL") + userDefaults.set("gpt-800", forKey: "AzureChatGPTDeployment") + userDefaults.set("embedding-800", forKey: "AzureEmbeddingDeployment") + userDefaults.set("azureOpenAI", forKey: "ChatFeatureProvider") + userDefaults.set("azureOpenAI", forKey: "EmbeddingFeatureProvider") + + try migrateTo240(defaults: userDefaults, keychain: keychain) + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + XCTAssertEqual(chatModels.count, 2) + XCTAssertEqual(embeddingModels.count, 2) + + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureChatModelId), + chatModels.first(where: { $0.format == .azureOpenAI })?.id + ) + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureEmbeddingModelId), + embeddingModels.first(where: { $0.format == .azureOpenAI })?.id + ) + } +} + diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index c99ba417..5fc2af8f 100644 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -25,7 +25,7 @@ final class AcceptSuggestionTests: XCTestCase { displayText: "" ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 1) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -36,14 +36,19 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - var age: String - } - """) + XCTAssertEqual( + lines.joined(separator: ""), + """ + struct Cat { + var name: String + var age: String + } + + """, + "There is always a new line at the end of each line! When you join them, it will look like this" + ) } func test_accept_suggestion_start_from_previous_line() async throws { @@ -68,7 +73,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -79,13 +84,14 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var age: String } + """) } @@ -111,7 +117,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 1, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -122,16 +128,17 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var age: String } + """) } - + func test_accept_suggestion_overlap_continue_typing() async throws { let content = """ struct Cat { @@ -154,7 +161,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 1, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -165,16 +172,17 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var age: String } + """) } - + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed() async throws { let content = """ print("") @@ -194,7 +202,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 7) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -205,14 +213,14 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 21)) XCTAssertEqual(lines.joined(separator: ""), """ print("Hello World!") - + """) } - + func test_accept_suggestion_overlap_continue_typing_suggestion_in_the_middle() async throws { let content = """ print("He") @@ -232,7 +240,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 7) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -243,14 +251,16 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 0, character: 20)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ print("Hello World!") + """) } - - func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines() async throws { + + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines( + ) async throws { let content = """ struct Cat {} """ @@ -272,7 +282,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -283,19 +293,19 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 3, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var kind: String } - + """) } func test_propose_suggestion_partial_overlap() async throws { - let content = "func quickSort() {}}\n" + let content = "func quickSort() {}}" let text = """ func quickSort() { var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] @@ -317,7 +327,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 18) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -328,7 +338,7 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 6, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ func quickSort() { @@ -343,7 +353,7 @@ final class AcceptSuggestionTests: XCTestCase { } func test_no_overlap_append_to_the_end() async throws { - let content = "func quickSort() {\n" + let content = "func quickSort() {" let text = """ var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] var left = 0 @@ -364,7 +374,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 18) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -375,7 +385,7 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 6, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ func quickSort() { @@ -388,7 +398,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_replacing_multiple_lines() async throws { let content = """ struct Cat { @@ -412,9 +422,9 @@ final class AcceptSuggestionTests: XCTestCase { ), displayText: "" ) - + var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 7) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -422,25 +432,32 @@ final class AcceptSuggestionTests: XCTestCase { completion: suggestion, extraInfo: &extraInfo ) - + XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 4, character: 0)) - XCTAssertEqual(lines.joined(separator: ""), text) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 4, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Dog { + func speak() { + print("woof") + } + } + + """) } - + func test_replacing_multiple_lines_in_the_middle() async throws { let content = """ protocol Animal { func speak() } - + struct Cat: Animal { func speak() { print("meow") } } - + func foo() {} """ let text = """ @@ -459,9 +476,9 @@ final class AcceptSuggestionTests: XCTestCase { ), displayText: "" ) - + var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 5, character: 34) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -469,24 +486,100 @@ final class AcceptSuggestionTests: XCTestCase { completion: suggestion, extraInfo: &extraInfo ) - + XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 7, character: 5)) XCTAssertEqual(lines.joined(separator: ""), """ protocol Animal { func speak() } - + struct Dog { func speak() { print("woof") } } - + func foo() {} + + """) + } + + func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character( + ) async throws { + let content = """ + apiKeyName: ,, + """ + + let suggestion = CodeSuggestion( + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + uuid: "", + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + displayText: "" + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName,, + """) } + + func test_remove_the_first_adjacent_placeholder_in_the_last_line( + ) async throws { + let content = """ + apiKeyName: <#T##value: BinaryInteger##BinaryInteger#> <#Hello#>, + """ + + let suggestion = CodeSuggestion( + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + uuid: "", + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + displayText: "" + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName <#Hello#>, + + """) + } +} + +extension String { + func breakIntoEditorStyleLines() -> [String] { + split(separator: "\n", omittingEmptySubsequences: false).map { $0 + "\n" } + } } + diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bc243c73..002dcc2e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,12 +18,10 @@ The `ExtensionService` is a program that operates in the background and performs Most of the logics are implemented inside the package `Core` and `Tool`. -- The `CopilotService` is responsible for communicating with the GitHub Copilot LSP. - The `Service` is responsible for handling the requests from the `EditorExtension`, communicating with the `CopilotService`, update the code blocks and present the GUI. - The `Client` is basically just a wrapper around the XPCService - The `SuggestionInjector` is responsible for injecting the suggestions into the code. Used in comment mode to present the suggestions, and all modes to accept suggestions. -- The `Environment` contains some swappable global functions. It is used to make testing easier. -- The `SuggestionWidget` is responsible for presenting the suggestions in floating widget mode. +- The `SuggestionWidget` is responsible for the UI of the widgets. ## Building and Archiving the App @@ -31,9 +29,9 @@ Most of the logics are implemented inside the package `Core` and `Tool`. 2. Build or archive the Copilot for Xcode target. 3. If Xcode complains that the pro package doesn't exist, please remove the package from the project, and update the last function in Core/Package.swift to return false. -## Testing Extension +## Testing Source Editor Extension -Just run both the `ExtensionService` and the `EditorExtension` Target. +Just run both the `ExtensionService` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details. ## SwiftUI Previews diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift new file mode 100644 index 00000000..f7d7171c --- /dev/null +++ b/EditorExtension/AcceptPromptToCodeCommand.swift @@ -0,0 +1,31 @@ +import Client +import Foundation +import SuggestionModel +import XcodeKit + +class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Prompt to Code" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.getPromptToCodeAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index 7ad0b841..396a8d00 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -11,21 +11,30 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { RejectSuggestionCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), - RealtimeSuggestionsCommand(), - PrefetchSuggestionsCommand(), - ChatWithSelectionCommand(), PromptToCodeCommand(), - + AcceptPromptToCodeCommand(), + ChatWithSelectionCommand(), + ].map(makeCommandDefinition) + } + + var internalUse: [[XCSourceEditorCommandDefinitionKey: Any]] { + [ SeparatorCommand().named("------"), + RealtimeSuggestionsCommand(), + PrefetchSuggestionsCommand(), ].map(makeCommandDefinition) } var custom: [[XCSourceEditorCommandDefinitionKey: Any]] { - customCommands() + let all = customCommands() + if all.isEmpty { + return [] + } + return [SeparatorCommand().named("------")].map(makeCommandDefinition) + all } var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { - return builtin + custom + return builtin + custom + internalUse } func extensionDidFinishLaunching() { diff --git a/Pro b/Pro index 790fd58d..39a2407c 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 790fd58df73b593ed683e0986b5d3424dd4e684a +Subproject commit 39a2407c29febaf5a74095177dc1035f5551dd69 diff --git a/README.md b/README.md index fa22ecac..4892e1f3 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Enable the Extension](#enable-the-extension) - [Granting Permissions to the App](#granting-permissions-to-the-app) - [Setting Up Key Bindings](#setting-up-key-bindings) - - [Setting Up GitHub Copilot](#setting-up-github-copilot) - - [Setting Up Codeium](#setting-up-codeium) - - [Setting Up OpenAI API Key](#setting-up-openai-api-key) + - [Setting Up Suggestion Feature](#setting-up-suggestion-feature) + - [Setting Up GitHub Copilot](#setting-up-github-copilot) + - [Setting Up Codeium](#setting-up-codeium) + - [Setting Up Chat Feature](#setting-up-chat-feature) - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) - [Update](#update) - [Feature](#feature) @@ -54,7 +55,8 @@ For suggestion features: For chat and prompt to code features: -- Valid OpenAI API key. +- A valid OpenAI API key. +- Access to other LLMs, such as Azure OpenAI and LocalAI. ## Permissions Required @@ -118,36 +120,45 @@ Essentially using `⌥⇧` as the "access" key combination for all bindings. Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. -### Setting Up GitHub Copilot +### Setting Up Suggestion Feature -1. In the host app, switch to the service tab and click on GitHub Copilot to access your GitHub Copilot account settings. -2. Click "Install" to install the language server. -3. Optionally setup the path to Node. The default value is just `node`, Copilot for Xcode.app will try to find the Node from the PATH available in a login shell. If your Node is installed somewhere else, you can run `which node` from terminal to get the path. -4. Click "Sign In", and you will be directed to a verification website provided by GitHub, and a user code will be pasted into your clipboard. -5. After signing in, go back to the app and click "Confirm Sign-in" to finish. +#### Setting Up GitHub Copilot + +1. In the host app, navigate to "Service - GitHub Copilot" to access your GitHub Copilot account settings. +2. Click on "Install" to install the language server. +3. Optionally, set up the path to Node. The default value is simply `node`. Copilot for Xcode.app will attempt to locate Node from the following directories: `/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`. + + If your Node installation is located elsewhere, you can run `which node` from the terminal to obtain the correct path. + + If you are using a node version manager that provides a shim executable, you will need to find the path to the actual executable. Please refer to the FAQ for more information. + +4. Click on "Sign In", and you will be redirected to a verification website provided by GitHub. A user code will be copied to your clipboard. +5. After signing in, return to the app and click on "Confirm Sign-in" to complete the process. 6. Go to "Feature - Suggestion" and update the feature provider to "GitHub Copilot". The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/GitHub Copilot/executable/`. -### Setting Up Codeium +#### Setting Up Codeium -1. In the host app, switch to the service tab and click Codeium to access the Codeium account settings. -2. Click "Install" to install the language server. -3. Click "Sign In", and you will be directed to codeium.com. After signing in, a token will be presented. You will need to paste the token back to the app to finish signing in. +1. In the host app, navigate to "Service - Codeium" to access the Codeium account settings. +2. Click on "Install" to install the language server. +3. Click on "Sign In" and you will be redirected to codeium.com. After signing in, a token will be provided. You need to copy and paste this token back into the app to complete the sign-in process. 4. Go to "Feature - Suggestion" and update the feature provider to "Codeium". > The key is stored in the keychain. When the helper app tries to access the key for the first time, it will prompt you to enter the password to access the keychain. Please select "Always Allow" to let the helper app access the key. The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`. -### Setting Up OpenAI API Key +### Setting Up Chat Feature -1. In the host app, click OpenAI to enter the OpenAI account settings. -2. Enter your api key to the text field. +1. In the host app, navigate to "Service - Chat Model". +2. Update the OpenAI model or create a new one if necessary. Use the test button to verify the model. +3. Optionally, set up the embedding model in "Service - Embedding Model", which is required for a subset of the chat feature. +4. Go to "Feature - Chat" and update the chat/embedding feature provider with the one you just updated/created. ### Managing `CopilotForXcodeExtensionService.app` -This app runs whenever you open `Copilot for Xcode.app` or `Xcode.app`. You can quit it with its menu bar item that looks like a steering wheel. +This app runs whenever you open `Copilot for Xcode.app` or `Xcode.app`. You can quit it with its menu bar item that looks like a tentacle. You can also set it to quit automatically when the above 2 apps are closed. @@ -161,9 +172,7 @@ brew upgrade --cask copilot-for-xcode Alternatively, You can use the in-app updater or download the latest version manually from the latest [release](https://github.com/intitni/CopilotForXcode/releases). -After updating, please restart Xcode to allow the extension to reload. - -If you are upgrading from a version lower than **0.7.0**, please run `Copilot for Xcode.app` at least once to let it set up the new launch agent for you and re-grant the permissions according to the new rules. +After updating, please open Copilot for Xcode.app once and restart Xcode to allow the extension to reload. If you find that some of the features are no longer working, please first try regranting permissions to the app. @@ -193,6 +202,9 @@ Whenever your code is updated, the app will automatically fetch suggestions for - Previous Suggestion: If there is more than one suggestion, switch to the previous one. - Accept Suggestion: Add the suggestion to the code. - Reject Suggestion: Remove the suggestion comments. + +Commands called by the app: + - Real-time Suggestions: Call only by Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. - Prefetch Suggestions: Call only by Copilot for Xcode. In the background, Copilot for Xcode will occasionally run this command to prefetch real-time suggestions. @@ -283,6 +295,7 @@ This feature is recommended when you need to update a specific piece of code. So #### Commands - Prompt to Code: Open a prompt to code window, where you can use natural language to write or edit selected code. +- Accept Prompt to Code: Accept the result of prompt to code. ### Custom Commands @@ -310,6 +323,7 @@ These features are included in another repo, and are not open sourced. The currently available Plus features include: +- Unlimited chat/embedding models. - Tab to accept suggestions. - Persisted chat panel. - Browser tab in chat panel. @@ -326,7 +340,7 @@ The request contains only the license key, the email address (only on activation ## Limitations -- The extension uses some dirty tricks to get the file and project/workspace paths. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though. +- The extension utilizes various tricks to monitor the state of Xcode. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though. ## License diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index d45389dd..9b31428f 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -126,6 +126,20 @@ "identifier" : "ChatTabPersistentTests", "name" : "ChatTabPersistentTests" } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "KeychainTests", + "name" : "KeychainTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index b595a4de..34740cc3 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), .library(name: "ASTParser", targets: ["ASTParser"]), + .library(name: "FocusedCodeFinder", targets: ["FocusedCodeFinder"]), .library(name: "Toast", targets: ["Toast"]), .library(name: "Keychain", targets: ["Keychain"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), @@ -47,6 +48,8 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), + .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), + .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), // TreeSitter .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", from: "0.7.1"), @@ -61,7 +64,7 @@ let package = Package( .target(name: "Configs"), - .target(name: "Preferences", dependencies: ["Configs"]), + .target(name: "Preferences", dependencies: ["Configs", "AIModel"]), .target(name: "Terminal"), @@ -71,7 +74,12 @@ let package = Package( .target( name: "Keychain", - dependencies: ["Configs"] + dependencies: ["Configs", "Preferences"] + ), + + .testTarget( + name: "KeychainTests", + dependencies: ["Keychain"] ), .target( @@ -120,6 +128,13 @@ let package = Package( ] ), + .target( + name: "AIModel", + dependencies: [ + .product(name: "CodableWrappers", package: "CodableWrappers"), + ] + ), + .testTarget( name: "SuggestionModelTests", dependencies: ["SuggestionModel"] @@ -184,6 +199,16 @@ let package = Package( ] ), + .target( + name: "FocusedCodeFinder", + dependencies: [ + "Preferences", + "ASTParser", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + ] + ), + // MARK: - Services .target( @@ -207,6 +232,7 @@ let package = Package( "Logger", "Preferences", "TokenEncoder", + "Keychain", .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift new file mode 100644 index 00000000..d132b5dd --- /dev/null +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -0,0 +1,79 @@ +import CodableWrappers +import Foundation + +public struct ChatModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case openAI + case azureOpenAI + case openAICompatible + } + + public struct Info: Codable, Equatable { + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var supportsFunctionCalling: Bool + @FallbackDecoding + public var modelName: String + public var azureOpenAIDeploymentName: String { + get { modelName } + set { modelName = newValue } + } + + public init( + apiKeyName: String = "", + baseURL: String = "", + maxTokens: Int = 4000, + supportsFunctionCalling: Bool = true, + modelName: String = "" + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.maxTokens = maxTokens + self.supportsFunctionCalling = supportsFunctionCalling + self.modelName = modelName + } + } + + public var endpoint: String { + switch format { + case .openAI, .openAICompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } + return "\(baseURL)/v1/chat/completions" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.azureOpenAIDeploymentName + let version = "2023-07-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" + } + } +} + +public struct EmptyChatModelInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info { .init() } +} + +public struct EmptyChatModelFormat: FallbackValueProvider { + public static var defaultValue: ChatModel.Format { .openAI } +} + diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift new file mode 100644 index 00000000..174280d8 --- /dev/null +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -0,0 +1,75 @@ +import Foundation +import CodableWrappers + +public struct EmbeddingModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case openAI + case azureOpenAI + case openAICompatible + } + + public struct Info: Codable, Equatable { + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var modelName: String + public var azureOpenAIDeploymentName: String { + get { modelName } + set { modelName = newValue } + } + + public init( + apiKeyName: String = "", + baseURL: String = "", + maxTokens: Int = 8192, + modelName: String = "" + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.maxTokens = maxTokens + self.modelName = modelName + } + } + + public var endpoint: String { + switch format { + case .openAI, .openAICompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/embeddings" } + return "\(baseURL)/v1/embeddings" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.azureOpenAIDeploymentName + let version = "2023-07-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/embeddings?api-version=\(version)" + } + } +} + + +public struct EmptyEmbeddingModelInfo: FallbackValueProvider { + public static var defaultValue: EmbeddingModel.Info { .init() } +} + +public struct EmptyEmbeddingModelFormat: FallbackValueProvider { + public static var defaultValue: EmbeddingModel.Format { .openAI } +} diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 4103f0e4..fc2fe82f 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -23,6 +23,9 @@ public protocol ChatTabType { /// Build the view for this chat tab. @ViewBuilder func buildView() -> any View + /// Build the tabItem for this chat tab. + @ViewBuilder + func buildTabItem() -> any View /// Build the menu for this chat tab. @ViewBuilder func buildMenu() -> any View @@ -82,7 +85,21 @@ open class BaseChatTab { } } - /// The menu for this chat tab. + /// The tab item for this chat tab. + @ViewBuilder + public var tabItem: some View { + let id = "ChatTabMenu\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildTabItem).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } + + /// The tab item for this chat tab. @ViewBuilder public var menu: some View { let id = "ChatTabMenu\(id)" @@ -162,8 +179,12 @@ public class EmptyChatTab: ChatTab { .background(Color.blue) } + public func buildTabItem() -> any View { + Text("Empty-\(id)") + } + public func buildMenu() -> any View { - EmptyView() + Text("Empty-\(id)") } public func restorableState() async -> Data { diff --git a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift new file mode 100644 index 00000000..6b4d9525 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift @@ -0,0 +1,147 @@ +import Foundation +import SuggestionModel + +public struct ActiveDocumentContext { + public var filePath: String + public var relativePath: String + public var language: CodeLanguage + public var fileContent: String + public var lines: [String] + public var selectedCode: String + public var selectionRange: CursorRange + public var lineAnnotations: [EditorInformation.LineAnnotation] + public var imports: [String] + + public struct FocusedContext { + public var context: [String] + public var contextRange: CursorRange + public var codeRange: CursorRange + public var code: String + public var lineAnnotations: [EditorInformation.LineAnnotation] + public var otherLineAnnotations: [EditorInformation.LineAnnotation] + + public init( + context: [String], + contextRange: CursorRange, + codeRange: CursorRange, + code: String, + lineAnnotations: [EditorInformation.LineAnnotation], + otherLineAnnotations: [EditorInformation.LineAnnotation] + ) { + self.context = context + self.contextRange = contextRange + self.codeRange = codeRange + self.code = code + self.lineAnnotations = lineAnnotations + self.otherLineAnnotations = otherLineAnnotations + } + } + + public var focusedContext: FocusedContext? + + public init( + filePath: String, + relativePath: String, + language: CodeLanguage, + fileContent: String, + lines: [String], + selectedCode: String, + selectionRange: CursorRange, + lineAnnotations: [EditorInformation.LineAnnotation], + imports: [String], + focusedContext: FocusedContext? = nil + ) { + self.filePath = filePath + self.relativePath = relativePath + self.language = language + self.fileContent = fileContent + self.lines = lines + self.selectedCode = selectedCode + self.selectionRange = selectionRange + self.lineAnnotations = lineAnnotations + self.imports = imports + self.focusedContext = focusedContext + } + + public mutating func moveToFocusedCode() { + moveToCodeContainingRange(selectionRange) + } + + public mutating func moveToCodeAroundLine(_ line: Int) { + moveToCodeContainingRange(.init( + start: .init(line: line, character: 0), + end: .init(line: line, character: 0) + )) + } + + public mutating func expandFocusedRangeToContextRange() { + guard let focusedContext else { return } + moveToCodeContainingRange(focusedContext.contextRange) + } + + public mutating func moveToCodeContainingRange(_ range: CursorRange) { + let finder: FocusedCodeFinder = { + switch language { + case .builtIn(.swift): + return SwiftFocusedCodeFinder() + default: + return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + } + }() + + let codeContext = finder.findFocusedCode( + containingRange: range, + activeDocumentContext: self + ) + + imports = codeContext.imports + + let startLine = codeContext.focusedRange.start.line + let endLine = codeContext.focusedRange.end.line + var matchedAnnotations = [EditorInformation.LineAnnotation]() + var otherAnnotations = [EditorInformation.LineAnnotation]() + for annotation in lineAnnotations { + if annotation.line >= startLine, annotation.line <= endLine { + matchedAnnotations.append(annotation) + } else { + otherAnnotations.append(annotation) + } + } + + focusedContext = .init( + context: codeContext.scopeSignatures, + contextRange: codeContext.contextRange, + codeRange: codeContext.focusedRange, + code: codeContext.focusedCode, + lineAnnotations: matchedAnnotations, + otherLineAnnotations: otherAnnotations + ) + } + + public mutating func update(_ info: EditorInformation) { + /// Whenever the file content, relative path, or selection range changes, + /// we should reset the context. + let changed: Bool = { + if info.relativePath != relativePath { return true } + if info.editorContent?.content != fileContent { return true } + if let range = info.editorContent?.selections.first, + range != selectionRange { return true } + return false + }() + + filePath = info.documentURL.path + relativePath = info.relativePath + language = info.language + fileContent = info.editorContent?.content ?? "" + lines = info.editorContent?.lines ?? [] + selectedCode = info.selectedContent + selectionRange = info.editorContent?.selections.first ?? .zero + lineAnnotations = info.editorContent?.lineAnnotations ?? [] + imports = [] + + if changed { + moveToFocusedCode() + } + } +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift similarity index 78% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift rename to Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift index 24642138..a571da5d 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift @@ -1,14 +1,14 @@ import Foundation import SuggestionModel -struct CodeContext: Equatable { - enum Scope: Equatable { +public struct CodeContext: Equatable { + public enum Scope: Equatable { case file case top case scope(signature: [String]) } - var scopeSignatures: [String] { + public var scopeSignatures: [String] { switch scope { case .file: return [] @@ -19,32 +19,46 @@ struct CodeContext: Equatable { } } - var scope: Scope - var contextRange: CursorRange - var focusedRange: CursorRange - var focusedCode: String - var imports: [String] + public var scope: Scope + public var contextRange: CursorRange + public var focusedRange: CursorRange + public var focusedCode: String + public var imports: [String] - static var empty: CodeContext { + public static var empty: CodeContext { .init(scope: .file, contextRange: .zero, focusedRange: .zero, focusedCode: "", imports: []) } + + public init( + scope: Scope, + contextRange: CursorRange, + focusedRange: CursorRange, + focusedCode: String, + imports: [String] + ) { + self.scope = scope + self.contextRange = contextRange + self.focusedRange = focusedRange + self.focusedCode = focusedCode + self.imports = imports + } } -protocol FocusedCodeFinder { +public protocol FocusedCodeFinder { func findFocusedCode( containingRange: CursorRange, activeDocumentContext: ActiveDocumentContext ) -> CodeContext } -struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { +public struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { let proposedSearchRange: Int - init(proposedSearchRange: Int) { + public init(proposedSearchRange: Int) { self.proposedSearchRange = proposedSearchRange } - func findFocusedCode( + public func findFocusedCode( containingRange: CursorRange, activeDocumentContext: ActiveDocumentContext ) -> CodeContext { diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift similarity index 98% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift rename to Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index e0e52e4c..0c2ea908 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -3,15 +3,19 @@ import Foundation import SuggestionModel import SwiftParser import SwiftSyntax +import Preferences -struct SwiftFocusedCodeFinder: FocusedCodeFinder { - let maxFocusedCodeLineCount: Int +public struct SwiftFocusedCodeFinder: FocusedCodeFinder { + public let maxFocusedCodeLineCount: Int - init(maxFocusedCodeLineCount: Int = UserDefaults.shared.value(for: \.maxFocusedCodeLineCount)) { + public init( + maxFocusedCodeLineCount: Int = UserDefaults.shared + .value(for: \.maxFocusedCodeLineCount) + ) { self.maxFocusedCodeLineCount = maxFocusedCodeLineCount } - func findFocusedCode( + public func findFocusedCode( containingRange range: CursorRange, activeDocumentContext: ActiveDocumentContext ) -> CodeContext { diff --git a/Tool/Sources/Keychain/Keychain.swift b/Tool/Sources/Keychain/Keychain.swift index 82b249dc..87eca493 100644 --- a/Tool/Sources/Keychain/Keychain.swift +++ b/Tool/Sources/Keychain/Keychain.swift @@ -1,20 +1,93 @@ import Configs import Foundation +import Preferences import Security -public struct Keychain { +public protocol KeychainType { + func getAll() throws -> [String: String] + func update(_ value: String, key: String) throws + func get(_ key: String) throws -> String? + func remove(_ key: String) throws +} + +public final class FakeKeyChain: KeychainType { + var values: [String: String] = [:] + + public init() {} + + public func getAll() throws -> [String: String] { + values + } + + public func update(_ value: String, key: String) throws { + values[key] = value + } + + public func get(_ key: String) throws -> String? { + values[key] + } + + public func remove(_ key: String) throws { + values[key] = nil + } +} + +public final class UserDefaultsBaseAPIKeychain: KeychainType { + let defaults = UserDefaults.shared + let scope: String + var key: String { + "UserDefaultsBaseAPIKeychain-\(scope)" + } + + init(scope: String) { + self.scope = scope + } + + public func getAll() throws -> [String : String] { + defaults.dictionary(forKey: key) as? [String: String] ?? [:] + } + + public func update(_ value: String, key: String) throws { + var dict = try getAll() + dict[key] = value + defaults.set(dict, forKey: self.key) + } + + public func get(_ key: String) throws -> String? { + try getAll()[key] + } + + public func remove(_ key: String) throws { + var dict = try getAll() + dict[key] = nil + defaults.set(dict, forKey: self.key) + } +} + +public struct Keychain: KeychainType { let service = keychainService let accessGroup = keychainAccessGroup + let scope: String + + public static var apiKey: KeychainType { + if UserDefaults.shared.value(for: \.useUserDefaultsBaseAPIKeychain) { + return UserDefaultsBaseAPIKeychain(scope: "apiKey") + } + return Keychain(scope: "apiKey") + } public enum Error: Swift.Error { case failedToDeleteFromKeyChain case failedToUpdateOrSetItem } - public init() {} - + public init(scope: String = "") { + self.scope = scope + } + func query(_ key: String) -> [String: Any] { - [ + let key = scopeKey(key) + return [ kSecClass as String: kSecClassGenericPassword as String, kSecAttrService as String: service, kSecAttrAccessGroup as String: accessGroup, @@ -38,6 +111,53 @@ public struct Keychain { } } + func scopeKey(_ key: String) -> String { + if scope.isEmpty { + return key + } + return "\(scope)::\(key)" + } + + func escapeScope(_ key: String) -> String? { + if scope.isEmpty { + return key + } + if !key.hasPrefix("\(scope)::") { return nil } + return key.replacingOccurrences(of: "\(scope)::", with: "") + } + + public func getAll() throws -> [String: String] { + let query = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] as [String: Any] + + var result: AnyObject? + if SecItemCopyMatching(query as CFDictionary, &result) == noErr { + guard let items = result as? [[String: Any]] else { + return [:] + } + + var dict = [String: String]() + for item in items { + guard let key = item[kSecAttrAccount as String] as? String, + let escapedKey = escapeScope(key) + else { continue } + guard let valueData = item[kSecValueData as String] as? Data, + let value = String(data: valueData, encoding: .utf8) + else { continue } + dict[escapedKey] = value + } + return dict + } + + return [:] + } + public func update(_ value: String, key: String) throws { let query = query(key).merging([ kSecMatchLimit as String: kSecMatchLimitOne, @@ -87,3 +207,4 @@ public struct Keychain { throw Error.failedToDeleteFromKeyChain } } + diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 406dafe4..f8f598c8 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -197,29 +197,36 @@ extension ChatGPTService { } let remainingTokens = await memory.remainingTokens + let model = configuration.model + let requestBody = CompletionRequestBody( - model: configuration.model, + model: model.info.modelName, messages: messages, temperature: configuration.temperature, stream: true, stop: configuration.stop.isEmpty ? nil : configuration.stop, max_tokens: maxTokenForReply( - model: configuration.model, + maxToken: model.info.maxTokens, remainingTokens: remainingTokens ), - function_call: functionProvider.functionCallStrategy, - functions: functionProvider.functions.map { - ChatGPTFunctionSchema( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - } + function_call: model.info.supportsFunctionCalling + ? functionProvider.functionCallStrategy + : nil, + functions: + model.info.supportsFunctionCalling + ? functionProvider.functions.map { + ChatGPTFunctionSchema( + name: $0.name, + description: $0.description, + parameters: $0.argumentSchema + ) + } + : [] ) let api = buildCompletionStreamAPI( configuration.apiKey, - configuration.featureProvider, + model, url, requestBody ) @@ -231,7 +238,7 @@ extension ChatGPTService { cancelTask = cancel let proposedId = UUID().uuidString for try await trunk in trunks { - guard let delta = trunk.choices.first?.delta else { continue } + guard let delta = trunk.choices?.first?.delta else { continue } // The api will always return a function call with JSON object. // The first round will contain the function name and an empty argument. @@ -297,29 +304,36 @@ extension ChatGPTService { } let remainingTokens = await memory.remainingTokens + let model = configuration.model + let requestBody = CompletionRequestBody( - model: configuration.model, + model: model.info.modelName, messages: messages, temperature: configuration.temperature, stream: true, stop: configuration.stop.isEmpty ? nil : configuration.stop, max_tokens: maxTokenForReply( - model: configuration.model, + maxToken: model.info.maxTokens, remainingTokens: remainingTokens ), - function_call: functionProvider.functionCallStrategy, - functions: functionProvider.functions.map { - ChatGPTFunctionSchema( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - } + function_call: model.info.supportsFunctionCalling + ? functionProvider.functionCallStrategy + : nil, + functions: + model.info.supportsFunctionCalling + ? functionProvider.functions.map { + ChatGPTFunctionSchema( + name: $0.name, + description: $0.description, + parameters: $0.argumentSchema + ) + } + : [] ) let api = buildCompletionAPI( configuration.apiKey, - configuration.featureProvider, + model, url, requestBody ) @@ -467,9 +481,8 @@ extension ChatGPTService { } } -func maxTokenForReply(model: String, remainingTokens: Int?) -> Int? { +func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { guard let remainingTokens else { return nil } - guard let model = ChatGPTModel(rawValue: model) else { return remainingTokens } - return min(model.maxToken / 2, remainingTokens) + return min(maxToken / 2, remainingTokens) } diff --git a/Tool/Sources/OpenAIService/CompletionAPI.swift b/Tool/Sources/OpenAIService/CompletionAPI.swift index e092411f..479c57bb 100644 --- a/Tool/Sources/OpenAIService/CompletionAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionAPI.swift @@ -1,7 +1,8 @@ +import AIModel import Foundation import Preferences -typealias CompletionAPIBuilder = (String, ChatFeatureProvider, URL, CompletionRequestBody) +typealias CompletionAPIBuilder = (String, ChatModel, URL, CompletionRequestBody) -> CompletionAPI protocol CompletionAPI { @@ -66,11 +67,11 @@ struct OpenAICompletionAPI: CompletionAPI { var apiKey: String var endpoint: URL var requestBody: CompletionRequestBody - var provider: ChatFeatureProvider + var model: ChatModel init( apiKey: String, - provider: ChatFeatureProvider, + model: ChatModel, endpoint: URL, requestBody: CompletionRequestBody ) { @@ -78,7 +79,7 @@ struct OpenAICompletionAPI: CompletionAPI { self.endpoint = endpoint self.requestBody = requestBody self.requestBody.stream = false - self.provider = provider + self.model = model } func callAsFunction() async throws -> CompletionResponseBody { @@ -88,9 +89,10 @@ struct OpenAICompletionAPI: CompletionAPI { request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { - if provider == .openAI { + switch model.format { + case .openAI, .openAICompatible: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } else { + case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") } } diff --git a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift index 6b39734b..2524257b 100644 --- a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift @@ -1,8 +1,9 @@ import AsyncAlgorithms import Foundation import Preferences +import AIModel -typealias CompletionStreamAPIBuilder = (String, ChatFeatureProvider, URL, CompletionRequestBody) +typealias CompletionStreamAPIBuilder = (String, ChatModel, URL, CompletionRequestBody) -> CompletionStreamAPI protocol CompletionStreamAPI { @@ -129,13 +130,13 @@ struct CompletionRequestBody: Encodable, Equatable { struct CompletionStreamDataTrunk: Codable { var id: String? - var object: String - var model: String - var choices: [Choice] + var object: String? + var model: String? + var choices: [Choice]? struct Choice: Codable { - var delta: Delta - var index: Int + var delta: Delta? + var index: Int? var finish_reason: String? struct Delta: Codable { @@ -155,11 +156,11 @@ struct OpenAICompletionStreamAPI: CompletionStreamAPI { var apiKey: String var endpoint: URL var requestBody: CompletionRequestBody - var provider: ChatFeatureProvider + var model: ChatModel init( apiKey: String, - provider: ChatFeatureProvider, + model: ChatModel, endpoint: URL, requestBody: CompletionRequestBody ) { @@ -167,7 +168,7 @@ struct OpenAICompletionStreamAPI: CompletionStreamAPI { self.endpoint = endpoint self.requestBody = requestBody self.requestBody.stream = true - self.provider = provider + self.model = model } func callAsFunction() async throws -> ( @@ -180,9 +181,10 @@ struct OpenAICompletionStreamAPI: CompletionStreamAPI { request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { - if provider == .openAI { + switch model.format { + case .openAI, .openAICompatible: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } else { + case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") } } diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index aa93a61e..503b8e8f 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -1,11 +1,11 @@ import Foundation +import AIModel import Preferences +import Keychain public protocol ChatGPTConfiguration { - var featureProvider: ChatFeatureProvider { get } + var model: ChatModel { get } var temperature: Double { get } - var model: String { get } - var endpoint: String { get } var apiKey: String { get } var stop: [String] { get } var maxTokens: Int { get } @@ -14,28 +14,12 @@ public protocol ChatGPTConfiguration { } public extension ChatGPTConfiguration { - func endpoint(for provider: ChatFeatureProvider) -> String { - switch provider { - case .openAI: - let baseURL = UserDefaults.shared.value(for: \.openAIBaseURL) - if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } - return "\(baseURL)/v1/chat/completions" - case .azureOpenAI: - let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) - let deployment = UserDefaults.shared.value(for: \.azureChatGPTDeployment) - let version = "2023-07-01-preview" - if baseURL.isEmpty { return "" } - return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" - } + var endpoint: String { + model.endpoint } - - func apiKey(for provider: ChatFeatureProvider) -> String { - switch provider { - case .openAI: - return UserDefaults.shared.value(for: \.openAIAPIKey) - case .azureOpenAI: - return UserDefaults.shared.value(for: \.azureOpenAIAPIKey) - } + + var apiKey: String { + (try? Keychain.apiKey.get(model.info.apiKeyName)) ?? "" } func overriding( diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index 13c4ab21..0ad7cc07 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -1,37 +1,21 @@ +import AIModel import Foundation +import Keychain import Preferences public protocol EmbeddingConfiguration { - var featureProvider: EmbeddingFeatureProvider { get } - var endpoint: String { get } + var model: EmbeddingModel { get } var apiKey: String { get } var maxToken: Int { get } - var model: String { get } } public extension EmbeddingConfiguration { - func endpoint(for provider: EmbeddingFeatureProvider) -> String { - switch provider { - case .openAI: - let baseURL = UserDefaults.shared.value(for: \.openAIBaseURL) - if baseURL.isEmpty { return "https://api.openai.com/v1/embeddings" } - return "\(baseURL)/v1/embeddings" - case .azureOpenAI: - let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) - let deployment = UserDefaults.shared.value(for: \.azureEmbeddingDeployment) - let version = "2023-07-01-preview" - if baseURL.isEmpty { return "" } - return "\(baseURL)/openai/deployments/\(deployment)/embeddings?api-version=\(version)" - } + var endpoint: String { + model.endpoint } - - func apiKey(for provider: EmbeddingFeatureProvider) -> String { - switch provider { - case .openAI: - return UserDefaults.shared.value(for: \.openAIAPIKey) - case .azureOpenAI: - return UserDefaults.shared.value(for: \.azureOpenAIAPIKey) - } + + var apiKey: String { + (try? Keychain.apiKey.get(model.info.apiKeyName)) ?? "" } func overriding( diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index d4ce6ed2..bc499d6f 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -1,31 +1,21 @@ +import AIModel import Foundation import Preferences public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { - public var featureProvider: ChatFeatureProvider { - UserDefaults.shared.value(for: \.chatFeatureProvider) - } - public var temperature: Double { min(max(0, UserDefaults.shared.value(for: \.chatGPTTemperature)), 2) } - public var model: String { - let value = UserDefaults.shared.value(for: \.chatGPTModel) - if value.isEmpty { return "gpt-3.5-turbo" } - return value - } - - public var endpoint: String { - endpoint(for: featureProvider) - } - - public var apiKey: String { - apiKey(for: featureProvider) + public var model: ChatModel { + let models = UserDefaults.shared.value(for: \.chatModels) + let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId) + return models.first { $0.id == id } + ?? models.first ?? .init(id: "", name: "", format: .openAI, info: .init()) } public var maxTokens: Int { - UserDefaults.shared.value(for: \.chatGPTMaxToken) + model.info.maxTokens } public var stop: [String] { @@ -45,11 +35,9 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public struct Overriding: Codable { - public var featureProvider: ChatFeatureProvider? public var temperature: Double? - public var model: String? - public var endPoint: String? - public var apiKey: String? + public var modelId: String? + public var model: ChatModel? public var stop: [String]? public var maxTokens: Int? public var minimumReplyTokens: Int? @@ -57,23 +45,19 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public init( temperature: Double? = nil, - model: String? = nil, + modelId: String? = nil, + model: ChatModel? = nil, stop: [String]? = nil, maxTokens: Int? = nil, minimumReplyTokens: Int? = nil, - featureProvider: ChatFeatureProvider? = nil, - endPoint: String? = nil, - apiKey: String? = nil, runFunctionsAutomatically: Bool? = nil ) { self.temperature = temperature + self.modelId = modelId self.model = model self.stop = stop self.maxTokens = maxTokens self.minimumReplyTokens = minimumReplyTokens - self.featureProvider = featureProvider - self.endPoint = endPoint - self.apiKey = apiKey self.runFunctionsAutomatically = runFunctionsAutomatically } } @@ -89,28 +73,17 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { self.configuration = configuration } - public var featureProvider: ChatFeatureProvider { - overriding.featureProvider ?? configuration.featureProvider - } - public var temperature: Double { overriding.temperature ?? configuration.temperature } - public var model: String { - overriding.model ?? configuration.model - } - - public var endpoint: String { - overriding.endPoint - ?? overriding.featureProvider.map(endpoint(for:)) - ?? configuration.endpoint - } - - public var apiKey: String { - overriding.apiKey - ?? overriding.featureProvider.map(apiKey(for:)) - ?? configuration.apiKey + public var model: ChatModel { + if let model = overriding.model { return model } + let models = UserDefaults.shared.value(for: \.chatModels) + guard let id = overriding.modelId, + let model = models.first(where: { $0.id == id }) + else { return configuration.model } + return model } public var stop: [String] { diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift index 44a92d3a..396cfd98 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift @@ -1,27 +1,19 @@ +import AIModel import Foundation import Preferences public struct UserPreferenceEmbeddingConfiguration: EmbeddingConfiguration { - public var featureProvider: EmbeddingFeatureProvider { - UserDefaults.shared.value(for: \.embeddingFeatureProvider) - } - - public var model: String { - OpenAIEmbeddingModel.textEmbeddingAda002.rawValue - } - - public var endpoint: String { - endpoint(for: featureProvider) - } - - public var apiKey: String { - apiKey(for: featureProvider) + public var model: EmbeddingModel { + let models = UserDefaults.shared.value(for: \.embeddingModels) + let id = UserDefaults.shared.value(for: \.defaultChatFeatureEmbeddingModelId) + return models.first { $0.id == id } + ?? models.first ?? .init(id: "", name: "", format: .openAI, info: .init()) } public var maxToken: Int { - OpenAIEmbeddingModel.textEmbeddingAda002.maxToken + model.info.maxTokens } - + public init() {} } @@ -29,23 +21,17 @@ public class OverridingEmbeddingConfiguration< Configuration: EmbeddingConfiguration >: EmbeddingConfiguration { public struct Overriding { - var featureProvider: EmbeddingFeatureProvider? - var model: String? - var endPoint: String? - var apiKey: String? - var maxTokens: Int? + public var modelId: String? + public var model: EmbeddingModel? + public var maxTokens: Int? public init( - model: String? = nil, - featureProvider: EmbeddingFeatureProvider? = nil, - endPoint: String? = nil, - apiKey: String? = nil, + modelId: String? = nil, + model: EmbeddingModel? = nil, maxTokens: Int? = nil ) { + self.modelId = modelId self.model = model - self.featureProvider = featureProvider - self.endPoint = endPoint - self.apiKey = apiKey self.maxTokens = maxTokens } } @@ -54,30 +40,19 @@ public class OverridingEmbeddingConfiguration< public var overriding = Overriding() public init(overriding configuration: Configuration, with overrides: Overriding = .init()) { - self.overriding = overrides + overriding = overrides self.configuration = configuration } - public var featureProvider: EmbeddingFeatureProvider { - overriding.featureProvider ?? configuration.featureProvider - } - - public var model: String { - overriding.model ?? configuration.model + public var model: EmbeddingModel { + if let model = overriding.model { return model } + let models = UserDefaults.shared.value(for: \.embeddingModels) + guard let id = overriding.modelId, + let model = models.first(where: { $0.id == id }) + else { return configuration.model } + return model } - public var endpoint: String { - overriding.endPoint - ?? overriding.featureProvider.map(endpoint(for:)) - ?? configuration.endpoint - } - - public var apiKey: String { - overriding.apiKey - ?? overriding.featureProvider.map(apiKey(for:)) - ?? configuration.apiKey - } - public var maxToken: Int { overriding.maxTokens ?? configuration.maxToken } diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index f4f0a713..a17b0863 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -44,17 +44,18 @@ public struct EmbeddingService { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + let model = configuration.model var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() request.httpBody = try encoder.encode(EmbeddingRequestBody( input: text, - model: configuration.model + model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !configuration.apiKey.isEmpty { - switch configuration.featureProvider { - case .openAI: + switch model.format { + case .openAI, .openAICompatible: request.setValue( "Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization" @@ -92,17 +93,18 @@ public struct EmbeddingService { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + let model = configuration.model var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() request.httpBody = try encoder.encode(EmbeddingFromTokensRequestBody( input: tokens, - model: configuration.model + model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !configuration.apiKey.isEmpty { - switch configuration.featureProvider { - case .openAI: + switch model.format { + case .openAI, .openAICompatible: request.setValue( "Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization" diff --git a/Tool/Sources/Preferences/AppStorage.swift b/Tool/Sources/Preferences/AppStorage.swift index 73bcee5f..a5b3b214 100644 --- a/Tool/Sources/Preferences/AppStorage.swift +++ b/Tool/Sources/Preferences/AppStorage.swift @@ -109,17 +109,154 @@ public extension AppStorage where Value: ExpressibleByNilLiteral { public extension AppStorage { init( _ keyPath: KeyPath - ) where K.Value == Value, Value == R?, R : RawRepresentable, R.RawValue == String { + ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == String { let key = UserDefaultPreferenceKeys()[keyPath: keyPath] self.init(key.key, store: .shared) } init( _ keyPath: KeyPath - ) where K.Value == Value, Value == R?, R : RawRepresentable, R.RawValue == Int { + ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +// MARK: - Deprecated Key Accessor + +public extension AppStorage { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Bool { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Double { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == URL { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Data { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value: RawRepresentable, Value.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value: RawRepresentable, Value.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } +} + +public extension AppStorage where Value: ExpressibleByNilLiteral { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Bool? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == String? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Double? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Int? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == URL? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Data? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +public extension AppStorage { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == R?, R: RawRepresentable, R.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == R?, R: RawRepresentable, R.RawValue == Int { let key = UserDefaultPreferenceKeys()[keyPath: keyPath] self.init(key.key, store: .shared) } } #endif + diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 57a4490a..fc739f64 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -1,3 +1,4 @@ +import AIModel import Foundation public protocol UserDefaultPreferenceKey { @@ -16,6 +17,16 @@ public struct PreferenceKey: UserDefaultPreferenceKey { } } +public struct DeprecatedPreferenceKey { + public let defaultValue: T + public let key: String + + public init(defaultValue: T, key: String) { + self.defaultValue = defaultValue + self.key = key + } +} + public struct FeatureFlag: UserDefaultPreferenceKey { public let defaultValue: Bool public let key: String @@ -82,40 +93,23 @@ public struct UserDefaultPreferenceKeys { // MARK: - OpenAI Account Settings public extension UserDefaultPreferenceKeys { - var openAIAPIKey: PreferenceKey { + var openAIAPIKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "OpenAIAPIKey") } - @available(*, deprecated, message: "Use `openAIBaseURL` instead.") - var chatGPTEndpoint: PreferenceKey { - .init(defaultValue: "", key: "ChatGPTEndpoint") - } - - var openAIBaseURL: PreferenceKey { + var openAIBaseURL: DeprecatedPreferenceKey { .init(defaultValue: "", key: "OpenAIBaseURL") } - var chatGPTModel: PreferenceKey { + var chatGPTModel: DeprecatedPreferenceKey { .init(defaultValue: ChatGPTModel.gpt35Turbo.rawValue, key: "ChatGPTModel") } - var chatGPTMaxToken: PreferenceKey { + var chatGPTMaxToken: DeprecatedPreferenceKey { .init(defaultValue: 4000, key: "ChatGPTMaxToken") } - var chatGPTLanguage: PreferenceKey { - .init(defaultValue: "", key: "ChatGPTLanguage") - } - - var chatGPTMaxMessageCount: PreferenceKey { - .init(defaultValue: 5, key: "ChatGPTMaxMessageCount") - } - - var chatGPTTemperature: PreferenceKey { - .init(defaultValue: 0.7, key: "ChatGPTTemperature") - } - - var embeddingModel: PreferenceKey { + var embeddingModel: DeprecatedPreferenceKey { .init( defaultValue: OpenAIEmbeddingModel.textEmbeddingAda002.rawValue, key: "OpenAIEmbeddingModel" @@ -126,19 +120,19 @@ public extension UserDefaultPreferenceKeys { // MARK: - Azure OpenAI Settings public extension UserDefaultPreferenceKeys { - var azureOpenAIAPIKey: PreferenceKey { + var azureOpenAIAPIKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureOpenAIAPIKey") } - var azureOpenAIBaseURL: PreferenceKey { + var azureOpenAIBaseURL: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureOpenAIBaseURL") } - var azureChatGPTDeployment: PreferenceKey { + var azureChatGPTDeployment: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureChatGPTDeployment") } - var azureEmbeddingDeployment: PreferenceKey { + var azureEmbeddingDeployment: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureEmbeddingDeployment") } } @@ -189,6 +183,71 @@ public extension UserDefaultPreferenceKeys { var codeiumVerboseLog: PreferenceKey { .init(defaultValue: false, key: "CodeiumVerboseLog") } + + var codeiumEnterpriseMode: PreferenceKey { + .init(defaultValue: false, key: "CodeiumEnterpriseMode") + } + + var codeiumPortalUrl: PreferenceKey { + .init(defaultValue: "", key: "CodeiumPortalUrl") + } + + var codeiumApiUrl: PreferenceKey { + .init(defaultValue: "", key: "CodeiumApiUrl") + } +} + +// MARK: - Chat Models + +public extension UserDefaultPreferenceKeys { + var chatModels: PreferenceKey<[ChatModel]> { + .init(defaultValue: [ + .init( + id: UUID().uuidString, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: "", + baseURL: "", + maxTokens: ChatGPTModel.gpt35Turbo.maxToken, + supportsFunctionCalling: true, + modelName: ChatGPTModel.gpt35Turbo.rawValue + ) + ), + ], key: "ChatModels") + } + + var chatGPTLanguage: PreferenceKey { + .init(defaultValue: "", key: "ChatGPTLanguage") + } + + var chatGPTMaxMessageCount: PreferenceKey { + .init(defaultValue: 5, key: "ChatGPTMaxMessageCount") + } + + var chatGPTTemperature: PreferenceKey { + .init(defaultValue: 0.7, key: "ChatGPTTemperature") + } +} + +// MARK: - Embedding Models + +public extension UserDefaultPreferenceKeys { + var embeddingModels: PreferenceKey<[EmbeddingModel]> { + .init(defaultValue: [ + .init( + id: UUID().uuidString, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: "", + baseURL: "", + maxTokens: OpenAIEmbeddingModel.textEmbeddingAda002.maxToken, + modelName: OpenAIEmbeddingModel.textEmbeddingAda002.rawValue + ) + ), + ], key: "EmbeddingModels") + } } // MARK: - Prompt to Code @@ -217,7 +276,7 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionToggle: PreferenceKey { .init(defaultValue: true, key: "RealtimeSuggestionToggle") } - + var suggestionDisplayCompactMode: PreferenceKey { .init(defaultValue: false, key: "SuggestionDisplayCompactMode") } @@ -249,7 +308,7 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionDebounce: PreferenceKey { .init(defaultValue: 0, key: "RealtimeSuggestionDebounce") } - + var acceptSuggestionWithTab: PreferenceKey { .init(defaultValue: false, key: "AcceptSuggestionWithTab") } @@ -258,14 +317,22 @@ public extension UserDefaultPreferenceKeys { // MARK: - Chat public extension UserDefaultPreferenceKeys { - var chatFeatureProvider: PreferenceKey { + var chatFeatureProvider: DeprecatedPreferenceKey { .init(defaultValue: .openAI, key: "ChatFeatureProvider") } - var embeddingFeatureProvider: PreferenceKey { + var defaultChatFeatureChatModelId: PreferenceKey { + .init(defaultValue: "", key: "DefaultChatFeatureChatModelId") + } + + var embeddingFeatureProvider: DeprecatedPreferenceKey { .init(defaultValue: .openAI, key: "EmbeddingFeatureProvider") } + var defaultChatFeatureEmbeddingModelId: PreferenceKey { + .init(defaultValue: "", key: "DefaultChatFeatureEmbeddingModelId") + } + var chatFontSize: PreferenceKey { .init(defaultValue: 12, key: "ChatFontSize") } @@ -409,13 +476,20 @@ public extension UserDefaultPreferenceKeys { var enableXcodeInspectorDebugMenu: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-EnableXcodeInspectorDebugMenu") } - + var disableFunctionCalling: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-DisableFunctionCalling") } - + + var useUserDefaultsBaseAPIKeychain: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-UseUserDefaultsBaseAPIKeychain") + } + var disableGitHubCopilotSettingsAutoRefreshOnAppear: FeatureFlag { - .init(defaultValue: false, key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear") + .init( + defaultValue: false, + key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear" + ) } } diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 207edcc5..9370bf6c 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -1,5 +1,11 @@ -import Foundation +import AIModel import Configs +import Foundation + +public protocol UserDefaultsType { + func value(forKey: String) -> Any? + func set(_ value: Any?, forKey: String) +} public extension UserDefaults { static var shared = UserDefaults(suiteName: userDefaultSuiteName)! @@ -13,15 +19,13 @@ public extension UserDefaults { shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue(for: \.runNodeWith, defaultValue: .env) - shared.setupDefaultValue(for: \.openAIBaseURL, defaultValue: { - guard let url = URL(string: shared.value(for: \.chatGPTEndpoint)) else { return "" } - let scheme = url.scheme ?? "https" - guard let host = url.host else { return "" } - return "\(scheme)://\(host)" - }() as String) + shared.setupDefaultValue(for: \.chatModels) + shared.setupDefaultValue(for: \.embeddingModels) } } +extension UserDefaults: UserDefaultsType {} + public protocol UserDefaultsStorable {} extension Int: UserDefaultsStorable {} @@ -51,8 +55,8 @@ extension Array: RawRepresentable where Element: Codable { } } -public extension UserDefaults { - // MARK: - Normal Types +public extension UserDefaultsType { + // MARK: Normal Types func value( for keyPath: KeyPath @@ -77,7 +81,7 @@ public extension UserDefaults { set(key.defaultValue, forKey: key.key) } } - + func setupDefaultValue( for keyPath: KeyPath, defaultValue: K.Value @@ -88,7 +92,7 @@ public extension UserDefaults { } } - // MARK: - Raw Representable + // MARK: Raw Representable func value( for keyPath: KeyPath @@ -146,3 +150,71 @@ public extension UserDefaults { } } } + +// MARK: - Deprecated Key Accessor + +public extension UserDefaultsType { + // MARK: Normal Types + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + return (value(forKey: key.key) as? K) ?? key.defaultValue + } + + // MARK: Raw Representable + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? Int else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } +} + +public extension UserDefaultsType { + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + return (value(forKey: key.key) as? K) ?? key.defaultValue + } + + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } + + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? Int else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } +} + diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index 609bd02b..9ce33e6b 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -7,18 +7,21 @@ public struct CustomTextEditor: NSViewRepresentable { @Binding public var text: String public let font: NSFont + public let isEditable: Bool public let onSubmit: () -> Void public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] public init( text: Binding, font: NSFont, + isEditable: Bool = true, onSubmit: @escaping () -> Void, completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) -> [String] = { _, _, _ in [] } ) { _text = text self.font = font + self.isEditable = isEditable self.onSubmit = onSubmit self.completions = completions } @@ -41,6 +44,7 @@ public struct CustomTextEditor: NSViewRepresentable { public func updateNSView(_ nsView: NSScrollView, context: Context) { context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) + textView.isEditable = isEditable guard textView.string != text else { return } textView.string = text textView.undoManager?.removeAllActions() diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index b7789da6..2d271206 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -4,6 +4,7 @@ import AXExtension import AXNotificationStream import Combine import Foundation +import SuggestionModel public final class XcodeInspector: ObservableObject { public static let shared = XcodeInspector() @@ -23,6 +24,41 @@ public final class XcodeInspector: ObservableObject { @Published public internal(set) var focusedElement: AXUIElement? @Published public internal(set) var completionPanel: AXUIElement? + public var focusedEditorContent: EditorInformation? { + let editorContent = XcodeInspector.shared.focusedEditor?.content + let documentURL = XcodeInspector.shared.activeDocumentURL + let projectURL = XcodeInspector.shared.activeProjectURL + let language = languageIdentifierFromFileURL(documentURL) + let relativePath = documentURL.path + .replacingOccurrences(of: projectURL.path, with: "") + + if let editorContent, let range = editorContent.selections.first { + let (selectedContent, selectedLines) = EditorInformation.code( + in: editorContent.lines, + inside: range + ) + return .init( + editorContent: editorContent, + selectedContent: selectedContent, + selectedLines: selectedLines, + documentURL: documentURL, + projectURL: projectURL, + relativePath: relativePath, + language: language + ) + } + + return .init( + editorContent: editorContent, + selectedContent: "", + selectedLines: [], + documentURL: documentURL, + projectURL: projectURL, + relativePath: relativePath, + language: language + ) + } + public var realtimeActiveDocumentURL: URL { latestActiveXcode?.realtimeDocumentURL ?? URL(fileURLWithPath: "/") } diff --git a/Tool/Tests/KeychainTests/KeychainTests.swift b/Tool/Tests/KeychainTests/KeychainTests.swift new file mode 100644 index 00000000..f5e39dfe --- /dev/null +++ b/Tool/Tests/KeychainTests/KeychainTests.swift @@ -0,0 +1,16 @@ +import Foundation +import XCTest + +@testable import Keychain + +class KeychainTests: XCTestCase { + func test_scope_key() { + let keychain = Keychain(scope: "scope") + XCTAssertEqual(keychain.scopeKey("key"), "scope::key") + } + + func test_escape_scope() { + let keychain = Keychain(scope: "scope") + XCTAssertEqual(keychain.escapeScope("scope::key"), "key") + } +} diff --git a/Version.xcconfig b/Version.xcconfig index c5391dee..987c2122 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,3 @@ -APP_VERSION = 0.22.3 -APP_BUILD = 233 +APP_VERSION = 0.23.1 +APP_BUILD = 240 + diff --git a/appcast.xml b/appcast.xml index e915b77f..ca5e80bf 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.23.1 + Wed, 06 Sep 2023 21:08:26 +0800 + 240 + 0.23.1 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.23.1 + + + + 0.22.3 Sat, 02 Sep 2023 15:51:16 +0800