diff --git a/.gitignore b/.gitignore index c915adee..eb99039a 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,5 @@ Python/site-packages/* !Python/site-packages/install.sh Python/VERSIONS +Copilot for Xcode Plus.xcworkspace +PLUS diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 759f2944..ca8e5bfe 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -175,7 +175,6 @@ C861E61F2994F6390056CB02 /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; }; C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommand.swift; sourceTree = ""; }; C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorCommand.swift; sourceTree = ""; }; - C87903302A5D2E6400FE6F42 /* Pro */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pro; sourceTree = ""; }; C87B03A3293B24AB00C77EAE /* Copilot-for-Xcode-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Copilot-for-Xcode-Info.plist"; sourceTree = SOURCE_ROOT; }; C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptSuggestionCommand.swift; sourceTree = ""; }; C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectSuggestionCommand.swift; sourceTree = ""; }; @@ -273,7 +272,6 @@ C8CD828229B88006008D044D /* TestPlan.xctestplan */, C81D181E2A1B509B006C1B70 /* Tool */, C8189B282938979000C9DCDA /* Core */, - C87903302A5D2E6400FE6F42 /* Pro */, C8189B182938972F00C9DCDA /* Copilot for Xcode */, C81458922939EFDC00135263 /* EditorExtension */, C8216B71298036EC00AD38C7 /* Helper */, diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme index d9c4e45f..2cc5df6c 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme @@ -46,6 +46,9 @@ reference = "container:TestPlan.xctestplan" default = "YES"> + + - - - - - - - - - - - - - - - - - - - - diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotServiceTests.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotServiceTests.xcscheme deleted file mode 100644 index c21246c2..00000000 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/CopilotServiceTests.xcscheme +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme b/Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme deleted file mode 100644 index f548b29e..00000000 --- a/Core/.swiftpm/xcode/xcshareddata/xcschemes/ServiceTests.xcscheme +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/Core/Package.swift b/Core/Package.swift index 081430b7..fc288376 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -171,7 +171,6 @@ let package = Package( name: "ChatService", dependencies: [ "ChatPlugin", - "ChatContextCollector", // plugins "MathChatPlugin", @@ -183,12 +182,15 @@ let package = Package( "ActiveDocumentChatContextCollector", "SystemInfoChatContextCollector", + .product(name: "ChatContextCollector", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "Parsing", package: "swift-parsing"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), - ] + ].pro([ + "ProService", + ]) ), .testTarget(name: "ChatServiceTests", dependencies: ["ChatService"]), .target( @@ -199,16 +201,6 @@ let package = Package( .product(name: "Terminal", package: "Tool"), ] ), - .target( - name: "ChatContextCollector", - dependencies: [ - .product(name: "SuggestionModel", package: "Tool"), - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Environment", package: "Tool"), - .product(name: "OpenAIService", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - ] - ), .target( name: "ChatGPTChatTab", @@ -346,7 +338,7 @@ let package = Package( .target( name: "WebChatContextCollector", dependencies: [ - "ChatContextCollector", + .product(name: "ChatContextCollector", package: "Tool"), .product(name: "LangChain", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "ExternalServices", package: "Tool"), @@ -358,7 +350,7 @@ let package = Package( .target( name: "SystemInfoChatContextCollector", dependencies: [ - "ChatContextCollector", + .product(name: "ChatContextCollector", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), ], path: "Sources/ChatContextCollectors/SystemInfoChatContextCollector" @@ -367,10 +359,11 @@ let package = Package( .target( name: "ActiveDocumentChatContextCollector", dependencies: [ - "ChatContextCollector", + .product(name: "ChatContextCollector", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "FocusedCodeFinder", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), ], path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" ), @@ -412,14 +405,18 @@ func isProIncluded(file: StaticString = #file) -> Bool { let rootURL = fileURL .deletingLastPathComponent() .deletingLastPathComponent() - let folderURL = rootURL.appendingPathComponent("Pro") - if !FileManager.default.fileExists(atPath: folderURL.path) { + let confURL = rootURL.appendingPathComponent("PLUS") + if !FileManager.default.fileExists(atPath: confURL.path) { return false } - let packageManifestURL = folderURL.appendingPathComponent("Package.swift") - if !FileManager.default.fileExists(atPath: packageManifestURL.path) { + do { + let content = String( + data: try Data(contentsOf: confURL), + encoding: .utf8 + ) + return content?.hasPrefix("YES") ?? false + } catch { return false } - return true } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift index 136c35a7..a70efe36 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/ExpandFocusRangeFunction.swift @@ -13,13 +13,11 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { "Editing Document Context is updated to display code at \(range)." } } - + struct E: Error, LocalizedError { var errorDescription: String? } - var reportProgress: (String) async -> Void = { _ in } - var name: String { "expandFocusRange" } @@ -32,18 +30,21 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { .type: "object", .properties: [:], ] } - + weak var contextCollector: ActiveDocumentChatContextCollector? - + init(contextCollector: ActiveDocumentChatContextCollector) { self.contextCollector = contextCollector } - func prepare() async { + func prepare(reportProgress: @escaping (String) async -> Void) async { await reportProgress("Finding the focused code..") } - func call(arguments: Arguments) async throws -> Result { + func call( + arguments: Arguments, + reportProgress: @escaping (String) async -> Void + ) async throws -> Result { await reportProgress("Finding the focused code..") contextCollector?.activeDocumentContext?.expandFocusedRangeToContextRange() guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { @@ -56,3 +57,4 @@ struct ExpandFocusRangeFunction: ChatGPTFunction { return .init(range: newContext.codeRange) } } + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift index 952b9b61..42ee50a2 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToCodeAroundLineFunction.swift @@ -15,13 +15,11 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { "Editing Document Context is updated to display code at \(range)." } } - + struct E: Error, LocalizedError { var errorDescription: String? } - var reportProgress: (String) async -> Void = { _ in } - var name: String { "getCodeAtLine" } @@ -36,7 +34,7 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { "line": [ .type: "number", .description: "The line number in the file", - ] + ], ], .required: ["line"], ] } @@ -47,11 +45,14 @@ struct MoveToCodeAroundLineFunction: ChatGPTFunction { self.contextCollector = contextCollector } - func prepare() async { + func prepare(reportProgress: @escaping (String) async -> Void) async { await reportProgress("Finding code around..") } - func call(arguments: Arguments) async throws -> Result { + func call( + arguments: Arguments, + reportProgress: @escaping (String) async -> Void + ) async throws -> Result { await reportProgress("Finding code around line \(arguments.line)..") contextCollector?.activeDocumentContext?.moveToCodeAroundLine(arguments.line) guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift index af667524..3b3096a2 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/MoveToFocusedCodeFunction.swift @@ -4,7 +4,7 @@ import OpenAIService import SuggestionModel struct MoveToFocusedCodeFunction: ChatGPTFunction { - struct Arguments: Codable {} + typealias Arguments = NoArguments struct Result: ChatGPTFunctionResult { var range: CursorRange @@ -13,13 +13,11 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { "Editing Document Context is updated to display code at \(range)." } } - + struct E: Error, LocalizedError { var errorDescription: String? } - var reportProgress: (String) async -> Void = { _ in } - var name: String { "moveToFocusedCode" } @@ -28,22 +26,20 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { "Move editing document context to the selected or focused code" } - var argumentSchema: JSONSchemaValue { [ - .type: "object", - .properties: [:], - ] } - weak var contextCollector: ActiveDocumentChatContextCollector? - + init(contextCollector: ActiveDocumentChatContextCollector) { self.contextCollector = contextCollector } - func prepare() async { + func prepare(reportProgress: @escaping (String) async -> Void) async { await reportProgress("Finding the focused code..") } - func call(arguments: Arguments) async throws -> Result { + func call( + arguments: Arguments, + reportProgress: @escaping (String) async -> Void + ) async throws -> Result { await reportProgress("Finding the focused code..") contextCollector?.activeDocumentContext?.moveToFocusedCode() guard let newContext = contextCollector?.activeDocumentContext?.focusedContext else { @@ -56,3 +52,4 @@ struct MoveToFocusedCodeFunction: ChatGPTFunction { return .init(range: newContext.codeRange) } } + diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift index ed2b84c0..e4a22903 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/QueryWebsiteFunction.swift @@ -17,8 +17,6 @@ struct QueryWebsiteFunction: ChatGPTFunction { } } - var reportProgress: (String) async -> Void = { _ in } - var name: String { "queryWebsite" } @@ -47,11 +45,14 @@ struct QueryWebsiteFunction: ChatGPTFunction { ] } - func prepare() async { + func prepare(reportProgress: @escaping (String) async -> Void) async { await reportProgress("Reading..") } - func call(arguments: Arguments) async throws -> Result { + func call( + arguments: Arguments, + reportProgress: @escaping (String) async -> Void + ) async throws -> Result { do { let embedding = OpenAIEmbedding(configuration: UserPreferenceEmbeddingConfiguration()) @@ -61,10 +62,13 @@ struct QueryWebsiteFunction: ChatGPTFunction { group.addTask { // 1. grab the website content await reportProgress("Loading \(url)..") - + if let database = await TemporaryUSearch.view(identifier: urlString) { await reportProgress("Getting relevant information..") - let qa = QAInformationRetrievalChain(vectorStore: database, embedding: embedding) + let qa = QAInformationRetrievalChain( + vectorStore: database, + embedding: embedding + ) return try await qa.call(.init(arguments.query)).information } let loader = WebLoader(urls: [url]) @@ -83,7 +87,10 @@ struct QueryWebsiteFunction: ChatGPTFunction { try await database.set(embeddedDocuments) // 4. generate answer await reportProgress("Getting relevant information..") - let qa = QAInformationRetrievalChain(vectorStore: database, embedding: embedding) + let qa = QAInformationRetrievalChain( + vectorStore: database, + embedding: embedding + ) let result = try await qa.call(.init(arguments.query)) return result.information } @@ -101,7 +108,7 @@ struct QueryWebsiteFunction: ChatGPTFunction { .joined(separator: "\n") ) """) - + return all } diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 348753b3..99c88312 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -28,10 +28,8 @@ struct SearchFunction: ChatGPTFunction { }.joined(separator: "\n") } } - - let maxTokens: Int - var reportProgress: (String) async -> Void = { _ in } + let maxTokens: Int var name: String { "searchWeb" @@ -62,11 +60,14 @@ struct SearchFunction: ChatGPTFunction { ] } - func prepare() async { + func prepare(reportProgress: @escaping ReportProgress) async { await reportProgress("Searching..") } - func call(arguments: Arguments) async throws -> Result { + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> Result { await reportProgress("Searching \(arguments.query)") do { @@ -74,7 +75,7 @@ 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: maxTokens > 5000 ? 5 : 3, diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index 6ead6a06..ab9a2aa7 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -4,7 +4,7 @@ import SwiftUI struct ChatTabItemView: View { @ObservedObject var chat: ChatProvider - + var body: some View { Text(chat.title) } @@ -13,6 +13,9 @@ struct ChatTabItemView: View { struct ChatContextMenu: View { @ObservedObject var chat: ChatProvider @AppStorage(\.customCommands) var customCommands + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatModelId + @AppStorage(\.chatGPTTemperature) var defaultTemperature var body: some View { currentSystemPrompt @@ -21,6 +24,11 @@ struct ChatContextMenu: View { Divider() + chatModel + temperature + + Divider() + customCommandMenu } @@ -52,6 +60,89 @@ struct ChatContextMenu: View { } } + @ViewBuilder + var chatModel: some View { + Menu("Chat Model") { + Button(action: { + chat.chatModelId = nil + }) { + HStack { + if let defaultModel = chatModels.first(where: { $0.id == defaultChatModelId }) { + Text("Default (\(defaultModel.name))") + if chat.chatModelId == nil { + Image(systemName: "checkmark") + } + } else { + Text("No Model Available") + } + } + } + + if let id = chat.chatModelId, + !chatModels.map(\.id).contains(id) + { + Button(action: { + chat.chatModelId = nil + chat.objectWillChange.send() + }) { + HStack { + Text("Default (Selected Model Not Found)") + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(chatModels, id: \.id) { model in + Button(action: { + chat.chatModelId = model.id + chat.objectWillChange.send() + }) { + HStack { + Text(model.name) + if model.id == chat.chatModelId { + Image(systemName: "checkmark") + } + } + } + } + } + } + + @ViewBuilder + var temperature: some View { + Menu("Temperature") { + Button(action: { + chat.temperature = nil + }) { + HStack { + Text( + "Default (\(defaultTemperature.formatted(.number.precision(.fractionLength(1)))))" + ) + if chat.temperature == nil { + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(Array(stride(from: 0.0, through: 2.0, by: 0.1)), id: \.self) { value in + Button(action: { + chat.temperature = value + }) { + HStack { + Text("\(value.formatted(.number.precision(.fractionLength(1))))") + if value == chat.temperature { + Image(systemName: "checkmark") + } + } + } + } + } + } + var customCommandMenu: some View { Menu("Custom Commands") { ForEach( @@ -73,3 +164,4 @@ struct ChatContextMenu: View { } } } + diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 8c72e72b..17532b24 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -44,7 +44,7 @@ public class ChatGPTChatTab: ChatTab { public func buildTabItem() -> any View { ChatTabItemView(chat: provider) } - + public func buildMenu() -> any View { ChatContextMenu(chat: provider) } @@ -95,33 +95,42 @@ public class ChatGPTChatTab: ChatTab { public func start() { chatTabViewStore.send(.updateTitle("Chat")) - + service.$systemPrompt.removeDuplicates().sink { _ in Task { @MainActor [weak self] in self?.chatTabViewStore.send(.tabContentUpdated) } }.store(in: &cancellable) - + service.$extraSystemPrompt.removeDuplicates().sink { _ in Task { @MainActor [weak self] in self?.chatTabViewStore.send(.tabContentUpdated) } }.store(in: &cancellable) - + provider.$history.sink { [weak self] _ in Task { @MainActor [weak self] in if let title = self?.provider.title { self?.chatTabViewStore.send(.updateTitle(title)) } - self?.chatTabViewStore.send(.tabContentUpdated) } }.store(in: &cancellable) + + provider.objectWillChange.debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.chatTabViewStore.send(.tabContentUpdated) + } + }.store(in: &cancellable) } } extension ChatProvider { convenience init(service: ChatService) { - self.init(pluginIdentifiers: service.allPluginCommands) + self.init( + configuration: service.configuration, + pluginIdentifiers: service.allPluginCommands + ) let cancellable = service.objectWillChange.sink { [weak self] in guard let self else { return } diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index 147c2446..ad537aa4 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -1,4 +1,5 @@ import AppKit +import OpenAIService import MarkdownUI import SharedUIComponents import SwiftUI @@ -112,7 +113,7 @@ private struct Instruction: View { Markdown( """ Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. - + \( useCodeScopeByDefaultInChatContext ? "Scope **`@code`** is enabled by default." @@ -131,6 +132,7 @@ private struct Instruction: View { | `@file` | Read the metadata of the editing file | | `@code` | Read the code and metadata in the editing file | | `@web` (beta) | Search on Bing or query from a web page | + | `@project` | Experimental. Access content of the project | To use scopes, you can prefix a message with `@code`. @@ -398,7 +400,7 @@ struct ChatPanelInputArea: View { EmptyView() } .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - + Button(action: { isInputAreaFocused = true }) { @@ -580,6 +582,7 @@ struct ChatPanel_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( + configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), history: ChatPanel_Preview.history, isReceivingMessage: true )) @@ -591,6 +594,7 @@ struct ChatPanel_Preview: PreviewProvider { struct ChatPanel_EmptyChat_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( + configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), history: [], isReceivingMessage: false )) @@ -623,6 +627,7 @@ struct ChatCodeSyntaxHighlighter: CodeSyntaxHighlighter { struct ChatPanel_InputText_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( + configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), history: ChatPanel_Preview.history, isReceivingMessage: false )) @@ -636,6 +641,7 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { static var previews: some View { ChatPanel( chat: .init( + configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), history: ChatPanel_Preview.history, isReceivingMessage: false ), @@ -650,6 +656,7 @@ struct ChatPanel_InputMultilineText_Preview: PreviewProvider { struct ChatPanel_Light_Preview: PreviewProvider { static var previews: some View { ChatPanel(chat: .init( + configuration: UserPreferenceChatGPTConfiguration().overriding(.init()), history: ChatPanel_Preview.history, isReceivingMessage: true )) diff --git a/Core/Sources/ChatGPTChatTab/ChatProvider.swift b/Core/Sources/ChatGPTChatTab/ChatProvider.swift index 03768e98..cce47476 100644 --- a/Core/Sources/ChatGPTChatTab/ChatProvider.swift +++ b/Core/Sources/ChatGPTChatTab/ChatProvider.swift @@ -8,8 +8,28 @@ public final class ChatProvider: ObservableObject { public let id = UUID() @Published public var history: [ChatMessage] = [] @Published public var isReceivingMessage = false + public var temperature: Double? { + get { + configuration.overriding.temperature + } + set { + configuration.overriding.temperature = newValue + objectWillChange.send() + } + } + public var chatModelId: String? { + get { + configuration.overriding.modelId + } + set { + configuration.overriding.modelId = newValue + objectWillChange.send() + } + } + private let configuration: OverridingChatGPTConfiguration public var pluginIdentifiers: [String] = [] public var systemPrompt = "" + public var title: String { let defaultTitle = "Chat" guard let lastMessageText = history @@ -38,6 +58,7 @@ public final class ChatProvider: ObservableObject { public var onSetAsExtraPrompt: (MessageID) -> Void public init( + configuration: OverridingChatGPTConfiguration, history: [ChatMessage] = [], isReceivingMessage: Bool = false, pluginIdentifiers: [String] = [], @@ -50,6 +71,7 @@ public final class ChatProvider: ObservableObject { onRunCustomCommand: @escaping (CustomCommand) -> Void = { _ in }, onSetAsExtraPrompt: @escaping (MessageID) -> Void = { _ in } ) { + self.configuration = configuration self.history = history self.isReceivingMessage = isReceivingMessage self.pluginIdentifiers = pluginIdentifiers diff --git a/Core/Sources/ChatService/AllContextCollector.swift b/Core/Sources/ChatService/AllContextCollector.swift index 600c8f74..ec365e9d 100644 --- a/Core/Sources/ChatService/AllContextCollector.swift +++ b/Core/Sources/ChatService/AllContextCollector.swift @@ -2,10 +2,19 @@ import ActiveDocumentChatContextCollector import ChatContextCollector import SystemInfoChatContextCollector import WebChatContextCollector - +#if canImport(ProChatContextCollectors) +import ProChatContextCollectors +let allContextCollectors: [any ChatContextCollector] = [ + SystemInfoChatContextCollector(), + ActiveDocumentChatContextCollector(), + WebChatContextCollector(), + ProChatContextCollectors(), +] +#else let allContextCollectors: [any ChatContextCollector] = [ SystemInfoChatContextCollector(), ActiveDocumentChatContextCollector(), WebChatContextCollector(), ] +#endif diff --git a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift b/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift index 5f611b01..e2f565d7 100644 --- a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift +++ b/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift @@ -34,7 +34,7 @@ struct CustomCommandTemplateProcessor { func getEditorInformation() -> EditorInformation { let editorContent = XcodeInspector.shared.focusedEditor?.content let documentURL = XcodeInspector.shared.activeDocumentURL - let language = languageIdentifierFromFileURL(documentURL) + let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext return .init( editorContent: editorContent, diff --git a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift index 4ade251d..27d15ad0 100644 --- a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift +++ b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.2.85" + static let latestSupportedVersion = "1.2.93" public init() {} diff --git a/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift index 79d98d19..f2837db6 100644 --- a/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift +++ b/Core/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift @@ -10,7 +10,7 @@ public struct GitHubCopilotInstallationManager { return URL(string: link)! } - static let latestSupportedVersion = "1.10.2" + static let latestSupportedVersion = "1.10.3" public init() {} diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 2427b442..4a815566 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -115,6 +115,13 @@ struct ChatModelEditView: View { "Supports Function Calling", isOn: viewStore.$supportsFunctionCalling ) + + Text( + "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors." + ) + .foregroundColor(.secondary) + .font(.callout) + .dynamicHeightTextInFormWorkaround() } } diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift index 2ca683c4..7c7b12cb 100644 --- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift +++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift @@ -208,8 +208,6 @@ struct CodeiumView: View { } } - Divider() - Form { Toggle("Codeium Enterprise Mode", isOn: $viewModel.codeiumEnterpriseMode) TextField("Codeium Portal URL", text: $viewModel.codeiumPortalUrl) diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 142be6ca..b94d8ae1 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -15,6 +15,7 @@ final class DebugSettings: ObservableObject { @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) var disableGitHubCopilotSettingsAutoRefreshOnAppear @AppStorage(\.useUserDefaultsBaseAPIKeychain) var useUserDefaultsBaseAPIKeychain + @AppStorage(\.disableEnhancedWorkspace) var disableEnhancedWorkspace init() {} } @@ -59,6 +60,10 @@ struct DebugSettingsView: View { Text("Store API keys in UserDefaults") } + Toggle(isOn: $settings.disableEnhancedWorkspace) { + Text("Disable Enhanced Workspace") + } + Button("Reset Migration Version to 0") { UserDefaults.shared.set(nil, forKey: "OldMigrationVersion") } diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index 6a4c83fc..880ab20d 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -44,7 +44,7 @@ struct ChatSettingsView: View { var chatSettingsForm: some View { Form { Picker( - "Chat Feature Provider", + "Chat Model", selection: $settings.defaultChatFeatureChatModelId ) { if !settings.chatModels @@ -63,7 +63,7 @@ struct ChatSettingsView: View { } Picker( - "Embedding Feature Provider", + "Embedding Model", selection: $settings.defaultChatFeatureEmbeddingModelId ) { if !settings.embeddingModels diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift index 126c19ad..de42264b 100644 --- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift @@ -10,6 +10,13 @@ struct PromptToCodeSettingsView: View { var promptToCodeGenerateDescription @AppStorage(\.promptToCodeGenerateDescriptionInUserPreferredLanguage) var promptToCodeGenerateDescriptionInUserPreferredLanguage + @AppStorage(\.promptToCodeChatModelId) + var promptToCodeChatModelId + @AppStorage(\.promptToCodeEmbeddingModelId) + var promptToCodeEmbeddingModelId + + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.embeddingModels) var embeddingModels init() {} } @@ -18,6 +25,50 @@ struct PromptToCodeSettingsView: View { var body: some View { VStack(alignment: .center) { Form { + Picker( + "Chat Model", + selection: $settings.promptToCodeChatModelId + ) { + Text("Same as Chat Feature").tag("") + + if !settings.chatModels + .contains(where: { $0.id == settings.promptToCodeChatModelId }), + !settings.promptToCodeChatModelId.isEmpty + { + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.promptToCodeChatModelId) + } + + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } + } + + Picker( + "Embedding Model", + selection: $settings.promptToCodeEmbeddingModelId + ) { + Text("Same as Chat Feature").tag("") + + if !settings.embeddingModels + .contains(where: { $0.id == settings.promptToCodeEmbeddingModelId }), + !settings.promptToCodeEmbeddingModelId.isEmpty + { + Text( + (settings.embeddingModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.promptToCodeEmbeddingModelId) + } + + ForEach(settings.embeddingModels, id: \.id) { embeddingModel in + Text(embeddingModel.name).tag(embeddingModel.id) + } + } + Toggle(isOn: $settings.promptToCodeGenerateDescription) { Text("Generate Description") } diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 064399fa..57c48195 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -42,7 +42,8 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { selectedContent: code, selectedLines: [], documentURL: source.documentURL, - projectURL: source.projectRootURL, + workspaceURL: source.projectRootURL, + projectRootURL: source.projectRootURL, relativePath: "", language: source.language ) @@ -170,8 +171,9 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { What is your requirement? """ - let configuration = UserPreferenceChatGPTConfiguration() - .overriding(.init(temperature: 0)) + let configuration = + UserPreferenceChatGPTConfiguration(chatModelKey: \.promptToCodeChatModelId) + .overriding(.init(temperature: 0)) let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, configuration: configuration, diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift index b574ff5b..6164f7c9 100644 --- a/Core/Sources/Service/GUI/ChatTabFactory.swift +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -45,11 +45,10 @@ enum ChatTabFactory { let content = editor.content return .init( selectedText: content.selectedContent, - language: languageIdentifierFromFileURL( - XcodeInspector.shared - .activeDocumentURL - ) - .rawValue, + language: ( + XcodeInspector.shared.activeDocumentURL + .map(languageIdentifierFromFileURL) ?? .plaintext + ).rawValue, fileContent: content.content ) }, diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index a6517f51..8b449020 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -19,7 +19,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { { return .init( code: suggestion.text, - language: filespace.language, + language: filespace.language.rawValue, startLineIndex: suggestion.position.line, suggestionCount: filespace.suggestions.count, currentSuggestionIndex: filespace.suggestionIndex, diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 0d631e07..e0069d84 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -109,8 +109,9 @@ public actor RealtimeSuggestionController { await self.triggerPrefetchDebounced() await self.notifyEditingFileChange(editor: focusElement) case kAXSelectedTextChangedNotification: - guard let sourceEditor = await sourceEditor else { continue } - let fileURL = XcodeInspector.shared.activeDocumentURL + guard let sourceEditor = await sourceEditor, + let fileURL = XcodeInspector.shared.activeDocumentURL + else { continue } await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor @@ -192,10 +193,10 @@ public actor RealtimeSuggestionController { func notifyEditingFileChange(editor: AXUIElement) async { guard let fileURL = try? await Environment.fetchCurrentFileURL(), - let (workspace, filespace) = try? await Service.shared.workspacePool + let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } - workspace.suggestionPlugin?.notifyUpdateFile(filespace: filespace, content: editor.value) + await workspace.didUpdateFilespace(fileURL: fileURL, content: editor.value) } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 0f345bf3..9c586e6b 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,8 +1,11 @@ +import Dependencies import Foundation +import Workspace + #if canImport(KeyBindingManager) +import EnhancedWorkspace import KeyBindingManager #endif -import Workspace @globalActor public enum ServiceActor { public actor TheActor {} @@ -14,14 +17,7 @@ public final class Service { public static let shared = Service() @WorkspaceActor - let workspacePool = { - let it = WorkspacePool() - it.registerPlugin { - SuggestionServiceWorkspacePlugin(workspace: $0) - } - return it - }() - + let workspacePool: WorkspacePool @MainActor public let guiController = GraphicalUserInterfaceController() public let realtimeSuggestionController = RealtimeSuggestionController() @@ -31,6 +27,8 @@ public final class Service { #endif private init() { + @Dependency(\.workspacePool) var workspacePool + scheduledCleaner = .init(workspacePool: workspacePool, guiController: guiController) #if canImport(KeyBindingManager) keyBindingManager = .init( @@ -42,6 +40,15 @@ public final class Service { } ) #endif + + workspacePool.registerPlugin { SuggestionServiceWorkspacePlugin(workspace: $0) } + #if canImport(EnhancedWorkspace) + if !UserDefaults.shared.value(for: \.disableEnhancedWorkspace) { + workspacePool.registerPlugin { EnhancedWorkspacePlugin(workspace: $0) } + } + #endif + + self.workspacePool = workspacePool } @MainActor diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index f7cd2227..91c72710 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -410,7 +410,8 @@ extension WindowBaseCommandHandler { let viewStore = Service.shared.guiController.viewStore _ = await Task { @MainActor in - viewStore.send(.promptToCodeGroup(.createPromptToCode(.init( + // if there is already a prompt to code presenting, we should not present another one + viewStore.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( code: code, selectionRange: selection, language: codeLanguage, diff --git a/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift b/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift index a9382dd4..9d8437e5 100644 --- a/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift +++ b/Core/Sources/Service/WorkspaceExtension/Filespace+SuggestionService.swift @@ -13,6 +13,7 @@ struct FilespaceSuggestionSnapshotKey: FilespacePropertyKey { } extension FilespacePropertyValues { + @WorkspaceActor var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { get { self[FilespaceSuggestionSnapshotKey.self] } set { self[FilespaceSuggestionSnapshotKey.self] = newValue } @@ -20,6 +21,7 @@ extension FilespacePropertyValues { } extension Filespace { + @WorkspaceActor func resetSnapshot() { // swiftformat:disable redundantSelf self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() diff --git a/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift b/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift index 94f54db4..9dd2d63f 100644 --- a/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift +++ b/Core/Sources/Service/WorkspaceExtension/SuggestionWorkspacePlugin.swift @@ -57,18 +57,6 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { return true } - func canAutoTriggerGetSuggestions( - forFileAt fileURL: URL, - lines: [String], - cursorPosition: CursorPosition - ) -> Bool { - guard isRealtimeSuggestionEnabled else { return false } - guard let filespace = filespaces[fileURL] else { return true } - if lines.hashValue != filespace.suggestionSourceSnapshot.linesHash { return true } - if cursorPosition != filespace.suggestionSourceSnapshot.cursorPosition { return true } - return false - } - override init(workspace: Workspace) { super.init(workspace: workspace) @@ -85,6 +73,10 @@ final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { override func didSaveFilespace(_ filespace: Filespace) { notifySaveFile(filespace: filespace) } + + override func didUpdateFilespace(_ filespace: Filespace, content: String) { + notifyUpdateFile(filespace: filespace, content: content) + } override func didCloseFilespace(_ fileURL: URL) { Task { diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index df6fbdb7..e090a676 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -52,9 +52,9 @@ public struct PanelFeature: ReducerProtocol { switch action { case .presentSuggestion: return .run { send in - guard let provider = await fetchSuggestionProvider( - fileURL: xcodeInspector.activeDocumentURL - ) else { return } + guard let fileURL = xcodeInspector.activeDocumentURL, + let provider = await fetchSuggestionProvider(fileURL: fileURL) + else { return } await send(.presentSuggestionProvider(provider, displayContent: true)) } @@ -96,7 +96,7 @@ public struct PanelFeature: ReducerProtocol { case .switchToAnotherEditorAndUpdateContent: state.content.error = nil return .run { send in - let fileURL = xcodeInspector.activeDocumentURL + guard let fileURL = xcodeInspector.realtimeActiveDocumentURL else { return } if let suggestion = await fetchSuggestionProvider(fileURL: fileURL) { await send(.presentSuggestionProvider(suggestion, displayContent: false)) } @@ -114,7 +114,8 @@ public struct PanelFeature: ReducerProtocol { state.content.suggestion = nil return .none - case .sharedPanel(.promptToCodeGroup(.createPromptToCode)): + case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), + .sharedPanel(.promptToCodeGroup(.createPromptToCode)): let hasPromptToCode = state.content.promptToCode != nil return .run { send in await send(.displayPanelContent) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index d6a05d69..0680030c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -67,6 +67,8 @@ public struct PromptToCodeGroup: ReducerProtocol { } public enum Action: Equatable { + /// Activate the prompt to code if it exists or create it if it doesn't + case activateOrCreatePromptToCode(PromptToCodeInitialState) case createPromptToCode(PromptToCodeInitialState) case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange) case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID) @@ -80,6 +82,13 @@ public struct PromptToCodeGroup: ReducerProtocol { public var body: some ReducerProtocol { Reduce { state, action in switch action { + case let .activateOrCreatePromptToCode(s): + guard state.activePromptToCode == nil else { + return .none + } + return .run { send in + await send(.createPromptToCode(s)) + } case let .createPromptToCode(s): let newPromptToCode = PromptToCode.State( code: s.code, diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 11dbf5f8..ed37d3f3 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -10,7 +10,7 @@ struct WidgetView: View { @State var isHovering: Bool = false var onOpenChatClicked: () -> Void = {} var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } - + @AppStorage(\.hideCircularWidget) var hideCircularWidget var body: some View { @@ -216,8 +216,9 @@ extension WidgetContextMenu { @ViewBuilder var enableSuggestionForProject: some View { WithViewStore(store) { _ in - let projectPath = xcodeInspector.activeProjectURL.path - if disableSuggestionFeatureGlobally { + if let projectPath = xcodeInspector.activeProjectRootURL?.path, + disableSuggestionFeatureGlobally + { let matchedPath = suggestionFeatureEnabledProjectList.first { path in projectPath.hasPrefix(path) } @@ -243,7 +244,7 @@ extension WidgetContextMenu { var disableSuggestionForLanguage: some View { WithViewStore(store) { _ in let fileURL = xcodeInspector.activeDocumentURL - let fileLanguage = languageIdentifierFromFileURL(fileURL) + let fileLanguage = fileURL.map(languageIdentifierFromFileURL) ?? .plaintext let matched = suggestionFeatureDisabledLanguageList.first { rawValue in fileLanguage.rawValue == rawValue } diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index a3a26425..7951c756 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -95,8 +95,12 @@ extension AppDelegate: NSMenuDelegate { case xcodeInspectorDebugMenuIdentifier: let inspector = XcodeInspector.shared menu.items.removeAll() - menu.items.append(.text("Active Project: \(inspector.activeProjectURL)")) - menu.items.append(.text("Active Document: \(inspector.activeDocumentURL)")) + menu.items + .append(.text("Active Project: \(inspector.activeProjectRootURL?.path ?? "N/A")")) + menu.items + .append(.text("Active Workspace: \(inspector.activeWorkspaceURL?.path ?? "N/A")")) + menu.items + .append(.text("Active Document: \(inspector.activeDocumentURL?.path ?? "N/A")")) for xcode in inspector.xcodes { let item = NSMenuItem( title: "Xcode \(xcode.runningApplication.processIdentifier)", @@ -107,8 +111,12 @@ extension AppDelegate: NSMenuDelegate { let xcodeMenu = NSMenu() item.submenu = xcodeMenu xcodeMenu.items.append(.text("Is Active: \(xcode.isActive)")) - xcodeMenu.items.append(.text("Active Project: \(xcode.projectURL)")) - xcodeMenu.items.append(.text("Active Document: \(xcode.documentURL)")) + xcodeMenu.items + .append(.text("Active Project: \(xcode.projectRootURL?.path ?? "N/A")")) + xcodeMenu.items + .append(.text("Active Workspace: \(xcode.workspaceURL?.path ?? "N/A")")) + xcodeMenu.items + .append(.text("Active Document: \(xcode.documentURL?.path ?? "N/A")")) for (key, workspace) in xcode.realtimeWorkspaces { let workspaceItem = NSMenuItem( diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift deleted file mode 100644 index a50a91ab..00000000 --- a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/Contents.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import SwiftUI -import AppKit -import ASTParser -import PlaygroundSupport - -struct ParsingForm: View { - @State var filePath: String = "" - @State var result: String = "" - - var body: some View { - Form { - Section("Input") { - TextField("File Path", text: $filePath) - Button("Parse") { - result = "" - Task { - do { - let fileContent = try String(contentsOfFile: filePath) - let parser = ASTParser(language: .swift) - let tree = parser.parse(fileContent) - result = tree?.dump() ?? "N/A" - print(result) - } catch { - result = error.localizedDescription - } - } - } - } - - Section("Result") { - Text(result) - .fontDesign(.monospaced) - .textSelection(.enabled) - } - } - .formStyle(.grouped) - .frame(width: 600, height: 800) - } -} - -PlaygroundPage.current.needsIndefiniteExecution = true -PlaygroundPage.current.setLiveView(NSHostingController(rootView: ParsingForm())) -// protocol_declaration, class_declaration, function_declaration, property_declaration, computed_property -// type_identifier, simple_identifier (for variables and funcs) diff --git a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline b/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline deleted file mode 100644 index 9d435df4..00000000 --- a/Playground.playground/Pages/ASTParsing.xcplaygroundpage/timeline.xctimeline +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - diff --git a/Pro b/Pro index 90d5c1c3..6b43f38a 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 90d5c1c304b671241a6fb3deaec99c2997fcb943 +Subproject commit 6b43f38aaa12fab3c051eb94c20a8d7192a6020b diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 9b31428f..77034ff3 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -99,13 +99,6 @@ "name" : "SharedUIComponentsTests" } }, - { - "target" : { - "containerPath" : "container:Pro", - "identifier" : "LicenseManagementTests", - "name" : "LicenseManagementTests" - } - }, { "target" : { "containerPath" : "container:Core", @@ -120,13 +113,6 @@ "name" : "ASTParserTests" } }, - { - "target" : { - "containerPath" : "container:Pro", - "identifier" : "ChatTabPersistentTests", - "name" : "ChatTabPersistentTests" - } - }, { "target" : { "containerPath" : "container:Core", diff --git a/Tool/Package.swift b/Tool/Package.swift index 7868368c..5f5218a6 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -14,6 +14,7 @@ let package = Package( .library(name: "Logger", targets: ["Logger"]), .library(name: "OpenAIService", targets: ["OpenAIService"]), .library(name: "ChatTab", targets: ["ChatTab"]), + .library(name: "ChatContextCollector", targets: ["ChatContextCollector"]), .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), .library(name: "ASTParser", targets: ["ASTParser"]), @@ -188,6 +189,7 @@ let package = Package( "Environment", "Logger", "Preferences", + "XcodeInspector" ] ), @@ -214,6 +216,14 @@ let package = Package( ] ), + .target( + name: "ChatContextCollector", + dependencies: [ + "SuggestionModel", + "OpenAIService", + ] + ), + .target(name: "BingSearchService"), // MARK: - OpenAI diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift index 174280d8..f690980e 100644 --- a/Tool/Sources/AIModel/EmbeddingModel.swift +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -29,6 +29,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { public var baseURL: String @FallbackDecoding public var maxTokens: Int + @FallbackDecoding + public var dimensions: Int @FallbackDecoding public var modelName: String public var azureOpenAIDeploymentName: String { @@ -40,11 +42,13 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { apiKeyName: String = "", baseURL: String = "", maxTokens: Int = 8192, + dimensions: Int = 1536, modelName: String = "" ) { self.apiKeyName = apiKeyName self.baseURL = baseURL self.maxTokens = maxTokens + self.dimensions = dimensions self.modelName = modelName } } diff --git a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift index 9dc7e6ee..bc7167b6 100644 --- a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift +++ b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift @@ -20,6 +20,8 @@ public final class ActiveApplicationMonitor { private var continuations: [UUID: AsyncStream.Continuation] = [:] private init() { + activeApplication = NSWorkspace.shared.runningApplications.first(where: \.isActive) + Task { let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.didActivateApplicationNotification) diff --git a/Core/Sources/ChatContextCollector/ChatContextCollector.swift b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift similarity index 100% rename from Core/Sources/ChatContextCollector/ChatContextCollector.swift rename to Tool/Sources/ChatContextCollector/ChatContextCollector.swift diff --git a/Tool/Sources/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift index 97b762fe..f1315daf 100644 --- a/Tool/Sources/Environment/Environment.swift +++ b/Tool/Sources/Environment/Environment.swift @@ -42,7 +42,8 @@ public enum Environment { } } - public static var fetchCurrentProjectRootURLFromXcode: () async throws -> URL? = { + #warning("TODO: Use XcodeInspector instead.") + public static var fetchCurrentWorkspaceURLFromXcode: () async throws -> URL? = { if let xcode = ActiveApplicationMonitor.shared.activeXcode ?? ActiveApplicationMonitor.shared.latestXcode { @@ -53,11 +54,6 @@ public enum Environment { let path = child.description let trimmedNewLine = path.trimmingCharacters(in: .newlines) var url = URL(fileURLWithPath: trimmedNewLine) - while !FileManager.default.fileIsDirectory(atPath: url.path) || - !url.pathExtension.isEmpty - { - url = url.deletingLastPathComponent() - } return url } } @@ -66,21 +62,40 @@ public enum Environment { return nil } + public static var fetchCurrentProjectRootURLFromXcode: () async throws -> URL? = { + if var url = try await fetchCurrentWorkspaceURLFromXcode() { + return try await guessProjectRootURLForFile(url) + } + + return nil + } + + #warning("TODO: Use WorkspaceXcodeWindowInspector.extractProjectURL instead.") public static var guessProjectRootURLForFile: (_ fileURL: URL) async throws -> URL = { fileURL in var currentURL = fileURL var firstDirectoryURL: URL? + var lastGitDirectoryURL: URL? while currentURL.pathComponents.count > 1 { defer { currentURL.deleteLastPathComponent() } guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue } + guard currentURL.pathExtension != "xcodeproj" else { continue } + guard currentURL.pathExtension != "xcworkspace" else { continue } + guard currentURL.pathExtension != "playground" else { continue } if firstDirectoryURL == nil { firstDirectoryURL = currentURL } let gitURL = currentURL.appendingPathComponent(".git") if FileManager.default.fileIsDirectory(atPath: gitURL.path) { - return currentURL + lastGitDirectoryURL = currentURL + } else if let text = try? String(contentsOf: gitURL) { + if !text.hasPrefix("gitdir: ../"), // it's not a sub module + text.range(of: "/.git/worktrees/") != nil // it's a git worktree + { + lastGitDirectoryURL = currentURL + } } } - return firstDirectoryURL ?? fileURL + return lastGitDirectoryURL ?? firstDirectoryURL ?? fileURL } public static var fetchCurrentFileURL: () async throws -> URL = { @@ -250,7 +265,7 @@ func runAppleScript(_ appleScript: String) async throws -> String { } } -extension FileManager { +public extension FileManager { func fileIsDirectory(atPath path: String) -> Bool { var isDirectory: ObjCBool = false let exists = fileExists(atPath: path, isDirectory: &isDirectory) diff --git a/Tool/Sources/LangChain/Agent.swift b/Tool/Sources/LangChain/Agent.swift index daf954a7..0048c1f5 100644 --- a/Tool/Sources/LangChain/Agent.swift +++ b/Tool/Sources/LangChain/Agent.swift @@ -21,18 +21,18 @@ public struct AgentAction: Equatable { } public extension CallbackEvents { - struct AgentDidFinish: CallbackEvent { - public let info: AgentFinish + struct AgentDidFinish: CallbackEvent { + public let info: AgentFinish } - - var agentDidFinish: AgentDidFinish.Type { - AgentDidFinish.self + + static func agentDidFinish() -> AgentDidFinish.Type { + AgentDidFinish.self } struct AgentActionDidStart: CallbackEvent { public let info: AgentAction } - + var agentActionDidStart: AgentActionDidStart.Type { AgentActionDidStart.self } @@ -40,46 +40,64 @@ public extension CallbackEvents { struct AgentActionDidEnd: CallbackEvent { public let info: AgentAction } - + var agentActionDidEnd: AgentActionDidEnd.Type { AgentActionDidEnd.self } + + struct AgentFunctionCallingToolReportProgress: CallbackEvent { + public struct Info { + public let functionName: String + public let progress: String + } + + public let info: Info + } + + var agentFunctionCallingToolReportProgress: AgentFunctionCallingToolReportProgress.Type { + AgentFunctionCallingToolReportProgress.self + } } -public struct AgentFinish: Equatable { - public var returnValue: String +public struct AgentFinish { + public enum ReturnValue { + case structured(Output) + case unstructured(String) + } + + public var returnValue: ReturnValue public var log: String - public init(returnValue: String, log: String) { + public init(returnValue: ReturnValue, log: String) { self.returnValue = returnValue self.log = log } } -public enum AgentNextStep: Equatable { +extension AgentFinish.ReturnValue: Equatable where Output: Equatable {} + +extension AgentFinish: Equatable where Output: Equatable {} + +public enum AgentNextStep { case actions([AgentAction]) - case finish(AgentFinish) + case finish(AgentFinish) } -public enum AgentScratchPad: Equatable { - case text(String) - case messages([String]) +extension AgentNextStep: Equatable where Output: Equatable {} - var isEmpty: Bool { - switch self { - case let .text(text): - return text.isEmpty - case let .messages(messages): - return messages.isEmpty - } +public struct AgentScratchPad: Equatable { + public var content: Content + + public init(content: Content) { + self.content = content } } -public struct AgentInput { - var input: T - var thoughts: AgentScratchPad +public struct AgentInput { + public var input: T + public var thoughts: AgentScratchPad - public init(input: T, thoughts: AgentScratchPad) { + public init(input: T, thoughts: AgentScratchPad) { self.input = input self.thoughts = thoughts } @@ -94,17 +112,24 @@ public enum AgentEarlyStopHandleType: Equatable { public protocol Agent { associatedtype Input - var chatModelChain: ChatModelChain> { get } - var observationPrefix: String { get } - var llmPrefix: String { get } + associatedtype Output: AgentOutputParsable + associatedtype ScratchPadContent: Equatable + var chatModelChain: ChatModelChain> { get } func validateTools(tools: [AgentTool]) throws - func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad - func parseOutput(_ output: String) -> AgentNextStep + func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad + func constructFinalScratchpad(intermediateSteps: [AgentAction]) + -> AgentScratchPad + func extraPlan(input: AgentInput) + func parseOutput(_ output: ChatModelChain>.Output) async + -> AgentNextStep } public extension Agent { - func getFullInputs(input: Input, intermediateSteps: [AgentAction]) -> AgentInput { + func getFullInputs( + input: Input, + intermediateSteps: [AgentAction] + ) -> AgentInput { let thoughts = constructScratchpad(intermediateSteps: intermediateSteps) return AgentInput(input: input, thoughts: thoughts) } @@ -113,10 +138,11 @@ public extension Agent { input: Input, intermediateSteps: [AgentAction], callbackManagers: [CallbackManager] - ) async throws -> AgentNextStep { + ) async throws -> AgentNextStep { let input = getFullInputs(input: input, intermediateSteps: intermediateSteps) + extraPlan(input: input) let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) - return parseOutput(output.content ?? "") + return await parseOutput(output) } func returnStoppedResponse( @@ -124,42 +150,28 @@ public extension Agent { earlyStoppedHandleType: AgentEarlyStopHandleType, intermediateSteps: [AgentAction], callbackManagers: [CallbackManager] - ) async throws -> AgentFinish { + ) async throws -> AgentFinish { switch earlyStoppedHandleType { case .force: return AgentFinish( - returnValue: "Agent stopped due to iteration limit or time limit.", + returnValue: .unstructured("Agent stopped due to iteration limit or time limit."), log: "" ) case .generate: - var thoughts = constructBaseScratchpad(intermediateSteps: intermediateSteps) - thoughts += """ - - \(llmPrefix)I now need to return a final answer based on the previous steps: - (Please continue with `Final Answer:`) - """ - let input = AgentInput(input: input, thoughts: .text(thoughts)) + let thoughts = constructFinalScratchpad(intermediateSteps: intermediateSteps) + let input = AgentInput(input: input, thoughts: thoughts) let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) - let reply = output.content ?? "" - let nextAction = parseOutput(reply) + let nextAction = await parseOutput(output) switch nextAction { case let .finish(finish): return finish case .actions: - return AgentFinish(returnValue: reply, log: reply) + return .init( + returnValue: .unstructured(output.content ?? ""), + log: output.content ?? "" + ) } } } - - func constructBaseScratchpad(intermediateSteps: [AgentAction]) -> String { - var thoughts = "" - for step in intermediateSteps { - thoughts += """ - \(step.log) - \(observationPrefix)\(step.observation ?? "") - """ - } - return thoughts - } } diff --git a/Tool/Sources/LangChain/AgentExecutor.swift b/Tool/Sources/LangChain/AgentExecutor.swift index 40540f79..cfa9d4af 100644 --- a/Tool/Sources/LangChain/AgentExecutor.swift +++ b/Tool/Sources/LangChain/AgentExecutor.swift @@ -1,9 +1,13 @@ import Foundation -public actor AgentExecutor: Chain where InnerAgent.Input == String { +public actor AgentExecutor: Chain + where InnerAgent.Input == String, InnerAgent.Output: AgentOutputParsable +{ public typealias Input = String public struct Output { - let finalOutput: String + public typealias FinalOutput = AgentFinish.ReturnValue + + public let finalOutput: FinalOutput let intermediateSteps: [AgentAction] } @@ -14,19 +18,22 @@ public actor AgentExecutor: Chain where InnerAgent.Input == S var earlyStopHandleType: AgentEarlyStopHandleType var now: () -> Date = { Date() } var isCancelled = false + var initialSteps: [AgentAction] public init( agent: InnerAgent, tools: [AgentTool], maxIteration: Int? = 10, maxExecutionTime: Double? = nil, - earlyStopHandleType: AgentEarlyStopHandleType = .force + earlyStopHandleType: AgentEarlyStopHandleType = .generate, + initialSteps: [AgentAction] = [] ) { self.agent = agent self.tools = tools.reduce(into: [:]) { $0[$1.name] = $1 } self.maxIteration = maxIteration self.maxExecutionTime = maxExecutionTime self.earlyStopHandleType = earlyStopHandleType + self.initialSteps = initialSteps } public func callLogic( @@ -37,7 +44,7 @@ public actor AgentExecutor: Chain where InnerAgent.Input == S let startTime = now().timeIntervalSince1970 var iterations = 0 - var intermediateSteps: [AgentAction] = [] + var intermediateSteps: [AgentAction] = initialSteps func shouldContinue() -> Bool { if isCancelled { return false } @@ -53,12 +60,14 @@ public actor AgentExecutor: Chain where InnerAgent.Input == S } while shouldContinue() { + try Task.checkCancellation() let nextStepOutput = try await takeNextStep( input: input, intermediateSteps: intermediateSteps, callbackManagers: callbackManagers ) + try Task.checkCancellation() switch nextStepOutput { case let .finish(finish): return end( @@ -96,7 +105,10 @@ public actor AgentExecutor: Chain where InnerAgent.Input == S } public nonisolated func parseOutput(_ output: Output) -> String { - output.finalOutput + switch output.finalOutput { + case let .unstructured(error): return error + case let .structured(output): return output.botReadableContent + } } public func cancel() { @@ -109,7 +121,7 @@ struct InvalidToolError: Error {} extension AgentExecutor { func end( - output: AgentFinish, + output: AgentFinish, intermediateSteps: [AgentAction], callbackManagers: [CallbackManager] ) -> Output { @@ -120,35 +132,46 @@ extension AgentExecutor { return .init(finalOutput: finalOutput, intermediateSteps: intermediateSteps) } + /// Plan the scratch pad and let the agent decide what to do next func takeNextStep( input: Input, intermediateSteps: [AgentAction], callbackManagers: [CallbackManager] - ) async throws -> AgentNextStep { + ) async throws -> AgentNextStep { let output = try await agent.plan( input: input, intermediateSteps: intermediateSteps, callbackManagers: callbackManagers ) switch output { + // If the output says finish, then return the output immediately. case .finish: return output + // If the output contains actions, run them, and append the results to the scratch pad. case let .actions(actions): let completedActions = try await withThrowingTaskGroup(of: AgentAction.self) { taskGroup in for action in actions { - callbackManagers - .forEach { $0.send(CallbackEvents.AgentActionDidStart(info: action)) } + callbackManagers.send(CallbackEvents.AgentActionDidStart(info: action)) + if action.observation != nil { + taskGroup.addTask { action } + continue + } guard let tool = tools[action.toolName] else { throw InvalidToolError() } taskGroup.addTask { - let observation = try await tool.run(input: action.toolInput) - return action.observationAvailable(observation) + do { + let observation = try await tool.run(input: action.toolInput) + return action.observationAvailable(observation) + } catch { + let observation = error.localizedDescription + return action.observationAvailable(observation) + } } } var completedActions = [AgentAction]() for try await action in taskGroup { + try Task.checkCancellation() completedActions.append(action) - callbackManagers - .forEach { $0.send(CallbackEvents.AgentActionDidEnd(info: action)) } + callbackManagers.send(CallbackEvents.AgentActionDidEnd(info: action)) } return completedActions } @@ -157,10 +180,49 @@ extension AgentExecutor { } } - func getToolFinish(action: AgentAction) -> AgentFinish? { + func getToolFinish(action: AgentAction) -> AgentFinish? { guard let tool = tools[action.toolName] else { return nil } guard tool.returnDirectly else { return nil } - return .init(returnValue: action.observation ?? "", log: "") + + do { + let result = try InnerAgent.Output.parse(action.observation ?? "") + return .init(returnValue: .structured(result), log: action.observation ?? "") + } catch { + return .init( + returnValue: .unstructured(action.observation ?? "no observation"), + log: action.observation ?? "" + ) + } } } +// MARK: - AgentOutputParsable + +public protocol AgentOutputParsable { + static func parse(_ string: String) throws -> Self + var botReadableContent: String { get } +} + +extension String: AgentOutputParsable { + public static func parse(_ string: String) throws -> String { string } + public var botReadableContent: String { self } +} + +extension Int: AgentOutputParsable { + public static func parse(_ string: String) throws -> Int { + guard let int = Int(string) else { return 0 } + return int + } + + public var botReadableContent: String { String(self) } +} + +extension Double: AgentOutputParsable { + public static func parse(_ string: String) throws -> Double { + guard let double = Double(string) else { return 0 } + return double + } + + public var botReadableContent: String { String(self) } +} + diff --git a/Tool/Sources/LangChain/AgentTool.swift b/Tool/Sources/LangChain/AgentTool.swift index 170cd9ca..d221adad 100644 --- a/Tool/Sources/LangChain/AgentTool.swift +++ b/Tool/Sources/LangChain/AgentTool.swift @@ -1,4 +1,5 @@ import Foundation +import OpenAIService public protocol AgentTool { var name: String { get } @@ -30,3 +31,68 @@ public struct SimpleAgentTool: AgentTool { } } +public class FunctionCallingAgentTool: AgentTool, ChatGPTFunction { + public func call(arguments: F.Arguments) async throws -> F.Result { + try await function.call(arguments: arguments, reportProgress: reportProgress) + } + + public var argumentSchema: OpenAIService.JSONSchemaValue { function.argumentSchema } + + public typealias Arguments = F.Arguments + public typealias Result = F.Result + + public var function: F + public var name: String + public var description: String + public var returnDirectly: Bool + + let callbackManagers: [CallbackManager] + + public init( + function: F, + returnDirectly: Bool = false, + callbackManagers: [CallbackManager] = [] + ) { + self.function = function + self.callbackManagers = callbackManagers + name = function.name + description = function.description + self.returnDirectly = returnDirectly + } + + func reportProgress(_ progress: String) { + callbackManagers.send( + CallbackEvents.AgentFunctionCallingToolReportProgress(info: .init( + functionName: name, + progress: progress + )) + ) + } + + public func run(input: String) async throws -> String { + await prepare(reportProgress: { [weak self] p in + self?.reportProgress(p) + }) + return try await call( + argumentsJsonString: input, + reportProgress: { [weak self] p in + self?.reportProgress(p) + } + ) + .botReadableContent + } + + public func prepare(reportProgress: @escaping ReportProgress) async { + await function.prepare(reportProgress: { [weak self] p in + self?.reportProgress(p) + }) + } + + public func call( + arguments: F.Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> F.Result { + try await function.call(arguments: arguments, reportProgress: reportProgress) + } +} + diff --git a/Tool/Sources/LangChain/Agents/ChatAgent.swift b/Tool/Sources/LangChain/Agents/ChatAgent.swift index fb24dc4a..b78ff113 100644 --- a/Tool/Sources/LangChain/Agents/ChatAgent.swift +++ b/Tool/Sources/LangChain/Agents/ChatAgent.swift @@ -35,9 +35,11 @@ private func formatInstruction(toolsNames: String, preferredLanguage: String) -> public class ChatAgent: Agent { public typealias Input = String + public typealias Output = String + public typealias ScratchPadContent = String public var observationPrefix: String { "Observation: " } public var llmPrefix: String { "Thought: " } - public let chatModelChain: ChatModelChain> + public let chatModelChain: ChatModelChain> let tools: [AgentTool] public init(chatModel: ChatModel, tools: [AgentTool], preferredLanguage: String) { @@ -67,62 +69,79 @@ public class ChatAgent: Agent { Begin! Reminder to always use the exact characters `Final Answer` when responding. """ ), - agentInput.thoughts.isEmpty + agentInput.thoughts.content.isEmpty ? .init(role: .user, content: agentInput.input) : .init( role: .user, content: """ \(agentInput.input) - \({ - switch agentInput.thoughts { - case let .text(text): - return text - case let .messages(messages): - return messages.map { message in - """ - \(message) - """ - }.joined(separator: "\n") - } - }()) + \(agentInput.thoughts.content) """ ), ] } ) } + + func constructBaseScratchpad(intermediateSteps: [AgentAction]) -> String { + var thoughts = "" + for step in intermediateSteps { + thoughts += """ + \(step.log) + \(observationPrefix)\(step.observation ?? "") + """ + } + return thoughts + } - public func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad { + public func constructScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad { let baseScratchpad = constructBaseScratchpad(intermediateSteps: intermediateSteps) - if baseScratchpad.isEmpty { return .text("") } - return .text(""" + if baseScratchpad.isEmpty { return .init(content: "") } + return .init(content: """ This was your previous work (but I haven't seen any of it! I only see what you return as `Final Answer`): \(baseScratchpad) (Please continue with `Thought:` or `Final Answer:`) """) } - + + public func constructFinalScratchpad(intermediateSteps: [AgentAction]) -> AgentScratchPad { + let baseScratchpad = constructBaseScratchpad(intermediateSteps: intermediateSteps) + if baseScratchpad.isEmpty { return .init(content: "") } + return .init(content: """ + This was your previous work (but I haven't seen any of it! I only see what you return as `Final Answer`): + \(baseScratchpad) + \(llmPrefix)I now need to return a final answer based on the previous steps: + "(Please continue with `Final Answer:`)" + """) + } + public func validateTools(tools: [AgentTool]) throws { // no validation } - public func parseOutput(_ text: String) -> AgentNextStep { - func parseFinalAnswerIfPossible() -> AgentNextStep? { + public func extraPlan(input: AgentInput) { + // do nothing + } + + public func parseOutput(_ output: ChatMessage) async -> AgentNextStep { + let text = output.content ?? "" + + func parseFinalAnswerIfPossible() -> AgentNextStep? { let throughAnswerParser = PrefixThrough("Final Answer:") var parsableContent = text[...] do { _ = try throughAnswerParser.parse(&parsableContent) let answer = String(parsableContent) let output = answer.trimmingCharacters(in: .whitespacesAndNewlines) - return .finish(AgentFinish(returnValue: output, log: text)) + return .finish(AgentFinish(returnValue: .structured(output), log: text)) } catch { Logger.langchain.info("Could not parse LLM output final answer: \(error)") return nil } } - func parseNextActionIfPossible() -> AgentNextStep? { + func parseNextActionIfPossible() -> AgentNextStep? { let throughActionBlockParser = PrefixThrough(""" Action: ``` @@ -169,7 +188,7 @@ public class ChatAgent: Agent { answer = "Sorry, I don't know." } - return .finish(AgentFinish(returnValue: String(answer), log: text)) + return .finish(AgentFinish(returnValue: .structured(String(answer)), log: text)) } } diff --git a/Tool/Sources/LangChain/Callback.swift b/Tool/Sources/LangChain/Callback.swift index 3c0a6561..57a1708b 100644 --- a/Tool/Sources/LangChain/Callback.swift +++ b/Tool/Sources/LangChain/Callback.swift @@ -6,10 +6,23 @@ public protocol CallbackEvent { } public struct CallbackEvents { + public struct UnTypedEvent: CallbackEvent { + public var info: String + public init(info: String) { + self.info = info + } + } + + public var untyped: UnTypedEvent.Type { UnTypedEvent.self } + private init() {} } public struct CallbackManager { + struct Observer { + let handler: (Event.Info) -> Void + } + fileprivate var observers = [Any]() public init() {} @@ -24,19 +37,19 @@ public struct CallbackManager { _: Event.Type = Event.self, _ handler: @escaping (Event.Info) -> Void ) { - observers.append(handler) + observers.append(Observer(handler: handler)) } public mutating func on( _: KeyPath, _ handler: @escaping (Event.Info) -> Void ) { - observers.append(handler) + observers.append(Observer(handler: handler)) } public func send(_ event: Event) { - for case let observer as ((Event.Info) -> Void) in observers { - observer(event.info) + for case let observer as Observer in observers { + observer.handler(event.info) } } @@ -44,8 +57,14 @@ public struct CallbackManager { _: KeyPath, _ info: Event.Info ) { - for case let observer as ((Event.Info) -> Void) in observers { - observer(info) + for case let observer as Observer in observers { + observer.handler(info) + } + } + + public func send(_ string: String) { + for case let observer as Observer in observers { + observer.handler(string) } } } @@ -61,5 +80,9 @@ public extension [CallbackManager] { ) { for cb in self { cb.send(keyPath, info) } } + + func send(_ event: String) { + for cb in self { cb.send(event) } + } } diff --git a/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift b/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift new file mode 100644 index 00000000..d199b4f2 --- /dev/null +++ b/Tool/Sources/LangChain/Chains/CombineAnswersChain.swift @@ -0,0 +1,70 @@ +import Foundation +import Logger +import OpenAIService + +public class CombineAnswersChain: Chain { + public struct Input: Decodable { + public var question: String + public var answers: [String] + public init(question: String, answers: [String]) { + self.question = question + self.answers = answers + } + } + + public typealias Output = String + public let chatModelChain: ChatModelChain + + public init( + configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + extraInstructions: String = "" + ) { + chatModelChain = .init( + chatModel: OpenAIChat( + configuration: configuration.overriding { + $0.runFunctionsAutomatically = false + }, + memory: nil, + stream: false + ), + stops: ["Observation:"], + promptTemplate: { input in + [ + .init( + role: .system, + content: """ + You are a helpful assistant. + Your job is to combine multiple answers from different sources to one question. + \(extraInstructions) + """ + ), + .init(role: .user, content: """ + Question: \(input.question) + + Answers: + \(input.answers.joined(separator: "\n\(String(repeating: "-", count: 32))\n")) + + What is the combined answer? + """), + ] + } + ) + } + + public func callLogic( + _ input: Input, + callbackManagers: [CallbackManager] + ) async throws -> String { + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + return await parseOutput(output) + } + + public func parseOutput(_ message: ChatMessage) async -> String { + return message.content ?? "No answer." + } + + public func parseOutput(_ output: String) -> String { + output + } +} + diff --git a/Tool/Sources/LangChain/Chains/LLMChain.swift b/Tool/Sources/LangChain/Chains/LLMChain.swift index 1fc546c2..1201bd45 100644 --- a/Tool/Sources/LangChain/Chains/LLMChain.swift +++ b/Tool/Sources/LangChain/Chains/LLMChain.swift @@ -3,9 +3,9 @@ import Foundation public class ChatModelChain: Chain { public typealias Output = ChatMessage - var chatModel: ChatModel - var promptTemplate: (Input) -> [ChatMessage] - var stops: [String] + public internal(set) var chatModel: ChatModel + public internal(set) var promptTemplate: (Input) -> [ChatMessage] + public internal(set) var stops: [String] public init( chatModel: ChatModel, diff --git a/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift new file mode 100644 index 00000000..f9c399f3 --- /dev/null +++ b/Tool/Sources/LangChain/Chains/QAInformationRetrievalChain.swift @@ -0,0 +1,99 @@ +import Foundation +import OpenAIService + +public final class QAInformationRetrievalChain: Chain { + let vectorStores: [VectorStore] + let embedding: Embeddings + let maxCount: Int + let filterMetadata: (String) -> Bool + let hint: String + + public struct Output { + public var information: String + public var sourceDocuments: [Document] + } + + public init( + vectorStore: VectorStore, + embedding: Embeddings, + maxCount: Int = 5, + filterMetadata: @escaping (String) -> Bool = { _ in true }, + hint: String = "" + ) { + vectorStores = [vectorStore] + self.embedding = embedding + self.maxCount = maxCount + self.filterMetadata = filterMetadata + self.hint = hint + } + + public init( + vectorStores: [VectorStore], + embedding: Embeddings, + maxCount: Int = 5, + filterMetadata: @escaping (String) -> Bool = { _ in true }, + hint: String = "" + ) { + self.vectorStores = vectorStores + self.embedding = embedding + self.maxCount = maxCount + self.filterMetadata = filterMetadata + self.hint = hint + } + + public func callLogic( + _ input: String, + callbackManagers: [CallbackManager] + ) async throws -> Output { + let embeddedQuestion = try await embedding.embed(query: input) + let documentsSlice = await withTaskGroup( + of: [(document: Document, distance: Float)].self + ) { group in + for vectorStore in vectorStores { + group.addTask { + (try? await vectorStore.searchWithDistance( + embeddings: embeddedQuestion, + count: 5 + ).filter { item in + item.distance < 0.31 + }) ?? [] + } + } + var result = [(document: Document, distance: Float)]() + for await items in group { + result.append(contentsOf: items) + } + return result + }.sorted { $0.distance < $1.distance }.prefix(maxCount) + + let documents = Array(documentsSlice) + + callbackManagers.send(CallbackEvents.RetrievalQADidExtractRelevantContent(info: documents)) + + let relevantInformationChain = RelevantInformationExtractionChain( + filterMetadata: filterMetadata, + hint: hint + ) + let relevantInformation = try await relevantInformationChain.run( + .init(question: input, documents: documents), + callbackManagers: callbackManagers + ) + + return .init(information: relevantInformation, sourceDocuments: documents.map(\.document)) + } + + public func parseOutput(_ output: Output) -> String { + return output.information + } +} + +public extension CallbackEvents { + struct RetrievalQADidExtractRelevantContent: CallbackEvent { + public let info: [(document: Document, distance: Float)] + } + + var retrievalQADidExtractRelevantContent: RetrievalQADidExtractRelevantContent.Type { + RetrievalQADidExtractRelevantContent.self + } +} + diff --git a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift index 41829694..3c38eb3a 100644 --- a/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift +++ b/Tool/Sources/LangChain/Chains/RefineDocumentChain.swift @@ -46,15 +46,8 @@ public final class RefineDocumentChain: Chain { var functions: [any ChatGPTFunction] = [RespondFunction()] } - struct RespondFunction: ChatGPTFunction { + struct RespondFunction: ChatGPTArgumentsCollectingFunction { typealias Arguments = IntermediateAnswer - - struct Result: ChatGPTFunctionResult { - var botReadableContent: String { "" } - } - - var reportProgress: (String) async -> Void = { _ in } - var name: String = "respond" var description: String = "Respond with the refined answer" var argumentSchema: JSONSchemaValue { @@ -77,12 +70,6 @@ public final class RefineDocumentChain: Chain { .required: ["answer", "more", "usefulness"], ] } - - func prepare() async {} - - func call(arguments: Arguments) async throws -> Result { - return Result() - } } func buildChatModel() -> ChatModelChain { diff --git a/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift index 47bd7bbd..d55cafd4 100644 --- a/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift +++ b/Tool/Sources/LangChain/Chains/RelevantInformationExtractionChain.swift @@ -15,63 +15,74 @@ public final class RelevantInformationExtractionChain: Chain { public typealias Output = String class FunctionProvider: ChatGPTFunctionProvider { - var functionCallStrategy: FunctionCallStrategy? = .auto - var functions: [any ChatGPTFunction] = [NoneFunction()] + var functionCallStrategy: FunctionCallStrategy? = .name("saveFinalAnswer") + var functions: [any ChatGPTFunction] = [FinalAnswer()] } - struct NoneFunction: ChatGPTFunction { - struct Arguments: Decodable {} - - struct Result: ChatGPTFunctionResult { - var botReadableContent: String { "" } + struct FinalAnswer: ChatGPTArgumentsCollectingFunction { + struct Arguments: Decodable { + var relevantInformation: String + var noRelevantInformationFound: Bool? } - var reportProgress: (String) async -> Void = { _ in } - - var name: String = "noInformationFound" - var description: String = "Call when you can't find any relevant information from the document, or the question was not mentioned in the document" + var name: String = "saveFinalAnswer" + var description: String = + "save the relevant information" var argumentSchema: JSONSchemaValue { - return [ + [ .type: "object", - .properties: .hash([:]) + .properties: [ + "relevantInformation": [.type: "string"], + "noRelevantInformationFound": [.type: "boolean"], + ], + .required: ["relevantInformation", "noRelevantInformationFound"], ] } + } - func prepare() async {} + let filterMetadata: (String) -> Bool + let hint: String - func call(arguments: Arguments) async throws -> Result { - return Result() - } + init(filterMetadata: @escaping (String) -> Bool = { _ in true }, hint: String) { + self.filterMetadata = filterMetadata + self.hint = hint } func buildChatModel() -> ChatModelChain { .init( chatModel: OpenAIChat( configuration: UserPreferenceChatGPTConfiguration().overriding { - $0.temperature = 0 + $0.temperature = 0.5 $0.runFunctionsAutomatically = false }, memory: EmptyChatGPTMemory(), functionProvider: FunctionProvider(), stream: false ) - ) { input in [ + ) { [filterMetadata, hint] input in [ .init( role: .system, content: """ Extract the relevant information from the Document according to the Question. + The information may not directly answer the question, but it should be relevant to the question, \ + please think carefully and make you decision. Make the information clear, concise and short. If found code, wrap it in markdown code block. + \(hint) """ ), .init( role: .user, content: """ Question:### + (how, when, what or why) \(input.question) ### Document:### - \(input.document) + \(input.document.metadata.filter { key, _ in + filterMetadata(key) + }) + \(input.document.pageContent) ### """ ), @@ -92,6 +103,22 @@ public final class RelevantInformationExtractionChain: Chain { taskInput, callbackManagers: callbackManagers ) + + if let functionCall = output.functionCall { + do { + let arguments = try JSONDecoder().decode( + FinalAnswer.Arguments.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + if arguments.noRelevantInformationFound ?? false { + return "" + } + return arguments.relevantInformation + } catch { + return output.content ?? "" + } + } + return output.content ?? "" } @@ -138,3 +165,4 @@ public extension CallbackEvents { RelevantInformationExtractionChainDidExtractPartialRelevantContent.self } } + diff --git a/Tool/Sources/LangChain/Chains/RetrievalQA.swift b/Tool/Sources/LangChain/Chains/RetrievalQA.swift deleted file mode 100644 index 9cdcbd4b..00000000 --- a/Tool/Sources/LangChain/Chains/RetrievalQA.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation -import OpenAIService - -public final class QAInformationRetrievalChain: Chain { - let vectorStore: VectorStore - let embedding: Embeddings - - public struct Output { - public var information: String - public var sourceDocuments: [Document] - } - - public init( - vectorStore: VectorStore, - embedding: Embeddings - ) { - self.vectorStore = vectorStore - self.embedding = embedding - } - - public func callLogic( - _ input: String, - callbackManagers: [CallbackManager] - ) async throws -> Output { - let embeddedQuestion = try await embedding.embed(query: input) - let documents = try await vectorStore.searchWithDistance( - embeddings: embeddedQuestion, - count: 5 - ).filter { item in - item.distance < 0.31 - } - - callbackManagers.send(CallbackEvents.RetrievalQADidExtractRelevantContent(info: documents)) - - let relevantInformationChain = RelevantInformationExtractionChain() - let relevantInformation = try await relevantInformationChain.run( - .init(question: input, documents: documents), - callbackManagers: callbackManagers - ) - - return .init(information: relevantInformation, sourceDocuments: documents.map(\.document)) - } - - public func parseOutput(_ output: Output) -> String { - return output.information - } -} - -public extension CallbackEvents { - struct RetrievalQADidExtractRelevantContent: CallbackEvent { - public let info: [(document: Document, distance: Float)] - } - - var retrievalQADidExtractRelevantContent: RetrievalQADidExtractRelevantContent.Type { - RetrievalQADidExtractRelevantContent.self - } -} - diff --git a/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift b/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift new file mode 100644 index 00000000..9c938cce --- /dev/null +++ b/Tool/Sources/LangChain/Chains/StructuredOutputChatModelChain.swift @@ -0,0 +1,126 @@ +import Foundation +import Logger +import OpenAIService + +/// This is an agent used to get a structured output. +public class StructuredOutputChatModelChain: Chain { + public struct EndFunction: ChatGPTArgumentsCollectingFunction { + public struct Arguments: Decodable { + var finalAnswer: Output + } + + public var name: String { "FinalAnswer" } + public var description: String { "Save the final answer when it's ready" } + public var argumentSchema: JSONSchemaValue { + return [ + .type: "object", + .properties: [ + "finalAnswer": .hash(finalAnswerSchema), + ], + .required: ["finalAnswer"], + ] + } + + public let finalAnswerSchema: [String: JSONSchemaValue] + + public init(argumentSchema: [String: JSONSchemaValue]) { + finalAnswerSchema = argumentSchema + } + + public init() where Output == String { + finalAnswerSchema = [ + JSONSchemaKey.type.key: "string", + ] + } + + public init() where Output == Int { + finalAnswerSchema = [ + JSONSchemaKey.type.key: "number", + ] + } + + public init() where Output == Double { + finalAnswerSchema = [ + JSONSchemaKey.type.key: "number", + ] + } + } + + struct FunctionProvider: ChatGPTFunctionProvider { + var endFunction: EndFunction + var functions: [any ChatGPTFunction] { + [endFunction] + } + + var functionCallStrategy: FunctionCallStrategy? { + .name(endFunction.name) + } + } + + public typealias Input = String + public let chatModelChain: ChatModelChain + var functionProvider: FunctionProvider + + public init( + configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + endFunction: EndFunction, + promptTemplate: ((String) -> [ChatMessage])? = nil + ) { + functionProvider = .init( + endFunction: endFunction + ) + chatModelChain = .init( + chatModel: OpenAIChat( + configuration: configuration.overriding { + $0.runFunctionsAutomatically = false + }, + memory: nil, + functionProvider: functionProvider, + stream: false + ), + stops: ["Observation:"], + promptTemplate: promptTemplate ?? { input in + [ + .init( + role: .system, + content: """ + You are a helpful assistant + Generate a final answer to my query as concisely, helpfully and accurately as possible. + You don't ask me for additional information. + """ + ), + .init(role: .user, content: input), + ] + } + ) + } + + public func callLogic( + _ input: String, + callbackManagers: [CallbackManager] + ) async throws -> Output? { + let output = try await chatModelChain.call(input, callbackManagers: callbackManagers) + return await parseOutput(output) + } + + public func parseOutput(_ output: Output?) -> String { + return String(describing: output) + } + + public func parseOutput(_ message: ChatMessage) async -> Output? { + if let functionCall = message.functionCall { + do { + let result = try JSONDecoder().decode( + EndFunction.Arguments.self, + from: functionCall.arguments.data(using: .utf8) ?? Data() + ) + return result.finalAnswer + } catch { + return nil + } + } + + return nil + } +} + diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index bb9c7752..af7c52bd 100644 --- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -3,13 +3,13 @@ import OpenAIService public struct OpenAIChat: ChatModel { public var configuration: ChatGPTConfiguration - public var memory: ChatGPTMemory + public var memory: ChatGPTMemory? public var functionProvider: ChatGPTFunctionProvider public var stream: Bool public init( configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), - memory: ChatGPTMemory = ConversationChatGPTMemory(systemPrompt: ""), + memory: ChatGPTMemory? = ConversationChatGPTMemory(systemPrompt: ""), functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider(), stream: Bool ) { @@ -24,6 +24,8 @@ public struct OpenAIChat: ChatModel { stops: [String], callbackManagers: [CallbackManager] ) async throws -> ChatMessage { + let memory = memory ?? EmptyChatGPTMemory() + let service = ChatGPTService( memory: memory, configuration: configuration, diff --git a/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift b/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift index 1c9b4627..020f5993 100644 --- a/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift +++ b/Tool/Sources/LangChain/DocumentLoader/DocumentLoader.swift @@ -9,6 +9,11 @@ public struct Document: Codable { self.pageContent = pageContent self.metadata = metadata } + + public func metadata(_ keyPath: KeyPath) -> JSONValue? { + let key = Key.self[keyPath: keyPath] + return metadata[key] + } } public protocol DocumentLoader { diff --git a/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift b/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift index f506c4ad..1c588fe6 100644 --- a/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift +++ b/Tool/Sources/LangChain/DocumentLoader/TextLoader.swift @@ -3,10 +3,11 @@ import Foundation /// Load a text document from local file. public struct TextLoader: DocumentLoader { - enum MetadataKeys { - static let filename = "filename" - static let `extension` = "extension" - static let contentModificationDate = "contentModificationDate" + public enum MetadataKeys { + public static let filename = "filename" + public static let `extension` = "extension" + public static let contentModificationDate = "contentModificationDate" + public static let filePath = "filePath" } let url: URL @@ -38,6 +39,7 @@ public struct TextLoader: DocumentLoader { MetadataKeys.contentModificationDate: .number( (modificationDate ?? Date()).timeIntervalSince1970 ), + MetadataKeys.filePath: .string(url.path), ])] } } diff --git a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift index f5a3cb3c..e0505bf8 100644 --- a/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift +++ b/Tool/Sources/LangChain/VectorStore/TemporaryUSearch.swift @@ -18,11 +18,11 @@ public actor TemporaryUSearch: VectorStore { let index: USearchIndex var documents: [USearchLabel: LabeledDocument] = [:] - public init(identifier: String) { + public init(identifier: String, dimensions: Int = 1536 /* text-embedding-ada-002 */ ) { self.identifier = calculateMD5Hash(identifier) index = .init( metric: .IP, - dimensions: 1536, // text-embedding-ada-002 + dimensions: UInt32(dimensions), connectivity: 16, quantization: .F32 ) diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index e78bf3a0..f4d97d1e 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -10,6 +10,7 @@ enum LogLevel: String { public final class Logger { private let subsystem: String private let category: String + private let osLog: OSLog public static let service = Logger(category: "Service") public static let ui = Logger(category: "UI") @@ -19,12 +20,14 @@ public final class Logger { public static let codeium = Logger(category: "Codeium") public static let langchain = Logger(category: "LangChain") #if DEBUG + /// Use a temp logger to log something temporary. I won't be available in release builds. public static let temp = Logger(category: "Temp") #endif public init(subsystem: String = "com.intii.CopilotForXcode", category: String) { self.subsystem = subsystem self.category = category + osLog = OSLog(subsystem: subsystem, category: category) } func log(level: LogLevel, message: String) { @@ -38,7 +41,6 @@ public final class Logger { osLogType = .error } - let osLog = OSLog(subsystem: subsystem, category: category) os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) } @@ -57,4 +59,8 @@ public final class Logger { public func error(_ error: Error) { log(level: .error, message: error.localizedDescription) } + + public func signpost(_ type: OSSignpostType, name: StaticString) { + os_signpost(type, log: osLog, name: name) + } } diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index f8f598c8..0dc01d6b 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -10,12 +10,18 @@ public protocol ChatGPTServiceType { } public enum ChatGPTServiceError: Error, LocalizedError { + case chatModelNotAvailable + case embeddingModelNotAvailable case endpointIncorrect case responseInvalid case otherError(String) public var errorDescription: String? { switch self { + case .chatModelNotAvailable: + return "Chat model is not available, please add a model in the settings." + case .embeddingModelNotAvailable: + return "Embedding model is not available, please add a model in the settings." case .endpointIncorrect: return "ChatGPT endpoint is incorrect" case .responseInvalid: @@ -180,8 +186,12 @@ extension ChatGPTService { /// Send the memory as prompt to ChatGPT, with stream enabled. func sendMemory() async throws -> AsyncThrowingStream { - guard let url = URL(string: configuration.endpoint) - else { throw ChatGPTServiceError.endpointIncorrect } + guard let model = configuration.model else { + throw ChatGPTServiceError.chatModelNotAvailable + } + guard let url = URL(string: configuration.endpoint) else { + throw ChatGPTServiceError.endpointIncorrect + } await memory.refresh() @@ -197,8 +207,6 @@ extension ChatGPTService { } let remainingTokens = await memory.remainingTokens - let model = configuration.model - let requestBody = CompletionRequestBody( model: model.info.modelName, messages: messages, @@ -287,8 +295,12 @@ extension ChatGPTService { /// Send the memory as prompt to ChatGPT, with stream disabled. func sendMemoryAndWait() async throws -> ChatMessage? { - guard let url = URL(string: configuration.endpoint) - else { throw ChatGPTServiceError.endpointIncorrect } + guard let model = configuration.model else { + throw ChatGPTServiceError.chatModelNotAvailable + } + guard let url = URL(string: configuration.endpoint) else { + throw ChatGPTServiceError.endpointIncorrect + } await memory.refresh() @@ -304,8 +316,6 @@ extension ChatGPTService { } let remainingTokens = await memory.remainingTokens - let model = configuration.model - let requestBody = CompletionRequestBody( model: model.info.modelName, messages: messages, @@ -357,7 +367,7 @@ extension ChatGPTService { /// When a function call is detected, but arguments are not yet ready, we can call this /// to insert a message placeholder in memory. func prepareFunctionCall(_ call: ChatMessage.FunctionCall, messageId: String) async { - guard var function = functionProvider.function(named: call.name) else { return } + guard let function = functionProvider.function(named: call.name) else { return } let responseMessage = ChatMessage( id: messageId, role: .function, @@ -365,12 +375,11 @@ extension ChatGPTService { name: call.name ) await memory.appendMessage(responseMessage) - function.reportProgress = { [weak self] summary in + await function.prepare { [weak self] summary in await self?.memory.updateMessage(id: messageId) { message in message.summary = summary } } - await function.prepare() } /// Run a function call from the bot, and insert the result in memory. @@ -381,7 +390,7 @@ extension ChatGPTService { ) async -> String { let messageId = messageId ?? uuidGenerator() - guard var function = functionProvider.function(named: call.name) else { + guard let function = functionProvider.function(named: call.name) else { return await fallbackFunctionCall(call, messageId: messageId) } @@ -395,15 +404,14 @@ extension ChatGPTService { await memory.appendMessage(responseMessage) - function.reportProgress = { [weak self] summary in - await self?.memory.updateMessage(id: messageId) { message in - message.summary = summary - } - } - do { // Run the function - let result = try await function.call(argumentsJsonString: call.arguments) + let result = try await function.call(argumentsJsonString: call.arguments) { + [weak self] summary in + await self?.memory.updateMessage(id: messageId) { message in + message.summary = summary + } + } await memory.updateMessage(id: messageId) { message in message.content = result.botReadableContent diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index 503b8e8f..aa441c67 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -4,7 +4,7 @@ import Preferences import Keychain public protocol ChatGPTConfiguration { - var model: ChatModel { get } + var model: ChatModel? { get } var temperature: Double { get } var apiKey: String { get } var stop: [String] { get } @@ -15,11 +15,12 @@ public protocol ChatGPTConfiguration { public extension ChatGPTConfiguration { var endpoint: String { - model.endpoint + model?.endpoint ?? "" } var apiKey: String { - (try? Keychain.apiKey.get(model.info.apiKeyName)) ?? "" + guard let name = model?.info.apiKeyName else { return "" } + return (try? Keychain.apiKey.get(name)) ?? "" } func overriding( diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index 0ad7cc07..fdaba303 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -4,30 +4,32 @@ import Keychain import Preferences public protocol EmbeddingConfiguration { - var model: EmbeddingModel { get } + var model: EmbeddingModel? { get } var apiKey: String { get } var maxToken: Int { get } + var dimensions: Int { get } } public extension EmbeddingConfiguration { var endpoint: String { - model.endpoint + model?.endpoint ?? "" } var apiKey: String { - (try? Keychain.apiKey.get(model.info.apiKeyName)) ?? "" + guard let name = model?.info.apiKeyName else { return "" } + return (try? Keychain.apiKey.get(name)) ?? "" } func overriding( - _ overrides: OverridingEmbeddingConfiguration.Overriding - ) -> OverridingEmbeddingConfiguration { + _ overrides: OverridingEmbeddingConfiguration.Overriding + ) -> OverridingEmbeddingConfiguration { .init(overriding: self, with: overrides) } func overriding( - _ update: (inout OverridingEmbeddingConfiguration.Overriding) -> Void = { _ in } - ) -> OverridingEmbeddingConfiguration { - var overrides = OverridingEmbeddingConfiguration.Overriding() + _ update: (inout OverridingEmbeddingConfiguration.Overriding) -> Void = { _ in } + ) -> OverridingEmbeddingConfiguration { + var overrides = OverridingEmbeddingConfiguration.Overriding() update(&overrides) return .init(overriding: self, with: overrides) } diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index bc499d6f..4b83895b 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -3,19 +3,29 @@ import Foundation import Preferences public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { + public var chatModelKey: KeyPath>? + public var temperature: Double { min(max(0, UserDefaults.shared.value(for: \.chatGPTTemperature)), 2) } - public var model: ChatModel { + public var model: ChatModel? { let models = UserDefaults.shared.value(for: \.chatModels) + + if let chatModelKey { + let id = UserDefaults.shared.value(for: chatModelKey) + if let model = models.first(where: { $0.id == id }) { + return model + } + } + let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId) return models.first { $0.id == id } - ?? models.first ?? .init(id: "", name: "", format: .openAI, info: .init()) + ?? models.first } public var maxTokens: Int { - model.info.maxTokens + model?.info.maxTokens ?? 0 } public var stop: [String] { @@ -30,7 +40,9 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { true } - public init() {} + public init(chatModelKey: KeyPath>? = nil) { + self.chatModelKey = chatModelKey + } } public class OverridingChatGPTConfiguration: ChatGPTConfiguration { @@ -77,7 +89,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { overriding.temperature ?? configuration.temperature } - public var model: ChatModel { + public var model: ChatModel? { if let model = overriding.model { return model } let models = UserDefaults.shared.value(for: \.chatModels) guard let id = overriding.modelId, diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift index 396cfd98..de400ba5 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift @@ -3,48 +3,74 @@ import Foundation import Preferences public struct UserPreferenceEmbeddingConfiguration: EmbeddingConfiguration { - public var model: EmbeddingModel { + public var embeddingModelKey: KeyPath>? + + public var model: EmbeddingModel? { let models = UserDefaults.shared.value(for: \.embeddingModels) + + if let embeddingModelKey { + let id = UserDefaults.shared.value(for: embeddingModelKey) + if let model = models.first(where: { $0.id == id }) { + return model + } + } + let id = UserDefaults.shared.value(for: \.defaultChatFeatureEmbeddingModelId) return models.first { $0.id == id } - ?? models.first ?? .init(id: "", name: "", format: .openAI, info: .init()) + ?? models.first } public var maxToken: Int { - model.info.maxTokens + model?.info.maxTokens ?? 0 + } + + public var dimensions: Int { + let dimensions = model?.info.dimensions ?? 0 + if dimensions <= 0 { + return 1536 + } + return dimensions } - public init() {} + public init( + embeddingModelKey: KeyPath>? = nil + ) { + self.embeddingModelKey = embeddingModelKey + } } -public class OverridingEmbeddingConfiguration< - Configuration: EmbeddingConfiguration ->: EmbeddingConfiguration { +public class OverridingEmbeddingConfiguration: EmbeddingConfiguration { public struct Overriding { public var modelId: String? public var model: EmbeddingModel? public var maxTokens: Int? + public var dimensions: Int? public init( modelId: String? = nil, model: EmbeddingModel? = nil, - maxTokens: Int? = nil + maxTokens: Int? = nil, + dimensions: Int? = nil ) { self.modelId = modelId self.model = model self.maxTokens = maxTokens + self.dimensions = dimensions } } - private let configuration: Configuration + private let configuration: EmbeddingConfiguration public var overriding = Overriding() - public init(overriding configuration: Configuration, with overrides: Overriding = .init()) { + public init( + overriding configuration: any EmbeddingConfiguration, + with overrides: Overriding = .init() + ) { overriding = overrides self.configuration = configuration } - public var model: EmbeddingModel { + public var model: EmbeddingModel? { if let model = overriding.model { return model } let models = UserDefaults.shared.value(for: \.embeddingModels) guard let id = overriding.modelId, @@ -56,5 +82,9 @@ public class OverridingEmbeddingConfiguration< public var maxToken: Int { overriding.maxTokens ?? configuration.maxToken } + + public var dimensions: Int { + overriding.dimensions ?? configuration.dimensions + } } diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index a17b0863..d3bd1c8d 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -41,10 +41,12 @@ public struct EmbeddingService { } public func embed(text: [String]) async throws -> EmbeddingResponse { + guard let model = configuration.model else { + throw ChatGPTServiceError.embeddingModelNotAvailable + } 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() @@ -90,10 +92,12 @@ public struct EmbeddingService { } public func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { + guard let model = configuration.model else { + throw ChatGPTServiceError.embeddingModelNotAvailable + } 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() diff --git a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFunction.swift b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFunction.swift index f2b4f759..420fc180 100644 --- a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFunction.swift +++ b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFunction.swift @@ -15,9 +15,13 @@ extension String: ChatGPTFunctionResult { public var botReadableContent: String { self } } +public struct NoChatGPTFunctionArguments: Decodable {} + public protocol ChatGPTFunction { + typealias NoArguments = NoChatGPTFunctionArguments associatedtype Arguments: Decodable associatedtype Result: ChatGPTFunctionResult + typealias ReportProgress = (String) async -> Void /// The name of this function. /// May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters. @@ -27,19 +31,67 @@ public protocol ChatGPTFunction { /// The arguments schema that the function take in [JSON schema](https://json-schema.org). var argumentSchema: JSONSchemaValue { get } /// Prepare to call the function - func prepare() async + func prepare(reportProgress: @escaping ReportProgress) async /// Call the function with the given arguments. - func call(arguments: Arguments) async throws -> Result - /// The message to present in different phases. - var reportProgress: (String) async -> Void { get set } + func call(arguments: Arguments, reportProgress: @escaping ReportProgress) async throws + -> Result } public extension ChatGPTFunction { /// Call the function with the given arguments in JSON. - func call(argumentsJsonString: String) async throws -> Result { + func call( + argumentsJsonString: String, + reportProgress: @escaping ReportProgress + ) async throws -> Result { let arguments = try JSONDecoder() .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data()) - return try await call(arguments: arguments) + return try await call(arguments: arguments, reportProgress: reportProgress) + } +} + +public extension ChatGPTFunction where Arguments == NoArguments { + var argumentSchema: JSONSchemaValue { + [.type: "object", .properties: [:]] + } +} + +/// This kind of function is only used to get a structured output from the bot. +public protocol ChatGPTArgumentsCollectingFunction: ChatGPTFunction where Result == String {} + +public extension ChatGPTArgumentsCollectingFunction { + @available( + *, + deprecated, + message: "This function is only used to get a structured output from the bot." + ) + func prepare(reportProgress: @escaping ReportProgress = { _ in }) async { + assertionFailure("This function is only used to get a structured output from the bot.") + } + + @available( + *, + deprecated, + message: "This function is only used to get a structured output from the bot." + ) + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress = { _ in } + ) async throws -> Result { + assertionFailure("This function is only used to get a structured output from the bot.") + return "" + } + + @available( + *, + deprecated, + message: "This function is only used to get a structured output from the bot." + ) + func call( + argumentsJsonString: String, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + assertionFailure("This function is only used to get a structured output from the bot.") + return "" } } diff --git a/Tool/Sources/OpenAIService/FucntionCall/JSONSchema.swift b/Tool/Sources/OpenAIService/FucntionCall/JSONSchema.swift index 6ffc000e..6769ba3d 100644 --- a/Tool/Sources/OpenAIService/FucntionCall/JSONSchema.swift +++ b/Tool/Sources/OpenAIService/FucntionCall/JSONSchema.swift @@ -1,7 +1,7 @@ import Foundation public struct JSONSchemaKey: Codable, Hashable, Sendable, Equatable, ExpressibleByStringLiteral { - var key: String + public var key: String public init(stringLiteral: String) { key = stringLiteral diff --git a/Tool/Sources/OpenAIService/Models.swift b/Tool/Sources/OpenAIService/Models.swift index 7a3475c2..1911f054 100644 --- a/Tool/Sources/OpenAIService/Models.swift +++ b/Tool/Sources/OpenAIService/Models.swift @@ -18,6 +18,10 @@ public struct ChatMessage: Equatable, Codable { public struct FunctionCall: Codable, Equatable { public var name: String public var arguments: String + public init(name: String, arguments: String) { + self.name = name + self.arguments = arguments + } } /// The role of a message. diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index fc739f64..eee92784 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -264,6 +264,14 @@ public extension UserDefaultPreferenceKeys { var promptToCodeGenerateDescriptionInUserPreferredLanguage: PreferenceKey { .init(defaultValue: true, key: "PromptToCodeGenerateDescriptionInUserPreferredLanguage") } + + var promptToCodeChatModelId: PreferenceKey { + .init(defaultValue: "", key: "PromptToCodeChatModelId") + } + + var promptToCodeEmbeddingModelId: PreferenceKey { + .init(defaultValue: "", key: "PromptToCodeEmbeddingModelId") + } } // MARK: - Suggestion @@ -491,5 +499,12 @@ public extension UserDefaultPreferenceKeys { key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear" ) } + + var disableEnhancedWorkspace: FeatureFlag { + .init( + defaultValue: false, + key: "FeatureFlag-DisableEnhancedWorkspace" + ) + } } diff --git a/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionModel/EditorInformation.swift index 60ba65d2..c0964f8d 100644 --- a/Tool/Sources/SuggestionModel/EditorInformation.swift +++ b/Tool/Sources/SuggestionModel/EditorInformation.swift @@ -55,7 +55,8 @@ public struct EditorInformation { public let selectedContent: String public let selectedLines: [String] public let documentURL: URL - public let projectURL: URL + public let workspaceURL: URL + public let projectRootURL: URL public let relativePath: String public let language: CodeLanguage @@ -64,7 +65,8 @@ public struct EditorInformation { selectedContent: String, selectedLines: [String], documentURL: URL, - projectURL: URL, + workspaceURL: URL, + projectRootURL: URL, relativePath: String, language: CodeLanguage ) { @@ -72,7 +74,8 @@ public struct EditorInformation { self.selectedContent = selectedContent self.selectedLines = selectedLines self.documentURL = documentURL - self.projectURL = projectURL + self.workspaceURL = workspaceURL + self.projectRootURL = projectRootURL self.relativePath = relativePath self.language = language } diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 11d02740..2683ce13 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -8,8 +8,9 @@ public protocol FilespacePropertyKey { } public final class FilespacePropertyValues { - var storage: [ObjectIdentifier: Any] = [:] + private var storage: [ObjectIdentifier: Any] = [:] + @WorkspaceActor public subscript(_ key: K.Type) -> K.Value { get { if let value = storage[ObjectIdentifier(key)] as? K.Value { @@ -47,7 +48,7 @@ public struct FilespaceCodeMetadata: Equatable { @dynamicMemberLookup public final class Filespace { public let fileURL: URL - public private(set) lazy var language: String = languageIdentifierFromFileURL(fileURL).rawValue + public private(set) lazy var language: CodeLanguage = languageIdentifierFromFileURL(fileURL) public var codeMetadata: FilespaceCodeMetadata = .init() public internal(set) var suggestions: [CodeSuggestion] = [] { didSet { refreshUpdateTime() } @@ -65,7 +66,7 @@ public final class Filespace { } private(set) var lastSuggestionUpdateTime: Date = Environment.now() - var additionalProperties = FilespacePropertyValues() + private var additionalProperties = FilespacePropertyValues() let fileSaveWatcher: FileSaveWatcher let onClose: (URL) -> Void @@ -87,6 +88,7 @@ public final class Filespace { } } + @WorkspaceActor public subscript( dynamicMember dynamicMember: WritableKeyPath ) -> K { diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index e6ffb14b..bbffbecc 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -3,6 +3,7 @@ import Foundation import Preferences import SuggestionModel import UserDefaultsObserver +import XcodeInspector public protocol WorkspacePropertyKey { associatedtype Value @@ -10,8 +11,9 @@ public protocol WorkspacePropertyKey { } public class WorkspacePropertyValues { - var storage: [ObjectIdentifier: Any] = [:] + private var storage: [ObjectIdentifier: Any] = [:] + @WorkspaceActor public subscript(_ key: K.Type) -> K.Value { get { if let value = storage[ObjectIdentifier(key)] as? K.Value { @@ -30,6 +32,7 @@ public class WorkspacePropertyValues { open class WorkspacePlugin { public private(set) weak var workspace: Workspace? public var projectRootURL: URL { workspace?.projectRootURL ?? URL(fileURLWithPath: "/") } + public var workspaceURL: URL { workspace?.workspaceURL ?? projectRootURL } public var filespaces: [URL: Filespace] { workspace?.filespaces ?? [:] } public init(workspace: Workspace) { @@ -38,6 +41,7 @@ open class WorkspacePlugin { open func didOpenFilespace(_: Filespace) {} open func didSaveFilespace(_: Filespace) {} + open func didUpdateFilespace(_: Filespace, content: String) {} open func didCloseFilespace(_: URL) {} } @@ -54,8 +58,9 @@ public final class Workspace { } } - var additionalProperties = WorkspacePropertyValues() + private var additionalProperties = WorkspacePropertyValues() public internal(set) var plugins = [ObjectIdentifier: WorkspacePlugin]() + public let workspaceURL: URL public let projectRootURL: URL public let openedFileRecoverableStorage: OpenedFileRecoverableStorage public private(set) var lastSuggestionUpdateTime = Environment.now() @@ -83,8 +88,12 @@ public final class Workspace { plugins[ObjectIdentifier(type)] as? P } - init(projectRootURL: URL) { - self.projectRootURL = projectRootURL + init(workspaceURL: URL) { + self.workspaceURL = workspaceURL + self.projectRootURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) ?? workspaceURL openedFileRecoverableStorage = .init(projectRootURL: projectRootURL) let openedFiles = openedFileRecoverableStorage.openedFiles Task { @WorkspaceActor in @@ -133,5 +142,13 @@ public final class Workspace { public func closeFilespace(fileURL: URL) { filespaces[fileURL] = nil } + + @WorkspaceActor + public func didUpdateFilespace(fileURL: URL, content: String) { + guard let filespace = filespaces[fileURL] else { return } + for plugin in plugins.values { + plugin.didUpdateFilespace(filespace, content: content) + } + } } diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 4f17941a..7c8c239b 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -1,5 +1,17 @@ import Environment import Foundation +import Dependencies + +public struct WorkspacePoolDependencyKey: DependencyKey { + public static var liveValue: WorkspacePool = .init() +} + +public extension DependencyValues { + var workspacePool: WorkspacePool { + get { self[WorkspacePoolDependencyKey.self] } + set { self[WorkspacePoolDependencyKey.self] = newValue } + } +} @globalActor public enum WorkspaceActor { public actor TheActor {} @@ -7,6 +19,17 @@ import Foundation } public class WorkspacePool { + public enum Error: Swift.Error, LocalizedError { + case invalidWorkspaceURL(URL) + + public var errorDescription: String? { + switch self { + case .invalidWorkspaceURL(let url): + return "Invalid workspace URL: \(url)" + } + } + } + public internal(set) var workspaces: [URL: Workspace] = [:] var plugins = [ObjectIdentifier: (Workspace) -> WorkspacePlugin]() @@ -45,6 +68,21 @@ public class WorkspacePool { } return nil } + + @WorkspaceActor + public func fetchOrCreateWorkspace(workspaceURL: URL) async throws -> Workspace { + guard workspaceURL != URL(fileURLWithPath: "/") else { + throw Error.invalidWorkspaceURL(workspaceURL) + } + + if let existed = workspaces[workspaceURL] { + return existed + } + + let new = createNewWorkspace(workspaceURL: workspaceURL) + workspaces[workspaceURL] = new + return new + } @WorkspaceActor public func fetchOrCreateWorkspaceAndFilespace(fileURL: URL) async throws @@ -56,14 +94,14 @@ public class WorkspacePool { } // If we know which project is opened. - if let currentProjectURL = try await Environment.fetchCurrentProjectRootURLFromXcode() { - if let existed = workspaces[currentProjectURL] { + if let currentWorkspaceURL = try await Environment.fetchCurrentWorkspaceURLFromXcode() { + if let existed = workspaces[currentWorkspaceURL] { let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) return (existed, filespace) } - let new = createNewWorkspace(projectRootURL: currentProjectURL) - workspaces[currentProjectURL] = new + let new = createNewWorkspace(workspaceURL: currentWorkspaceURL) + workspaces[currentWorkspaceURL] = new let filespace = new.createFilespaceIfNeeded(fileURL: fileURL) return (new, filespace) } @@ -93,7 +131,7 @@ public class WorkspacePool { return workspace } } - return createNewWorkspace(projectRootURL: workspaceURL) + return createNewWorkspace(workspaceURL: workspaceURL) }() let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) @@ -121,8 +159,8 @@ extension WorkspacePool { workspace.plugins[id] = nil } - func createNewWorkspace(projectRootURL: URL) -> Workspace { - let new = Workspace(projectRootURL: projectRootURL) + func createNewWorkspace(workspaceURL: URL) -> Workspace { + let new = Workspace(workspaceURL: workspaceURL) for (id, plugin) in plugins { addPlugin(plugin, id: id, to: new) } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 2d271206..08d74805 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -17,20 +17,23 @@ public final class XcodeInspector: ObservableObject { @Published public internal(set) var activeXcode: XcodeAppInstanceInspector? @Published public internal(set) var latestActiveXcode: XcodeAppInstanceInspector? @Published public internal(set) var xcodes: [XcodeAppInstanceInspector] = [] - @Published public internal(set) var activeProjectURL = URL(fileURLWithPath: "/") - @Published public internal(set) var activeDocumentURL = URL(fileURLWithPath: "/") + @Published public internal(set) var activeProjectRootURL: URL? = nil + @Published public internal(set) var activeDocumentURL: URL? = nil + @Published public internal(set) var activeWorkspaceURL: URL? = nil @Published public internal(set) var focusedWindow: XcodeWindowInspector? @Published public internal(set) var focusedEditor: SourceEditor? @Published public internal(set) var focusedElement: AXUIElement? @Published public internal(set) var completionPanel: AXUIElement? - + public var focusedEditorContent: EditorInformation? { + guard let documentURL = XcodeInspector.shared.realtimeActiveDocumentURL, + let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, + let projectURL = XcodeInspector.shared.activeProjectRootURL + 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: "") + let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") if let editorContent, let range = editorContent.selections.first { let (selectedContent, selectedLines) = EditorInformation.code( @@ -42,7 +45,8 @@ public final class XcodeInspector: ObservableObject { selectedContent: selectedContent, selectedLines: selectedLines, documentURL: documentURL, - projectURL: projectURL, + workspaceURL: workspaceURL, + projectRootURL: projectURL, relativePath: relativePath, language: language ) @@ -53,14 +57,23 @@ public final class XcodeInspector: ObservableObject { selectedContent: "", selectedLines: [], documentURL: documentURL, - projectURL: projectURL, + workspaceURL: workspaceURL, + projectRootURL: projectURL, relativePath: relativePath, language: language ) } - public var realtimeActiveDocumentURL: URL { - latestActiveXcode?.realtimeDocumentURL ?? URL(fileURLWithPath: "/") + public var realtimeActiveDocumentURL: URL? { + latestActiveXcode?.realtimeDocumentURL ?? activeDocumentURL + } + + public var realtimeActiveWorkspaceURL: URL? { + latestActiveXcode?.realtimeWorkspaceURL ?? activeWorkspaceURL + } + + public var realtimeActiveProjectURL: URL? { + latestActiveXcode?.realtimeProjectURL ?? activeWorkspaceURL } init() { @@ -69,6 +82,7 @@ public final class XcodeInspector: ObservableObject { .filter { $0.isXcode } .map(XcodeAppInstanceInspector.init(runningApplication:)) let activeXcode = xcodes.first(where: \.isActive) + latestActiveXcode = activeXcode ?? xcodes.first activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) @@ -141,7 +155,8 @@ public final class XcodeInspector: ObservableObject { activeDocumentURL = xcode.documentURL focusedWindow = xcode.focusedWindow completionPanel = xcode.completionPanel - activeProjectURL = xcode.projectURL + activeProjectRootURL = xcode.projectRootURL + activeWorkspaceURL = xcode.workspaceURL focusedWindow = xcode.focusedWindow let setFocusedElement = { [weak self] in @@ -179,8 +194,12 @@ public final class XcodeInspector: ObservableObject { self?.activeDocumentURL = url }.store(in: &activeXcodeCancellable) - xcode.$projectURL.sink { [weak self] url in - self?.activeProjectURL = url + xcode.$workspaceURL.sink { [weak self] url in + self?.activeWorkspaceURL = url + }.store(in: &activeXcodeCancellable) + + xcode.$projectRootURL.sink { [weak self] url in + self?.activeProjectRootURL = url }.store(in: &activeXcodeCancellable) xcode.$focusedWindow.sink { [weak self] window in @@ -206,8 +225,9 @@ public class AppInstanceInspector: ObservableObject { public final class XcodeAppInstanceInspector: AppInstanceInspector { @Published public var focusedWindow: XcodeWindowInspector? - @Published public var documentURL: URL = .init(fileURLWithPath: "/") - @Published public var projectURL: URL = .init(fileURLWithPath: "/") + @Published public var documentURL: URL? = nil + @Published public var workspaceURL: URL? = nil + @Published public var projectRootURL: URL? = nil @Published public var workspaces = [WorkspaceIdentifier: Workspace]() public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { updateWorkspaceInfo() @@ -216,15 +236,29 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { @Published public private(set) var completionPanel: AXUIElement? - public var realtimeDocumentURL: URL { + public var realtimeDocumentURL: URL? { guard let window = appElement.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" - else { - return URL(fileURLWithPath: "/") - } + else { return nil } return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) - ?? URL(fileURLWithPath: "/") + } + + public var realtimeWorkspaceURL: URL? { + guard let window = appElement.focusedWindow, + window.identifier == "Xcode.WorkspaceWindow" + else { return nil } + + return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) + } + + public var realtimeProjectURL: URL? { + let workspaceURL = realtimeWorkspaceURL + let documentURL = realtimeDocumentURL + return WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: documentURL + ) } var _version: String? @@ -284,17 +318,23 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { focusedWindowObservations.removeAll() documentURL = window.documentURL - projectURL = window.projectURL + workspaceURL = window.workspaceURL + projectRootURL = window.projectRootURL window.$documentURL .filter { $0 != .init(fileURLWithPath: "/") } .sink { [weak self] url in self?.documentURL = url }.store(in: &focusedWindowObservations) - window.$projectURL + window.$workspaceURL + .filter { $0 != .init(fileURLWithPath: "/") } + .sink { [weak self] url in + self?.workspaceURL = url + }.store(in: &focusedWindowObservations) + window.$projectRootURL .filter { $0 != .init(fileURLWithPath: "/") } .sink { [weak self] url in - self?.projectURL = url + self?.projectRootURL = url }.store(in: &focusedWindowObservations) } } else { @@ -427,18 +467,8 @@ extension XcodeAppInstanceInspector { /// Use the project path as the workspace identifier. static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { - for child in window.children { - if child.description.starts(with: "/"), child.description.count > 1 { - let path = child.description - let trimmedNewLine = path.trimmingCharacters(in: .newlines) - var url = URL(fileURLWithPath: trimmedNewLine) - while !FileManager.default.fileIsDirectory(atPath: url.path) || - !url.pathExtension.isEmpty - { - url = url.deletingLastPathComponent() - } - return WorkspaceIdentifier.url(url) - } + if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) { + return WorkspaceIdentifier.url(url) } return WorkspaceIdentifier.unknown } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 217a9cbd..8c7b2b2d 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -15,7 +15,8 @@ public class XcodeWindowInspector: ObservableObject { public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { let app: NSRunningApplication @Published var documentURL: URL = .init(fileURLWithPath: "/") - @Published var projectURL: URL = .init(fileURLWithPath: "/") + @Published var workspaceURL: URL = .init(fileURLWithPath: "/") + @Published var projectRootURL: URL = .init(fileURLWithPath: "/") private var updateTabsTask: Task? private var focusedElementChangedTask: Task? @@ -23,7 +24,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { updateTabsTask?.cancel() focusedElementChangedTask?.cancel() } - + public func refresh() { updateURLs() } @@ -34,7 +35,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { focusedElementChangedTask = Task { @MainActor in updateURLs() - + Task { @MainActor in // prevent that documentURL may not be available yet try await Task.sleep(nanoseconds: 500_000_000) @@ -42,7 +43,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { updateURLs() } } - + let notifications = AXNotificationStream( app: app, notificationNames: kAXFocusedUIElementChangedNotification @@ -54,18 +55,22 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } } } - + func updateURLs() { let documentURL = Self.extractDocumentURL(windowElement: uiElement) if let documentURL { self.documentURL = documentURL } + let workspaceURL = Self.extractWorkspaceURL(windowElement: uiElement) + if let workspaceURL { + self.workspaceURL = workspaceURL + } let projectURL = Self.extractProjectURL( - windowElement: uiElement, - fileURL: documentURL + workspaceURL: workspaceURL, + documentURL: documentURL ) if let projectURL { - self.projectURL = projectURL + projectRootURL = projectURL } } @@ -84,37 +89,47 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return nil } - static func extractProjectURL( - windowElement: AXUIElement, - fileURL: URL? + static func extractWorkspaceURL( + windowElement: AXUIElement ) -> URL? { for child in windowElement.children { if child.description.starts(with: "/"), child.description.count > 1 { let path = child.description let trimmedNewLine = path.trimmingCharacters(in: .newlines) - var url = URL(fileURLWithPath: trimmedNewLine) - while !FileManager.default.fileIsDirectory(atPath: url.path) || - !url.pathExtension.isEmpty - { - url = url.deletingLastPathComponent() - } + let url = URL(fileURLWithPath: trimmedNewLine) return url } } + return nil + } - guard var currentURL = fileURL else { return nil } + public static func extractProjectURL( + workspaceURL: URL?, + documentURL: URL? + ) -> URL? { + guard var currentURL = workspaceURL ?? documentURL else { return nil } var firstDirectoryURL: URL? + var lastGitDirectoryURL: URL? while currentURL.pathComponents.count > 1 { defer { currentURL.deleteLastPathComponent() } guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue } + guard currentURL.pathExtension != "xcodeproj" else { continue } + guard currentURL.pathExtension != "xcworkspace" else { continue } + guard currentURL.pathExtension != "playground" else { continue } if firstDirectoryURL == nil { firstDirectoryURL = currentURL } let gitURL = currentURL.appendingPathComponent(".git") if FileManager.default.fileIsDirectory(atPath: gitURL.path) { - return currentURL + lastGitDirectoryURL = currentURL + } else if let text = try? String(contentsOf: gitURL) { + if !text.hasPrefix("gitdir: ../"), // it's not a sub module + text.range(of: "/.git/worktrees/") != nil // it's a git worktree + { + lastGitDirectoryURL = currentURL + } } } - return firstDirectoryURL ?? fileURL + return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL } } diff --git a/Tool/Tests/LangChainTests/ChatAgentTests.swift b/Tool/Tests/LangChainTests/ChatAgentTests.swift index 24918f61..58dcbc14 100644 --- a/Tool/Tests/LangChainTests/ChatAgentTests.swift +++ b/Tool/Tests/LangChainTests/ChatAgentTests.swift @@ -12,24 +12,24 @@ private struct FakeChatModel: ChatModel { } final class ChatAgentParseOutputTests: XCTestCase { - func test_parsing_well_formatted_final_answer() throws { + func test_parsing_well_formatted_final_answer() async throws { let finalAnswer = """ Final Answer: The answer is 42. Because 42 is the answer to everything. """ let agent = ChatAgent(chatModel: FakeChatModel(), tools: [], preferredLanguage: "") - let result = agent.parseOutput(finalAnswer) + let result = await agent.parseOutput(.init(role: .assistant, content: finalAnswer)) XCTAssertEqual(result, .finish(.init( - returnValue: """ + returnValue: .structured(""" The answer is 42. Because 42 is the answer to everything. - """, + """), log: finalAnswer ))) } - func test_parsing_final_answer_with_random_prefix() throws { + func test_parsing_final_answer_with_random_prefix() async throws { let finalAnswer = """ Now I have the final answer. Final Answer: The answer is 42. @@ -37,17 +37,17 @@ final class ChatAgentParseOutputTests: XCTestCase { """ let agent = ChatAgent(chatModel: FakeChatModel(), tools: [], preferredLanguage: "") - let result = agent.parseOutput(finalAnswer) + let result = await agent.parseOutput(.init(role: .assistant, content: finalAnswer)) XCTAssertEqual(result, .finish(.init( - returnValue: """ + returnValue: .structured(""" The answer is 42. Because 42 is the answer to everything. - """, + """), log: finalAnswer ))) } - func test_parsing_action() throws { + func test_parsing_action() async throws { let reply = """ Question: How to setup langchain python? Thought: I am not familiar with langchain python, I should use the Search tool to find more information on how to set it up. @@ -61,7 +61,7 @@ final class ChatAgentParseOutputTests: XCTestCase { """ let agent = ChatAgent(chatModel: FakeChatModel(), tools: [], preferredLanguage: "") - let result = agent.parseOutput(reply) + let result = await agent.parseOutput(.init(role: .assistant, content: reply)) XCTAssertEqual(result, .actions([ .init( toolName: "Search", @@ -71,7 +71,7 @@ final class ChatAgentParseOutputTests: XCTestCase { ])) } - func test_parsing_broken_action_and_return_everything_ahead_of_it() { + func test_parsing_broken_action_and_return_everything_ahead_of_it() async { let reply = """ Question: How to setup langchain python? Thought: I am not familiar with langchain python, I should use the Search tool to find more information on how to set it up. @@ -82,26 +82,26 @@ final class ChatAgentParseOutputTests: XCTestCase { """ let agent = ChatAgent(chatModel: FakeChatModel(), tools: [], preferredLanguage: "") - let result = agent.parseOutput(reply) + let result = await agent.parseOutput(.init(role: .assistant, content: reply)) XCTAssertEqual(result, .finish(.init( - returnValue: """ + returnValue: .structured(""" Question: How to setup langchain python? Thought: I am not familiar with langchain python, I should use the Search tool to find more information on how to set it up. - """, + """), log: reply ))) } - func test_parsing_simple_reply_that_does_not_follow_the_format() { + func test_parsing_simple_reply_that_does_not_follow_the_format() async { let reply = """ The answer is 42. Because 42 is the answer to everything. """ let agent = ChatAgent(chatModel: FakeChatModel(), tools: [], preferredLanguage: "") - let result = agent.parseOutput(reply) + let result = await agent.parseOutput(.init(role: .assistant, content: reply)) XCTAssertEqual(result, .finish(.init( - returnValue: reply, + returnValue: .structured(reply), log: reply ))) } diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index d55e2c9c..e40442cc 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -139,7 +139,7 @@ final class ChatGPTStreamTests: XCTestCase { .init(name: $0.name, description: $0.description, parameters: $0.argumentSchema) }, "Function schema is not submitted") } - + func test_handling_multiple_function_call() async throws { let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") let configuration = UserPreferenceChatGPTConfiguration().overriding() @@ -339,29 +339,30 @@ extension ChatGPTStreamTests { } var name: String { "function" } - + var description: String { "description" } var argumentSchema: JSONSchemaValue { [ - .type: ["null"] + .type: ["null"], ] } - - var reportProgress: (String) async -> Void = { print($0) } - func prepare() async { + func prepare(reportProgress: @escaping ReportProgress) async { print("Function will be called") } - func call(arguments: Parameters) async throws -> String { + func call( + arguments: Parameters, + reportProgress: @escaping ReportProgress + ) async throws -> String { "Function is called." } } struct FunctionProvider: ChatGPTFunctionProvider { var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil } - + var functions: [any ChatGPTFunction] { [EmptyFunction()] } } } diff --git a/Version.xcconfig b/Version.xcconfig index 9b74baa7..58c50109 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.23.2 -APP_BUILD = 241 +APP_VERSION = 0.24.0 +APP_BUILD = 250 diff --git a/appcast.xml b/appcast.xml index b8e4aa63..55f4365b 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,16 +3,28 @@ Copilot for Xcode - - 0.23.2 - Sat, 09 Sep 2023 22:07:35 +0800 - 241 - 0.23.2 - 12.0 - + + 0.24.0 + Thu, 28 Sep 2023 01:35:21 +0800 + 250 + 0.24.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.24.0 + + + + + + 0.23.2 + Sat, 09 Sep 2023 22:07:35 +0800 + 241 + 0.23.2 + 12.0 + https://github.com/intitni/CopilotForXcode/releases/tag/0.23.2 - +