├── .gitignore ├── Config.debug.xcconfig ├── Config.xcconfig ├── CopilotForXcodeExtension ├── CopilotForXcodeExtension.entitlements ├── CopilotForXcodeExtension.swift └── Info.plist ├── Core ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── Core-Package.xcscheme ├── Package.swift ├── Sources │ ├── CodeCompletionService │ │ ├── API │ │ │ ├── AnthropicService.swift │ │ │ ├── AzureOpenAIService.swift │ │ │ ├── GoogleGeminiService.swift │ │ │ ├── MistralFIMService.swift │ │ │ ├── OllamaService.swift │ │ │ ├── OpenAIService.swift │ │ │ └── TabbyService.swift │ │ ├── CodeCompletionLogger.swift │ │ ├── CodeCompletionService.swift │ │ ├── ResponseStream.swift │ │ ├── StreamLineLimiter.swift │ │ └── StreamStopStrategy │ │ │ ├── DefaultStreamStopStrategy.swift │ │ │ ├── NeverStreamStopStrategy.swift │ │ │ ├── OpeningTagBasedStreamStopStrategy.swift │ │ │ └── StreamStopStrategy.swift │ ├── Fundamental │ │ ├── Logger.swift │ │ ├── Models │ │ │ ├── ChatModel.swift │ │ │ ├── CompletionModel.swift │ │ │ ├── CustomModelType.swift │ │ │ ├── FIMModel.swift │ │ │ └── TabbyModel.swift │ │ ├── PromptStrategy.swift │ │ ├── TextProcessing │ │ │ ├── ConvertRange.swift │ │ │ └── String+Extensions.swift │ │ └── TruncateStrategy │ │ │ └── TruncateStrategy.swift │ ├── Storage │ │ ├── AppStorage+PreferenceKey.swift │ │ ├── Configurations.swift │ │ ├── Keychain.swift │ │ ├── Preferences.swift │ │ └── UserDefaults+PreferenceKey.swift │ └── SuggestionService │ │ ├── RawSuggestionPostProcessing │ │ ├── DefaultRawSuggestionPostProcessingStrategy.swift │ │ └── NoOpRawSuggestionPostProcessingStrategy.swift │ │ ├── RequestStrategies │ │ ├── AnthropicRequestStrategy.swift │ │ ├── ContinueRequestStrategy.swift │ │ ├── DefaultRequestStrategy.swift │ │ ├── FIMEndpointRequestStrategy.swift │ │ ├── FillInTheMiddleRequestStrategy.swift │ │ ├── NaiveRequestStrategy.swift │ │ └── TabbyRequestStrategy.swift │ │ ├── RequestStrategy.swift │ │ ├── Service.swift │ │ └── SuggestionService.swift └── Tests │ ├── CodeCompletionServiceTests │ ├── OpeningTagBasedStreamStopStrategyTests.swift │ └── StreamLineLimiterTests.swift │ ├── FundamentalTests │ └── ConvertRangeTests.swift │ └── SuggestionServiceTests │ ├── CodeSplitAtCursorTests.swift │ ├── DefaultRawSuggestionPostProcessingStrategyTests.swift │ └── DefaultRequestStrategyTests.swift ├── CustomSuggestionService.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── intitni.xcuserdatad │ │ └── IDEFindNavigatorScopes.plist └── xcshareddata │ └── xcschemes │ ├── CopilotForXcodeExtension.xcscheme │ └── CustomSuggestionService.xcscheme ├── CustomSuggestionService ├── App.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── ChatModelManagement │ ├── APIKeyManagement │ │ ├── APIKeyManagementView.swift │ │ ├── APIKeyManangement.swift │ │ ├── APIKeyPicker.swift │ │ ├── APIKeySelection.swift │ │ └── APIKeySubmission.swift │ ├── BaseURLPicker.swift │ ├── BaseURLSelection.swift │ ├── ChatModelEdit.swift │ ├── ChatModelEditView.swift │ ├── CompletionModelEdit.swift │ ├── CompletionModelEditView.swift │ ├── FIMModelEdit.swift │ ├── FIMModelEditView.swift │ ├── TabbyModelEdit.swift │ └── TabbyModelEditView.swift ├── ContentView.swift ├── CustomSuggestionService.entitlements ├── CustomSuggestionServiceApp.swift ├── Dependency.swift ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── TestField │ ├── TestField.swift │ └── TestFieldView.swift ├── Toast │ ├── HandleToast.swift │ └── Toast.swift ├── Tutorial │ └── TutorialView.swift └── UpdaterChecker.swift ├── LICENSE ├── README.md ├── TestPlan.xctestplan ├── Version.xcconfig ├── appcast.xml └── makefile /.gitignore: -------------------------------------------------------------------------------- 1 | CustomSuggestionService.xcodeproj/xcuserdata/ 2 | sparkle/ 3 | -------------------------------------------------------------------------------- /Config.debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Version.xcconfig" 2 | 3 | SLASH = / 4 | 5 | MODULE_NAME = Custom Suggestion Service 6 | HOST_APP_NAME = Custom Suggestion Service Debug 7 | BUNDLE_IDENTIFIER_BASE = dev.com.intii.CopilotForXcode 8 | SPARKLE_FEED_URL = http:$(SLASH)$(SLASH)127.0.0.1:9433/appcast.xml 9 | SPARKLE_PUBLIC_KEY = WDzm5GHnc6c8kjeJEgX5GuGiPpW6Lc/ovGjLnrrZvPY= 10 | APPLICATION_SUPPORT_FOLDER = dev.com.intii.CopilotForXcode.CustomSuggestionService 11 | 12 | // see also target Configs 13 | -------------------------------------------------------------------------------- /Config.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Version.xcconfig" 2 | 3 | SLASH = / 4 | 5 | MODULE_NAME = Custom Suggestion Service 6 | HOST_APP_NAME = Custom Suggestion Service 7 | BUNDLE_IDENTIFIER_BASE = com.intii.CopilotForXcode 8 | SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)raw.githubusercontent.com/intitni/CustomSuggestionServiceForCopilotForXcode/main/appcast.xml 9 | SPARKLE_PUBLIC_KEY = WDzm5GHnc6c8kjeJEgX5GuGiPpW6Lc/ovGjLnrrZvPY= 10 | APPLICATION_SUPPORT_FOLDER = com.intii.CopilotForXcode.CustomSuggestionService 11 | 12 | // see also target Configs 13 | -------------------------------------------------------------------------------- /CopilotForXcodeExtension/CopilotForXcodeExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | $(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE) 10 | 11 | com.apple.security.network.client 12 | 13 | keychain-access-groups 14 | 15 | $(AppIdentifierPrefix)$(BUNDLE_IDENTIFIER_BASE).Shared 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /CopilotForXcodeExtension/CopilotForXcodeExtension.swift: -------------------------------------------------------------------------------- 1 | import CopilotForXcodeKit 2 | import Foundation 3 | import SuggestionService 4 | 5 | @main 6 | class Extension: CopilotForXcodeExtension { 7 | let suggestionService = SuggestionService() 8 | var sceneConfiguration = SceneConfiguration() 9 | 10 | let updateChecker = 11 | UpdateChecker( 12 | hostBundle: locateHostBundleURL(url: Bundle.main.bundleURL) 13 | .flatMap(Bundle.init(url:)) 14 | ) 15 | } 16 | 17 | struct SceneConfiguration: CopilotForXcodeExtensionSceneConfiguration {} 18 | 19 | func locateHostBundleURL(url: URL) -> URL? { 20 | var nextURL = url 21 | while nextURL.path != "/" { 22 | nextURL = nextURL.deletingLastPathComponent() 23 | if nextURL.lastPathComponent.hasSuffix(".app") { 24 | return nextURL 25 | } 26 | } 27 | let devAppURL = url 28 | .deletingLastPathComponent() 29 | .appendingPathComponent("Custom Suggestion Service Dev.app") 30 | return devAppURL 31 | } 32 | -------------------------------------------------------------------------------- /CopilotForXcodeExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BUNDLE_IDENTIFIER_BASE 6 | $(BUNDLE_IDENTIFIER_BASE) 7 | EXAppExtensionAttributes 8 | 9 | EXExtensionPointIdentifier 10 | com.intii.CopilotForXcode.ExtensionService.Extension 11 | 12 | TEAM_ID_PREFIX 13 | $(TeamIdentifierPrefix) 14 | 15 | 16 | -------------------------------------------------------------------------------- /Core/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Core/.swiftpm/xcode/xcshareddata/xcschemes/Core-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 47 | 48 | 54 | 55 | 61 | 62 | 63 | 64 | 66 | 67 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /Core/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Core", 8 | platforms: [.macOS(.v13)], 9 | products: [ 10 | .library( 11 | name: "Core", 12 | targets: ["Storage", "SuggestionService", "Fundamental"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package( 17 | url: "https://github.com/intitni/CopilotForXcodeKit", 18 | from: "0.5.0" 19 | ), 20 | .package( 21 | url: "https://github.com/pointfreeco/swift-dependencies", 22 | from: "1.2.0" 23 | ), 24 | .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), 25 | .package(url: "https://github.com/google/generative-ai-swift", from: "0.4.4"), 26 | .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), 27 | ], 28 | targets: [ 29 | .target( 30 | name: "Fundamental", 31 | dependencies: [ 32 | .product(name: "CodableWrappers", package: "CodableWrappers"), 33 | .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), 34 | .product(name: "Dependencies", package: "swift-dependencies"), 35 | .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), 36 | .product(name: "Parsing", package: "swift-parsing"), 37 | ] 38 | ), 39 | .target( 40 | name: "Storage", 41 | dependencies: [ 42 | "Fundamental", 43 | .product(name: "CodableWrappers", package: "CodableWrappers"), 44 | .product(name: "Dependencies", package: "swift-dependencies"), 45 | ] 46 | ), 47 | .target( 48 | name: "CodeCompletionService", 49 | dependencies: [ 50 | "Storage", 51 | "Fundamental", 52 | .product(name: "CodableWrappers", package: "CodableWrappers"), 53 | .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), 54 | .product(name: "Dependencies", package: "swift-dependencies"), 55 | .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), 56 | ] 57 | ), 58 | .target( 59 | name: "SuggestionService", 60 | dependencies: [ 61 | "Fundamental", 62 | "CodeCompletionService", 63 | "Storage", 64 | .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), 65 | .product(name: "Parsing", package: "swift-parsing"), 66 | ] 67 | ), 68 | 69 | .testTarget( 70 | name: "CodeCompletionServiceTests", 71 | dependencies: ["CodeCompletionService"] 72 | ), 73 | .testTarget( 74 | name: "FundamentalTests", 75 | dependencies: ["Fundamental"] 76 | ), 77 | .testTarget( 78 | name: "SuggestionServiceTests", 79 | dependencies: ["SuggestionService"] 80 | ), 81 | ] 82 | ) 83 | 84 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/API/GoogleGeminiService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Fundamental 3 | import GoogleGenerativeAI 4 | 5 | public struct GoogleGeminiService { 6 | let modelName: String 7 | let maxToken: Int 8 | let contextWindow: Int 9 | let temperature: Double 10 | let stopWords: [String] 11 | let apiKey: String 12 | 13 | init( 14 | modelName: String, 15 | contextWindow: Int, 16 | maxToken: Int, 17 | temperature: Double = 0.2, 18 | stopWords: [String] = [], 19 | apiKey: String 20 | ) { 21 | self.modelName = modelName 22 | self.maxToken = maxToken 23 | self.contextWindow = contextWindow 24 | self.temperature = temperature 25 | self.stopWords = stopWords 26 | self.apiKey = apiKey 27 | } 28 | } 29 | 30 | extension GoogleGeminiService: CodeCompletionServiceType { 31 | func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream { 32 | let messages = createMessages(from: request) 33 | CodeCompletionLogger.logger.logPrompt(messages.map { 34 | ($0.parts.first?.text ?? "N/A", $0.role ?? "N/A") 35 | }) 36 | return AsyncStream { continuation in 37 | let task = Task { 38 | let result = try await sendMessages(messages) 39 | try Task.checkCancellation() 40 | continuation.yield(result) 41 | continuation.finish() 42 | } 43 | continuation.onTermination = { _ in 44 | task.cancel() 45 | } 46 | } 47 | } 48 | } 49 | 50 | public extension GoogleGeminiService { 51 | enum KnownModels: String, CaseIterable { 52 | case geminiPro = "gemini-pro" 53 | 54 | public var maxToken: Int { 55 | switch self { 56 | case .geminiPro: 57 | return 32768 58 | } 59 | } 60 | } 61 | } 62 | 63 | extension GoogleGeminiService { 64 | public enum Error: Swift.Error, LocalizedError { 65 | case apiError(Swift.Error) 66 | case otherError(Swift.Error) 67 | 68 | public var errorDescription: String? { 69 | switch self { 70 | case let .apiError(error): 71 | return "API error: \(error.localizedDescription)" 72 | case let .otherError(error): 73 | return "Error: \(error.localizedDescription)" 74 | } 75 | } 76 | } 77 | 78 | func createMessages(from request: PromptStrategy) -> [ModelContent] { 79 | let strategy = DefaultTruncateStrategy(maxTokenLimit: max( 80 | contextWindow / 3 * 2, 81 | contextWindow - maxToken - 20 82 | )) 83 | let prompts = strategy.createTruncatedPrompt(promptStrategy: request) 84 | return [ 85 | .init( 86 | role: "user", 87 | parts: ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") 88 | ), 89 | ] 90 | } 91 | 92 | func sendMessages(_ messages: [ModelContent]) async throws -> String { 93 | let aiModel = GenerativeModel( 94 | name: modelName, 95 | apiKey: apiKey, 96 | generationConfig: .init(GenerationConfig( 97 | temperature: Float(temperature), 98 | maxOutputTokens: maxToken, 99 | stopSequences: stopWords 100 | )) 101 | ) 102 | 103 | do { 104 | let response = try await aiModel.generateContent(messages) 105 | 106 | return response.candidates.first.map { 107 | $0.content.parts.first(where: { part in 108 | if let text = part.text { 109 | return !text.isEmpty 110 | } else { 111 | return false 112 | } 113 | })?.text ?? "" 114 | } ?? "" 115 | } catch let error as GenerateContentError { 116 | switch error { 117 | case let .internalError(underlying): 118 | throw Error.apiError(underlying) 119 | default: 120 | throw Error.apiError(error) 121 | } 122 | } catch { 123 | throw Error.otherError(error) 124 | } 125 | } 126 | } 127 | 128 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/API/MistralFIMService.swift: -------------------------------------------------------------------------------- 1 | import CopilotForXcodeKit 2 | import Foundation 3 | import Fundamental 4 | 5 | public actor MistralFIMService { 6 | let url: URL 7 | let model: String 8 | let temperature: Double 9 | let stopWords: [String] 10 | let apiKey: String 11 | let contextWindow: Int 12 | let maxToken: Int 13 | 14 | init( 15 | url: URL? = nil, 16 | model: String, 17 | temperature: Double, 18 | stopWords: [String] = [], 19 | apiKey: String, 20 | contextWindow: Int, 21 | maxToken: Int 22 | ) { 23 | self.url = url ?? URL(string: "https://api.mistral.ai/v1/fim/completions")! 24 | self.model = model 25 | self.temperature = temperature 26 | self.stopWords = stopWords 27 | self.apiKey = apiKey 28 | self.contextWindow = contextWindow 29 | self.maxToken = maxToken 30 | } 31 | } 32 | 33 | extension MistralFIMService: CodeCompletionServiceType { 34 | typealias CompletionSequence = AsyncThrowingCompactMapSequence< 35 | ResponseStream, 36 | String 37 | > 38 | 39 | func getCompletion(_ request: any PromptStrategy) async throws -> CompletionSequence { 40 | let result = try await send(request) 41 | return result.compactMap { $0.choices?.first?.delta?.content ?? $0.choices?.first?.text } 42 | } 43 | } 44 | 45 | extension MistralFIMService { 46 | struct RequestBody: Codable { 47 | let model: String 48 | let prompt: String 49 | let suffix: String 50 | let stream: Bool 51 | let temperature: Double 52 | let max_tokens: Int 53 | } 54 | 55 | enum Error: Swift.Error, LocalizedError { 56 | case decodeError(Swift.Error) 57 | case otherError(String) 58 | 59 | public var errorDescription: String? { 60 | switch self { 61 | case let .decodeError(error): 62 | return error.localizedDescription 63 | case let .otherError(message): 64 | return message 65 | } 66 | } 67 | } 68 | 69 | struct StreamDataChunk: Decodable { 70 | struct Delta: Decodable { 71 | var role: OpenAIService.Message.Role? 72 | var content: String? 73 | } 74 | 75 | struct Choice: Decodable { 76 | var index: Int 77 | var delta: Delta? 78 | var text: String? 79 | var finish_reason: String? 80 | } 81 | 82 | var id: String? 83 | var object: String? 84 | var model: String? 85 | var choices: [Choice]? 86 | } 87 | 88 | func send(_ request: any PromptStrategy) async throws -> ResponseStream { 89 | let strategy = DefaultTruncateStrategy(maxTokenLimit: max( 90 | contextWindow / 3 * 2, 91 | contextWindow - maxToken - 20 92 | )) 93 | let prompts = strategy.createTruncatedPrompt(promptStrategy: request) 94 | 95 | let prefix = prompts.first { $0.role == .prefix }?.content ?? "" 96 | let suffix = prompts.last { $0.role == .suffix }?.content ?? "" 97 | 98 | CodeCompletionLogger.logger.logPrompt([ 99 | (prefix, "prefix"), 100 | (suffix, "suffix"), 101 | ]) 102 | 103 | var request = URLRequest(url: url) 104 | let requestBody = RequestBody( 105 | model: model, 106 | prompt: prefix, 107 | suffix: suffix, 108 | stream: true, 109 | temperature: temperature, 110 | max_tokens: maxToken 111 | ) 112 | let encoder = JSONEncoder() 113 | request.httpBody = try encoder.encode(requestBody) 114 | request.httpMethod = "POST" 115 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 116 | request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") 117 | let (result, response) = try await URLSession.shared.bytes(for: request) 118 | 119 | guard let response = response as? HTTPURLResponse else { 120 | throw CancellationError() 121 | } 122 | 123 | guard response.statusCode == 200 else { 124 | let text = try await result.lines.reduce(into: "") { partialResult, current in 125 | partialResult += current 126 | } 127 | throw Error.otherError(text) 128 | } 129 | 130 | return ResponseStream(result: result) { 131 | var text = $0 132 | if text.hasPrefix("data: ") { 133 | text = String(text.dropFirst(6)) 134 | } 135 | do { 136 | let chunk = try JSONDecoder().decode( 137 | StreamDataChunk.self, 138 | from: text.data(using: .utf8) ?? Data() 139 | ) 140 | return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) 141 | } catch { 142 | print(error) 143 | throw error 144 | } 145 | } 146 | } 147 | } 148 | 149 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/API/TabbyService.swift: -------------------------------------------------------------------------------- 1 | import CopilotForXcodeKit 2 | import Foundation 3 | import Fundamental 4 | 5 | actor TabbyService { 6 | enum AuthorizationMode { 7 | case none 8 | case bearerToken(String) 9 | case basic(username: String, password: String) 10 | case customHeaderField(name: String, value: String) 11 | } 12 | 13 | let url: URL 14 | let temperature: Double 15 | let authorizationMode: AuthorizationMode 16 | 17 | init( 18 | url: String? = nil, 19 | temperature: Double = 0.2, 20 | authorizationMode: AuthorizationMode 21 | ) { 22 | self.url = url 23 | .flatMap(URL.init(string:)) ?? URL(string: "http://127.0.0.1:8080/v1/completions")! 24 | self.temperature = temperature 25 | self.authorizationMode = authorizationMode 26 | } 27 | } 28 | 29 | extension TabbyService: CodeCompletionServiceType { 30 | func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream { 31 | let prefix = request.prefix.joined() 32 | let suffix = request.suffix.joined() 33 | let requestBody = RequestBody( 34 | language: request.language?.rawValue, 35 | segments: .init( 36 | prefix: prefix, 37 | suffix: suffix, 38 | clipboard: "" 39 | ), 40 | temperature: temperature, 41 | seed: nil 42 | ) 43 | CodeCompletionLogger.logger.logPrompt([ 44 | (prefix, "prefix"), 45 | (suffix, "suffix"), 46 | ]) 47 | return AsyncStream { continuation in 48 | let task = Task { 49 | let result = try await send(requestBody) 50 | try Task.checkCancellation() 51 | continuation.yield(result) 52 | continuation.finish() 53 | } 54 | continuation.onTermination = { _ in 55 | task.cancel() 56 | } 57 | } 58 | } 59 | } 60 | 61 | extension TabbyService { 62 | enum Error: Swift.Error, LocalizedError { 63 | case serverError(String) 64 | case decodeError(Swift.Error) 65 | 66 | var errorDescription: String? { 67 | switch self { 68 | case let .serverError(message): 69 | return "Server returned an error: \(message)" 70 | case let .decodeError(error): 71 | return "Failed to decode response body: \(error)" 72 | } 73 | } 74 | } 75 | 76 | struct RequestBody: Codable { 77 | struct Segments: Codable { 78 | var prefix: String 79 | var suffix: String 80 | var clipboard: String 81 | } 82 | 83 | var language: String? 84 | var segments: Segments 85 | var temperature: Double 86 | var seed: Int? 87 | } 88 | 89 | struct ResponseBody: Codable { 90 | struct Choice: Codable { 91 | var index: Int 92 | var text: String 93 | } 94 | 95 | var id: String 96 | var choices: [Choice] 97 | } 98 | 99 | func send(_ requestBody: RequestBody) async throws -> String { 100 | var request = URLRequest(url: url) 101 | request.httpMethod = "POST" 102 | let encoder = JSONEncoder() 103 | request.httpBody = try encoder.encode(requestBody) 104 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 105 | 106 | switch authorizationMode { 107 | case let .basic(username, password): 108 | let data = "\(username):\(password)".data(using: .utf8)! 109 | let base64 = data.base64EncodedString() 110 | request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization") 111 | case let .bearerToken(token): 112 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 113 | case let .customHeaderField(name, value): 114 | request.setValue(value, forHTTPHeaderField: name) 115 | case .none: 116 | break 117 | } 118 | 119 | let (result, response) = try await URLSession.shared.data(for: request) 120 | 121 | guard let response = response as? HTTPURLResponse else { 122 | throw CancellationError() 123 | } 124 | 125 | guard response.statusCode == 200 else { 126 | throw Error.serverError(String(data: result, encoding: .utf8) ?? "Unknown Error") 127 | } 128 | 129 | do { 130 | let body = try JSONDecoder().decode(ResponseBody.self, from: result) 131 | return body.choices.first?.text ?? "" 132 | } catch { 133 | dump(error) 134 | throw Error.decodeError(error) 135 | } 136 | } 137 | } 138 | 139 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/CodeCompletionLogger.swift: -------------------------------------------------------------------------------- 1 | import CopilotForXcodeKit 2 | import Foundation 3 | import Fundamental 4 | 5 | public final class CodeCompletionLogger { 6 | struct Model { 7 | var type: String 8 | var format: String 9 | var modelName: String 10 | var baseURL: String 11 | } 12 | 13 | @TaskLocal public static var logger: CodeCompletionLogger = .init(request: SuggestionRequest( 14 | fileURL: .init(filePath: "/"), 15 | relativePath: "", 16 | language: .plaintext, 17 | content: "", 18 | originalContent: "", 19 | cursorPosition: .zero, 20 | tabSize: 0, 21 | indentSize: 0, 22 | usesTabsForIndentation: false, 23 | relevantCodeSnippets: [] 24 | )) 25 | 26 | let request: SuggestionRequest 27 | var model = Model(type: "", format: "", modelName: "", baseURL: "") 28 | var prompt: [(message: String, role: String)] = [] 29 | var responses: [String] = [] 30 | let startTime = Date() 31 | let id = UUID() 32 | 33 | var shouldLogToConsole: Bool { 34 | #if DEBUG 35 | return true 36 | #else 37 | return UserDefaults.shared.value(for: \.verboseLog) 38 | #endif 39 | } 40 | 41 | public init(request: SuggestionRequest) { 42 | self.request = request 43 | } 44 | 45 | public func logModel(_ chatModel: ChatModel) { 46 | model = .init( 47 | type: "Chat Completion", 48 | format: chatModel.format.rawValue, 49 | modelName: chatModel.info.modelName, 50 | baseURL: chatModel.info.baseURL 51 | ) 52 | } 53 | 54 | public func logModel(_ completionModel: CompletionModel) { 55 | model = .init( 56 | type: "Chat Completion", 57 | format: completionModel.format.rawValue, 58 | modelName: completionModel.info.modelName, 59 | baseURL: completionModel.info.baseURL 60 | ) 61 | } 62 | 63 | public func logModel(_ tabbyModel: TabbyModel) { 64 | model = .init( 65 | type: "Tabby", 66 | format: "N/A", 67 | modelName: "N/A", 68 | baseURL: tabbyModel.url 69 | ) 70 | } 71 | 72 | public func logModel(_ fimModel: FIMModel) { 73 | model = .init( 74 | type: "FIM", 75 | format: fimModel.format.rawValue, 76 | modelName: fimModel.info.modelName, 77 | baseURL: fimModel.info.baseURL 78 | ) 79 | } 80 | 81 | public func logPrompt(_ prompt: [(message: String, role: String)]) { 82 | self.prompt = prompt 83 | } 84 | 85 | public func logResponse(_ response: String) { 86 | responses.append(response) 87 | } 88 | 89 | public func error(_ error: Error) { 90 | guard shouldLogToConsole else { return } 91 | 92 | let now = Date() 93 | let duration = now.timeIntervalSince(startTime) 94 | let formattedDuration = String(format: "%.2f", duration) 95 | 96 | Logger.service.info(""" 97 | [Request] \(id) 98 | 99 | Duration: \(formattedDuration) 100 | Error: \(error.localizedDescription). 101 | """) 102 | } 103 | 104 | public func finish() { 105 | guard shouldLogToConsole else { return } 106 | 107 | let now = Date() 108 | let duration = now.timeIntervalSince(startTime) 109 | let formattedDuration = String(format: "%.2f", duration) 110 | 111 | Logger.service.info(""" 112 | [Request] \(id) 113 | 114 | Format: \(model.format) 115 | Model Name: \(model.modelName) 116 | Base URL: \(model.baseURL) 117 | Duration: \(formattedDuration) 118 | --- 119 | File URL: \(request.fileURL) 120 | Code Snippets: \(request.relevantCodeSnippets.count) snippets 121 | CursorPosition: \(request.cursorPosition) 122 | """) 123 | 124 | Logger.service.info(""" 125 | [Prompt] \(id) 126 | 127 | \(prompt.map { "\($0.role): \($0.message)" }.joined(separator: "\n\n")) 128 | """) 129 | 130 | Logger.service.info(""" 131 | [Response] \(id) 132 | 133 | \(responses.enumerated().map { "\($0 + 1): \($1)" }.joined(separator: "\n\n")) 134 | """) 135 | } 136 | } 137 | 138 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/ResponseStream.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ResponseStream: AsyncSequence { 4 | func makeAsyncIterator() -> Stream.AsyncIterator { 5 | stream.makeAsyncIterator() 6 | } 7 | 8 | typealias Stream = AsyncThrowingStream 9 | typealias AsyncIterator = Stream.AsyncIterator 10 | typealias Element = Chunk 11 | 12 | struct LineContent { 13 | let chunk: Chunk? 14 | let done: Bool 15 | } 16 | 17 | let stream: Stream 18 | 19 | init(result: URLSession.AsyncBytes, lineExtractor: @escaping (String) throws -> LineContent) { 20 | stream = AsyncThrowingStream { continuation in 21 | let task = Task { 22 | do { 23 | for try await line in result.lines { 24 | if Task.isCancelled { break } 25 | let content = try lineExtractor(line) 26 | if let chunk = content.chunk { 27 | continuation.yield(chunk) 28 | } 29 | 30 | if content.done { break } 31 | } 32 | continuation.finish() 33 | } catch { 34 | continuation.finish(throwing: error) 35 | result.task.cancel() 36 | } 37 | } 38 | continuation.onTermination = { _ in 39 | task.cancel() 40 | result.task.cancel() 41 | } 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/StreamLineLimiter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class StreamLineLimiter { 4 | public private(set) var result = "" 5 | private var currentLine = "" 6 | private var existedLines = [String]() 7 | private let lineLimit: Int 8 | private let strategy: any StreamStopStrategy 9 | 10 | enum PushResult: Equatable { 11 | case `continue` 12 | case finish(String) 13 | } 14 | 15 | init( 16 | lineLimit: Int = UserDefaults.shared.value(for: \.maxNumberOfLinesOfSuggestion), 17 | strategy: any StreamStopStrategy 18 | ) { 19 | self.lineLimit = lineLimit 20 | self.strategy = strategy 21 | } 22 | 23 | func push(_ token: String) -> PushResult { 24 | currentLine.append(token) 25 | if let newLine = currentLine.last(where: { $0.isNewline }) { 26 | let lines = currentLine 27 | .breakLines(proposedLineEnding: String(newLine), appendLineBreakToLastLine: false) 28 | let (newLines, lastLine) = lines.headAndTail 29 | existedLines.append(contentsOf: newLines) 30 | currentLine = lastLine ?? "" 31 | } 32 | 33 | let stopResult = if lineLimit <= 0 { 34 | StreamStopStrategyResult.continue 35 | } else { 36 | strategy.shouldStop( 37 | existedLines: existedLines, 38 | currentLine: currentLine, 39 | proposedLineLimit: lineLimit 40 | ) 41 | } 42 | 43 | switch stopResult { 44 | case .continue: 45 | result.append(token) 46 | return .continue 47 | case let .stop(appendingNewContent): 48 | if appendingNewContent { 49 | result.append(token) 50 | } 51 | return .finish(result) 52 | } 53 | } 54 | } 55 | 56 | extension Array { 57 | var headAndTail: ([Element], Element?) { 58 | guard let tail = last else { return ([], nil) } 59 | return (Array(dropLast()), tail) 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/StreamStopStrategy/DefaultStreamStopStrategy.swift: -------------------------------------------------------------------------------- 1 | public struct DefaultStreamStopStrategy: StreamStopStrategy { 2 | public init() {} 3 | 4 | public func shouldStop( 5 | existedLines: [String], 6 | currentLine: String, 7 | proposedLineLimit: Int 8 | ) -> StreamStopStrategyResult { 9 | if existedLines.count >= proposedLineLimit { 10 | return .stop(appendingNewContent: true) 11 | } 12 | return .continue 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/StreamStopStrategy/NeverStreamStopStrategy.swift: -------------------------------------------------------------------------------- 1 | public struct NeverStreamStopStrategy: StreamStopStrategy { 2 | public init() {} 3 | 4 | public func shouldStop( 5 | existedLines: [String], 6 | currentLine: String, 7 | proposedLineLimit: Int 8 | ) -> StreamStopStrategyResult { 9 | .continue 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct OpeningTagBasedStreamStopStrategy: StreamStopStrategy { 4 | public let openingTag: String 5 | public let toleranceIfNoOpeningTagFound: Int 6 | 7 | public init(openingTag: String, toleranceIfNoOpeningTagFound: Int) { 8 | self.openingTag = openingTag 9 | self.toleranceIfNoOpeningTagFound = toleranceIfNoOpeningTagFound 10 | } 11 | 12 | public func shouldStop( 13 | existedLines: [String], 14 | currentLine: String, 15 | proposedLineLimit: Int 16 | ) -> StreamStopStrategyResult { 17 | if let index = existedLines.firstIndex(where: { $0.contains(openingTag) }) { 18 | if existedLines.count - index - 1 >= proposedLineLimit { 19 | return .stop(appendingNewContent: true) 20 | } 21 | return .continue 22 | } else { 23 | if existedLines.count >= proposedLineLimit + toleranceIfNoOpeningTagFound { 24 | return .stop(appendingNewContent: true) 25 | } else { 26 | return .continue 27 | } 28 | } 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Core/Sources/CodeCompletionService/StreamStopStrategy/StreamStopStrategy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum StreamStopStrategyResult { 4 | case `continue` 5 | case stop(appendingNewContent: Bool) 6 | } 7 | 8 | public protocol StreamStopStrategy { 9 | func shouldStop(existedLines: [String], currentLine: String, proposedLineLimit: Int) 10 | -> StreamStopStrategyResult 11 | } 12 | 13 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | enum LogLevel: String { 5 | case debug 6 | case info 7 | case error 8 | } 9 | 10 | public final class Logger { 11 | private let subsystem: String 12 | private let category: String 13 | private let osLog: OSLog 14 | 15 | public static let service = Logger(category: "Service") 16 | #if DEBUG 17 | /// Use a temp logger to log something temporary. I won't be available in release builds. 18 | public static let temp = Logger(category: "Temp") 19 | #endif 20 | 21 | public init( 22 | subsystem: String = "com.intii.CopilotForXcode.CustomSuggestionService", 23 | category: String 24 | ) { 25 | self.subsystem = subsystem 26 | self.category = category 27 | osLog = OSLog(subsystem: subsystem, category: category) 28 | } 29 | 30 | func log( 31 | level: LogLevel, 32 | message: String, 33 | file: StaticString = #file, 34 | line: UInt = #line, 35 | function: StaticString = #function 36 | ) { 37 | let osLogType: OSLogType 38 | switch level { 39 | case .debug: 40 | osLogType = .debug 41 | case .info: 42 | osLogType = .info 43 | case .error: 44 | osLogType = .error 45 | } 46 | 47 | os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) 48 | } 49 | 50 | public func debug( 51 | _ message: String, 52 | file: StaticString = #file, 53 | line: UInt = #line, 54 | function: StaticString = #function 55 | ) { 56 | log(level: .debug, message: """ 57 | \(message) 58 | file: \(file) 59 | line: \(line) 60 | function: \(function) 61 | """, file: file, line: line, function: function) 62 | } 63 | 64 | public func info( 65 | _ message: String, 66 | file: StaticString = #file, 67 | line: UInt = #line, 68 | function: StaticString = #function 69 | ) { 70 | log(level: .info, message: message, file: file, line: line, function: function) 71 | } 72 | 73 | public func error( 74 | _ message: String, 75 | file: StaticString = #file, 76 | line: UInt = #line, 77 | function: StaticString = #function 78 | ) { 79 | log(level: .error, message: message, file: file, line: line, function: function) 80 | } 81 | 82 | public func error( 83 | _ error: Error, 84 | file: StaticString = #file, 85 | line: UInt = #line, 86 | function: StaticString = #function 87 | ) { 88 | log( 89 | level: .error, 90 | message: error.localizedDescription, 91 | file: file, 92 | line: line, 93 | function: function 94 | ) 95 | } 96 | 97 | public func signpostBegin( 98 | name: StaticString, 99 | file: StaticString = #file, 100 | line: UInt = #line, 101 | function: StaticString = #function 102 | ) -> Signposter { 103 | let poster = OSSignposter(logHandle: osLog) 104 | let id = poster.makeSignpostID() 105 | let state = poster.beginInterval(name, id: id) 106 | return .init(log: osLog, id: id, name: name, signposter: poster, beginState: state) 107 | } 108 | 109 | public struct Signposter { 110 | let log: OSLog 111 | let id: OSSignpostID 112 | let name: StaticString 113 | let signposter: OSSignposter 114 | let state: OSSignpostIntervalState 115 | 116 | init( 117 | log: OSLog, 118 | id: OSSignpostID, 119 | name: StaticString, 120 | signposter: OSSignposter, 121 | beginState: OSSignpostIntervalState 122 | ) { 123 | self.id = id 124 | self.log = log 125 | self.name = name 126 | self.signposter = signposter 127 | state = beginState 128 | } 129 | 130 | public func end() { 131 | signposter.endInterval(name, state) 132 | } 133 | 134 | public func event(_ text: String) { 135 | signposter.emitEvent(name, id: id, "\(text, privacy: .public)") 136 | } 137 | } 138 | } 139 | 140 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/Models/ChatModel.swift: -------------------------------------------------------------------------------- 1 | import CodableWrappers 2 | import Foundation 3 | 4 | /// A chat completion model. 5 | public struct ChatModel: Codable, Equatable, Identifiable { 6 | public var id: String 7 | public var name: String 8 | @FallbackDecoding 9 | public var format: Format 10 | @FallbackDecoding 11 | public var info: Info 12 | 13 | public init(id: String, name: String, format: Format, info: Info) { 14 | self.id = id 15 | self.name = name 16 | self.format = format 17 | self.info = info 18 | } 19 | 20 | public enum Format: String, Codable, Equatable, CaseIterable { 21 | case openAI 22 | case azureOpenAI 23 | case openAICompatible 24 | case googleAI 25 | case ollama 26 | case claude 27 | 28 | case unknown 29 | } 30 | 31 | public struct Info: Codable, Equatable { 32 | public struct OllamaInfo: Codable, Equatable { 33 | @FallbackDecoding 34 | public var keepAlive: String 35 | 36 | public init(keepAlive: String = "") { 37 | self.keepAlive = keepAlive 38 | } 39 | } 40 | 41 | public struct OpenAIInfo: Codable, Equatable { 42 | @FallbackDecoding 43 | public var organizationID: String 44 | 45 | public init(organizationID: String = "") { 46 | self.organizationID = organizationID 47 | } 48 | } 49 | 50 | @FallbackDecoding 51 | public var apiKeyName: String 52 | @FallbackDecoding 53 | public var baseURL: String 54 | @FallbackDecoding 55 | public var isFullURL: Bool 56 | @FallbackDecoding 57 | public var maxTokens: Int 58 | @FallbackDecoding 59 | public var supportsFunctionCalling: Bool 60 | @FallbackDecoding 61 | public var modelName: String 62 | 63 | @FallbackDecoding 64 | public var openAIInfo: OpenAIInfo 65 | @FallbackDecoding 66 | public var ollamaInfo: OllamaInfo 67 | 68 | public init( 69 | apiKeyName: String = "", 70 | baseURL: String = "", 71 | isFullURL: Bool = false, 72 | maxTokens: Int = 4000, 73 | supportsFunctionCalling: Bool = true, 74 | modelName: String = "", 75 | openAIInfo: OpenAIInfo = OpenAIInfo(), 76 | ollamaInfo: OllamaInfo = OllamaInfo() 77 | ) { 78 | self.apiKeyName = apiKeyName 79 | self.baseURL = baseURL 80 | self.isFullURL = isFullURL 81 | self.maxTokens = maxTokens 82 | self.supportsFunctionCalling = supportsFunctionCalling 83 | self.modelName = modelName 84 | self.openAIInfo = openAIInfo 85 | self.ollamaInfo = ollamaInfo 86 | } 87 | } 88 | 89 | public var endpoint: String { 90 | switch format { 91 | case .openAI: 92 | let baseURL = info.baseURL 93 | if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } 94 | return "\(baseURL)/v1/chat/completions" 95 | case .openAICompatible: 96 | let baseURL = info.baseURL 97 | if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } 98 | if info.isFullURL { return baseURL } 99 | return "\(baseURL)/v1/chat/completions" 100 | case .azureOpenAI: 101 | let baseURL = info.baseURL 102 | let deployment = info.modelName 103 | let version = "2023-07-01-preview" 104 | if baseURL.isEmpty { return "" } 105 | return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" 106 | case .googleAI: 107 | let baseURL = info.baseURL 108 | if baseURL.isEmpty { return "https://generativelanguage.googleapis.com/v1" } 109 | return "\(baseURL)/v1/chat/completions" 110 | case .ollama: 111 | let baseURL = info.baseURL 112 | if baseURL.isEmpty { return "http://localhost:11434/api/chat" } 113 | return "\(baseURL)/api/chat" 114 | case .claude: 115 | let baseURL = info.baseURL 116 | if baseURL.isEmpty { return "https://api.anthropic.com/v1/messages" } 117 | return "\(baseURL)/v1/messages" 118 | case .unknown: 119 | return "" 120 | } 121 | } 122 | } 123 | 124 | public struct EmptyChatModelInfo: FallbackValueProvider { 125 | public static var defaultValue: ChatModel.Info { .init() } 126 | } 127 | 128 | public struct EmptyChatModelFormat: FallbackValueProvider { 129 | public static var defaultValue: ChatModel.Format { .unknown } 130 | } 131 | 132 | public struct EmptyChatModelOllamaInfo: FallbackValueProvider { 133 | public static var defaultValue: ChatModel.Info.OllamaInfo { .init() } 134 | } 135 | 136 | public struct EmptyChatModelOpenAIInfo: FallbackValueProvider { 137 | public static var defaultValue: ChatModel.Info.OpenAIInfo { .init() } 138 | } 139 | 140 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/Models/CompletionModel.swift: -------------------------------------------------------------------------------- 1 | import CodableWrappers 2 | import Foundation 3 | 4 | /// A completion model. 5 | public struct CompletionModel: Codable, Equatable, Identifiable { 6 | public var id: String 7 | public var name: String 8 | @FallbackDecoding 9 | public var format: Format 10 | @FallbackDecoding 11 | public var info: Info 12 | 13 | public init(id: String, name: String, format: Format, info: Info) { 14 | self.id = id 15 | self.name = name 16 | self.format = format 17 | self.info = info 18 | } 19 | 20 | public enum Format: String, Codable, Equatable, CaseIterable { 21 | case openAI 22 | case azureOpenAI 23 | case openAICompatible 24 | case ollama 25 | 26 | case unknown 27 | } 28 | 29 | public struct Info: Codable, Equatable { 30 | public typealias OllamaInfo = ChatModel.Info.OllamaInfo 31 | public typealias OpenAIInfo = ChatModel.Info.OpenAIInfo 32 | 33 | @FallbackDecoding 34 | public var apiKeyName: String 35 | @FallbackDecoding 36 | public var baseURL: String 37 | @FallbackDecoding 38 | public var isFullURL: Bool 39 | @FallbackDecoding 40 | public var maxTokens: Int 41 | @FallbackDecoding 42 | public var modelName: String 43 | 44 | @FallbackDecoding 45 | public var openAIInfo: OpenAIInfo 46 | @FallbackDecoding 47 | public var ollamaInfo: OllamaInfo 48 | 49 | public init( 50 | apiKeyName: String = "", 51 | baseURL: String = "", 52 | isFullURL: Bool = false, 53 | maxTokens: Int = 4000, 54 | modelName: String = "", 55 | openAIInfo: OpenAIInfo = OpenAIInfo(), 56 | ollamaInfo: OllamaInfo = OllamaInfo() 57 | ) { 58 | self.apiKeyName = apiKeyName 59 | self.baseURL = baseURL 60 | self.isFullURL = isFullURL 61 | self.maxTokens = maxTokens 62 | self.modelName = modelName 63 | self.openAIInfo = openAIInfo 64 | self.ollamaInfo = ollamaInfo 65 | } 66 | } 67 | 68 | public var endpoint: String { 69 | switch format { 70 | case .openAI: 71 | let baseURL = info.baseURL 72 | if baseURL.isEmpty { return "https://api.openai.com/v1/completions" } 73 | return "\(baseURL)/v1/completions" 74 | case .openAICompatible: 75 | let baseURL = info.baseURL 76 | if baseURL.isEmpty { return "https://api.openai.com/v1/completions" } 77 | if info.isFullURL { return baseURL } 78 | return "\(baseURL)/v1/completions" 79 | case .azureOpenAI: 80 | let baseURL = info.baseURL 81 | let deployment = info.modelName 82 | let version = "2023-07-01-preview" 83 | if baseURL.isEmpty { return "" } 84 | return "\(baseURL)/openai/deployments/\(deployment)/completions?api-version=\(version)" 85 | case .ollama: 86 | let baseURL = info.baseURL 87 | if baseURL.isEmpty { return "http://localhost:11434/api/generate" } 88 | return "\(baseURL)/api/generate" 89 | case .unknown: 90 | return "" 91 | } 92 | } 93 | } 94 | 95 | public struct EmptyCompletionModelInfo: FallbackValueProvider { 96 | public static var defaultValue: CompletionModel.Info { .init() } 97 | } 98 | 99 | public struct EmptyCompletionModelFormat: FallbackValueProvider { 100 | public static var defaultValue: CompletionModel.Format { .unknown } 101 | } 102 | 103 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/Models/CustomModelType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum CustomModelType: String, CaseIterable { 4 | case chatModel 5 | case completionModel 6 | case fimModel 7 | case tabby 8 | 9 | public static var `default`: CustomModelType { 10 | .completionModel 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/Models/FIMModel.swift: -------------------------------------------------------------------------------- 1 | import CodableWrappers 2 | import Foundation 3 | 4 | /// A completion model. 5 | public struct FIMModel: Codable, Equatable, Identifiable { 6 | public var id: String 7 | public var name: String 8 | @FallbackDecoding 9 | public var format: Format 10 | @FallbackDecoding 11 | public var info: Info 12 | 13 | public init(id: String, name: String, format: Format, info: Info) { 14 | self.id = id 15 | self.name = name 16 | self.format = format 17 | self.info = info 18 | } 19 | 20 | public enum Format: String, Codable, Equatable, CaseIterable { 21 | case mistral 22 | case ollama 23 | case ollamaCompatible 24 | 25 | case unknown 26 | } 27 | 28 | public struct Info: Codable, Equatable { 29 | @FallbackDecoding 30 | public var apiKeyName: String 31 | @FallbackDecoding 32 | public var baseURL: String 33 | @FallbackDecoding 34 | public var isFullURL: Bool 35 | @FallbackDecoding 36 | public var maxTokens: Int 37 | @FallbackDecoding 38 | public var modelName: String 39 | @FallbackDecoding 40 | public var authenticationMode: AuthenticationMode 41 | @FallbackDecoding 42 | public var authenticationHeaderFieldName: String 43 | 44 | public enum AuthenticationMode: Codable, Equatable, CaseIterable { 45 | case header 46 | case bearerToken 47 | } 48 | 49 | @FallbackDecoding 50 | public var ollamaInfo: ChatModel.Info.OllamaInfo 51 | 52 | public init( 53 | apiKeyName: String = "", 54 | baseURL: String = "", 55 | isFullURL: Bool = false, 56 | maxTokens: Int = 4000, 57 | modelName: String = "", 58 | authenticationMode: AuthenticationMode = .bearerToken, 59 | authenticationHeaderFieldName: String = "", 60 | ollamaInfo: ChatModel.Info.OllamaInfo = ChatModel.Info.OllamaInfo() 61 | ) { 62 | self.apiKeyName = apiKeyName 63 | self.baseURL = baseURL 64 | self.isFullURL = isFullURL 65 | self.maxTokens = maxTokens 66 | self.modelName = modelName 67 | self.ollamaInfo = ollamaInfo 68 | self.authenticationMode = authenticationMode 69 | self.authenticationHeaderFieldName = authenticationHeaderFieldName 70 | } 71 | } 72 | 73 | public var endpoint: String { 74 | switch format { 75 | case .mistral: 76 | let baseURL = info.baseURL 77 | if baseURL.isEmpty { return "https://api.mistral.ai/v1/fim/completions" } 78 | if info.isFullURL { return baseURL } 79 | return "\(baseURL)/v1/fim/completions" 80 | case .ollama: 81 | let baseURL = info.baseURL 82 | if baseURL.isEmpty { return "http://localhost:11434/api/generate" } 83 | return "\(baseURL)/api/generate" 84 | case .ollamaCompatible: 85 | let baseURL = info.baseURL 86 | if baseURL.isEmpty { return "http://localhost:11434/api/generate" } 87 | if info.isFullURL { return baseURL } 88 | return "\(baseURL)/api/generate" 89 | case .unknown: 90 | return "" 91 | } 92 | } 93 | } 94 | 95 | public struct EmptyFIMModelInfo: FallbackValueProvider { 96 | public static var defaultValue: FIMModel.Info { .init() } 97 | } 98 | 99 | public struct EmptyFIMModelFormat: FallbackValueProvider { 100 | public static var defaultValue: FIMModel.Format { .unknown } 101 | } 102 | 103 | public struct EmptyFIMModelAuthenticationMode: FallbackValueProvider { 104 | public static var defaultValue: FIMModel.Info.AuthenticationMode { .bearerToken } 105 | } 106 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/Models/TabbyModel.swift: -------------------------------------------------------------------------------- 1 | import CodableWrappers 2 | import Foundation 3 | 4 | public struct TabbyModel: Codable, Equatable { 5 | public enum AuthorizationMode: String, Codable, CaseIterable { 6 | case none 7 | case bearerToken 8 | case basic 9 | case customHeaderField 10 | } 11 | 12 | @FallbackDecoding 13 | public var url: String 14 | @FallbackDecoding 15 | public var authorizationMode: AuthorizationMode 16 | @FallbackDecoding 17 | public var apiKeyName: String 18 | @FallbackDecoding 19 | public var authorizationHeaderName: String 20 | @FallbackDecoding 21 | public var username: String 22 | 23 | public init( 24 | url: String, 25 | authorizationMode: AuthorizationMode, 26 | apiKeyName: String, 27 | authorizationHeaderName: String, 28 | username: String 29 | ) { 30 | self.url = url 31 | self.authorizationMode = authorizationMode 32 | self.apiKeyName = apiKeyName 33 | self.authorizationHeaderName = authorizationHeaderName 34 | self.username = username 35 | } 36 | } 37 | 38 | public struct EmptyAuthorizationMode: FallbackValueProvider { 39 | public static var defaultValue: TabbyModel.AuthorizationMode { .none } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/PromptStrategy.swift: -------------------------------------------------------------------------------- 1 | import CopilotForXcodeKit 2 | import Foundation 3 | 4 | public protocol PromptStrategy { 5 | /// An instruction to the AI model to generate a completion. 6 | var systemPrompt: String { get } 7 | /// The source code before the text cursor. Represented as an array of lines. 8 | var prefix: [String] { get } 9 | /// The source code after the text cursor. Represented as an array of lines. 10 | var suffix: [String] { get } 11 | /// The prefix that should be prepended to the response. By default the last element of 12 | /// `prefix`. 13 | var suggestionPrefix: SuggestionPrefix { get } 14 | /// The relevant code snippets that the AI model should consider when generating a completion. 15 | var relevantCodeSnippets: [RelevantCodeSnippet] { get } 16 | /// The words at which the AI model should stop generating the completion. 17 | var stopWords: [String] { get } 18 | /// The language of the source code. 19 | var language: CodeLanguage? { get } 20 | /// If the prompt generated is raw. 21 | var promptIsRaw: Bool { get } 22 | 23 | /// Creates a prompt about the source code and relevant code snippets to be sent to the AI 24 | /// model. 25 | /// 26 | /// - Parameters: 27 | /// - truncatedPrefix: The truncated source code before the text cursor. 28 | /// - truncatedSuffix: The truncated source code after the text cursor. 29 | /// - includedSnippets: The relevant code snippets to be included in the prompt. 30 | /// 31 | /// - Warning: Please make sure that the prompt won't cause the whole prompt to 32 | /// exceed the token limit. 33 | func createPrompt( 34 | truncatedPrefix: [String], 35 | truncatedSuffix: [String], 36 | includedSnippets: [RelevantCodeSnippet] 37 | ) -> [PromptMessage] 38 | } 39 | 40 | /// A meesage in prompt. 41 | public struct PromptMessage { 42 | public enum PromptRole { 43 | case user 44 | case assistant 45 | public static var prefix: PromptRole { .user } 46 | public static var suffix: PromptRole { .assistant } 47 | } 48 | 49 | public var role: PromptRole 50 | public var content: String 51 | 52 | public init(role: PromptRole, content: String) { 53 | self.role = role 54 | self.content = content 55 | } 56 | } 57 | 58 | /// The last line of the prefix. 59 | public struct SuggestionPrefix { 60 | /// The original value. 61 | public var original: String 62 | /// The value to be in the prompt. This value can be different than the ``original`` value. Use 63 | /// it to tweak the prompt to make the AI model generate a better completion. 64 | /// 65 | /// For example, it the last character is `{`, we may want to start the generation from the 66 | /// next line. 67 | public var infillValue: String 68 | /// The value to be prepended to the response that is generated from the ``infillValue``. 69 | /// 70 | /// For example, if we appended `// write some code` in the ``infillValue`` to make the model 71 | /// generate code instead of comments, we may not want to include this line in the final 72 | /// suggestion. 73 | public var prependingValue: String 74 | 75 | public static var empty: SuggestionPrefix { 76 | .init(original: "", infillValue: "", prependingValue: "") 77 | } 78 | 79 | public static func unchanged(_ string: String) -> SuggestionPrefix { 80 | .init(original: string, infillValue: string, prependingValue: string) 81 | } 82 | 83 | public init(original: String, infillValue: String, prependingValue: String) { 84 | self.original = original 85 | self.infillValue = infillValue 86 | self.prependingValue = prependingValue 87 | } 88 | } 89 | 90 | // MARK: - Default Implementations 91 | 92 | public extension PromptStrategy { 93 | var suggestionPrefix: SuggestionPrefix { 94 | guard let prefix = prefix.last else { return .empty } 95 | return .unchanged(prefix) 96 | } 97 | 98 | var promptIsRaw: Bool { false } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/TextProcessing/ConvertRange.swift: -------------------------------------------------------------------------------- 1 | import CopilotForXcodeKit 2 | import Foundation 3 | 4 | public func convertRangeToCursorRange( 5 | _ range: ClosedRange, 6 | in lines: [String] 7 | ) -> CursorRange { 8 | guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) } 9 | var countS = 0 10 | var countE = 0 11 | var cursorRange = CursorRange(start: .zero, end: .outOfScope) 12 | for (i, line) in lines.enumerated() { 13 | // The range is counted in UTF8, which causes line endings like \r\n to be of length 2. 14 | let lineEndingAddition = line.lineEnding.utf8.count - 1 15 | if countS <= range.lowerBound, 16 | range.lowerBound < countS + line.count + lineEndingAddition 17 | { 18 | cursorRange.start = .init(line: i, character: range.lowerBound - countS) 19 | } 20 | if countE <= range.upperBound, 21 | range.upperBound < countE + line.count + lineEndingAddition 22 | { 23 | cursorRange.end = .init(line: i, character: range.upperBound - countE) 24 | break 25 | } 26 | countS += line.count + lineEndingAddition 27 | countE += line.count + lineEndingAddition 28 | } 29 | if cursorRange.end == .outOfScope { 30 | cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0) 31 | } 32 | return cursorRange 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/TextProcessing/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension String { 4 | /// The line ending of the string. 5 | /// 6 | /// We are pretty safe to just check the last character here, in most case, a line ending 7 | /// will be in the end of the string. 8 | /// 9 | /// For other situations, we can assume that they are "\n". 10 | var lineEnding: Character { 11 | if let last, last.isNewline { return last } 12 | return "\n" 13 | } 14 | 15 | func splitByNewLine( 16 | omittingEmptySubsequences: Bool = true, 17 | fast: Bool = true 18 | ) -> [Substring] { 19 | if fast { 20 | let lineEndingInText = lineEnding 21 | return split( 22 | separator: lineEndingInText, 23 | omittingEmptySubsequences: omittingEmptySubsequences 24 | ) 25 | } 26 | return split( 27 | omittingEmptySubsequences: omittingEmptySubsequences, 28 | whereSeparator: \.isNewline 29 | ) 30 | } 31 | 32 | /// Break a string into lines. 33 | func breakLines( 34 | proposedLineEnding: String? = nil, 35 | appendLineBreakToLastLine: Bool = false 36 | ) -> [String] { 37 | let lineEndingInText = lineEnding 38 | let lineEnding = proposedLineEnding ?? String(lineEndingInText) 39 | // Split on character for better performance. 40 | let lines = split(separator: lineEndingInText, omittingEmptySubsequences: false) 41 | var all = [String]() 42 | for (index, line) in lines.enumerated() { 43 | if !appendLineBreakToLastLine, index == lines.endIndex - 1 { 44 | all.append(String(line)) 45 | } else { 46 | all.append(String(line) + lineEnding) 47 | } 48 | } 49 | return all 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Core/Sources/Fundamental/TruncateStrategy/TruncateStrategy.swift: -------------------------------------------------------------------------------- 1 | import CopilotForXcodeKit 2 | import Foundation 3 | 4 | public protocol TruncateStrategy { 5 | func createTruncatedPrompt(promptStrategy: PromptStrategy) -> [PromptMessage] 6 | } 7 | 8 | public struct DefaultTruncateStrategy: TruncateStrategy { 9 | let maxTokenLimit: Int 10 | let countToken: ([PromptMessage]) -> Int 11 | 12 | public init(maxTokenLimit: Int, countToken: @escaping ([PromptMessage]) -> Int = { 13 | $0.reduce(0) { $0 + $1.content.count } 14 | }) { 15 | self.maxTokenLimit = maxTokenLimit 16 | self.countToken = countToken 17 | } 18 | 19 | public func createTruncatedPrompt(promptStrategy: PromptStrategy) -> [PromptMessage] { 20 | var prefix = promptStrategy.prefix 21 | var suffix = promptStrategy.suffix 22 | var snippets = promptStrategy.relevantCodeSnippets 23 | 24 | var prompts = promptStrategy.createPrompt( 25 | truncatedPrefix: prefix, 26 | truncatedSuffix: suffix, 27 | includedSnippets: snippets 28 | ) 29 | 30 | let limit = maxTokenLimit - countToken([.init( 31 | role: .user, 32 | content: promptStrategy.systemPrompt 33 | )]) 34 | 35 | let prefixDropWeight = 1 36 | let suffixDropWeight = 5 37 | let snippetsDropWeight = 8 38 | 39 | while countToken(prompts) > limit, 40 | !(prefix.isEmpty && suffix.isEmpty && snippets.isEmpty) 41 | { 42 | let p = prefix.count * prefixDropWeight 43 | let s = suffix.count * suffixDropWeight 44 | let n = snippets.count * snippetsDropWeight 45 | 46 | let maxScore = max(p, s, n) 47 | switch maxScore { 48 | case s: 49 | truncateSuffix(&suffix) 50 | case n: 51 | truncateSnippets(&snippets) 52 | case p: 53 | truncatePrefix(&prefix) 54 | default: 55 | truncateSuffix(&suffix) 56 | } 57 | 58 | prompts = promptStrategy.createPrompt( 59 | truncatedPrefix: prefix, 60 | truncatedSuffix: suffix, 61 | includedSnippets: snippets 62 | ) 63 | } 64 | 65 | return prompts 66 | } 67 | 68 | /// Drop the last one third. 69 | func truncateSuffix(_ suffix: inout [String]) { 70 | let step = 3 71 | 72 | if suffix.isEmpty { return } 73 | let dropCount = max(suffix.count / step, 1) 74 | suffix.removeLast(dropCount) 75 | } 76 | 77 | /// Drop the leading one fourth. 78 | func truncatePrefix(_ prefix: inout [String]) { 79 | let step = 4 80 | 81 | if prefix.isEmpty { return } 82 | let dropCount = max(prefix.count / step, 1) 83 | prefix.removeFirst(dropCount) 84 | } 85 | 86 | func truncateSnippets(_ snippets: inout [RelevantCodeSnippet]) { 87 | if snippets.isEmpty { return } 88 | snippets.removeLast() 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /Core/Sources/Storage/AppStorage+PreferenceKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(SwiftUI) 4 | 5 | import SwiftUI 6 | 7 | public extension AppStorage { 8 | init( 9 | _ keyPath: KeyPath 10 | ) where K.Value == Value, Value == Bool { 11 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 12 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 13 | } 14 | 15 | init( 16 | _ keyPath: KeyPath 17 | ) where K.Value == Value, Value == String { 18 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 19 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 20 | } 21 | 22 | init( 23 | _ keyPath: KeyPath 24 | ) where K.Value == Value, Value == Double { 25 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 26 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 27 | } 28 | 29 | init( 30 | _ keyPath: KeyPath 31 | ) where K.Value == Value, Value == Int { 32 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 33 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 34 | } 35 | 36 | init( 37 | _ keyPath: KeyPath 38 | ) where K.Value == Value, Value == URL { 39 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 40 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 41 | } 42 | 43 | init( 44 | _ keyPath: KeyPath 45 | ) where K.Value == Value, Value == Data { 46 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 47 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 48 | } 49 | 50 | init( 51 | _ keyPath: KeyPath 52 | ) where K.Value == Value, Value: RawRepresentable, Value.RawValue == Int { 53 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 54 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 55 | } 56 | 57 | init( 58 | _ keyPath: KeyPath 59 | ) where K.Value == Value, Value: RawRepresentable, Value.RawValue == String { 60 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 61 | self.init(wrappedValue: key.defaultValue, key.key, store: .shared) 62 | } 63 | } 64 | 65 | public extension AppStorage where Value: ExpressibleByNilLiteral { 66 | init( 67 | _ keyPath: KeyPath 68 | ) where K.Value == Value, Value == Bool? { 69 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 70 | self.init(key.key, store: .shared) 71 | } 72 | 73 | init( 74 | _ keyPath: KeyPath 75 | ) where K.Value == Value, Value == String? { 76 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 77 | self.init(key.key, store: .shared) 78 | } 79 | 80 | init( 81 | _ keyPath: KeyPath 82 | ) where K.Value == Value, Value == Double? { 83 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 84 | self.init(key.key, store: .shared) 85 | } 86 | 87 | init( 88 | _ keyPath: KeyPath 89 | ) where K.Value == Value, Value == Int? { 90 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 91 | self.init(key.key, store: .shared) 92 | } 93 | 94 | init( 95 | _ keyPath: KeyPath 96 | ) where K.Value == Value, Value == URL? { 97 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 98 | self.init(key.key, store: .shared) 99 | } 100 | 101 | init( 102 | _ keyPath: KeyPath 103 | ) where K.Value == Value, Value == Data? { 104 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 105 | self.init(key.key, store: .shared) 106 | } 107 | } 108 | 109 | public extension AppStorage { 110 | init( 111 | _ keyPath: KeyPath 112 | ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == String { 113 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 114 | self.init(key.key, store: .shared) 115 | } 116 | 117 | init( 118 | _ keyPath: KeyPath 119 | ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == Int { 120 | let key = UserDefaultPreferenceKeys()[keyPath: keyPath] 121 | self.init(key.key, store: .shared) 122 | } 123 | } 124 | 125 | #endif 126 | 127 | -------------------------------------------------------------------------------- /Core/Sources/Storage/Configurations.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private var teamIDPrefix: String { 4 | Bundle.main.infoDictionary?["TEAM_ID_PREFIX"] as? String ?? "" 5 | } 6 | 7 | private var bundleIdentifierBase: String { 8 | Bundle.main.infoDictionary?["BUNDLE_IDENTIFIER_BASE"] as? String ?? "" 9 | } 10 | 11 | public var userDefaultSuiteName: String { 12 | "\(teamIDPrefix)group.\(bundleIdentifierBase)" 13 | } 14 | 15 | public var keychainAccessGroup: String { 16 | return "\(teamIDPrefix)\(bundleIdentifierBase).Shared" 17 | } 18 | 19 | public var keychainService: String { 20 | return bundleIdentifierBase 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Core/Sources/Storage/Preferences.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Fundamental 3 | 4 | public struct UserDefaultPreferenceKeys { 5 | public init() {} 6 | } 7 | 8 | public protocol UserDefaultPreferenceKey { 9 | associatedtype Value 10 | var defaultValue: Value { get } 11 | var key: String { get } 12 | } 13 | 14 | public struct PreferenceKey: UserDefaultPreferenceKey { 15 | public let defaultValue: T 16 | public let key: String 17 | 18 | public init(defaultValue: T, key: String) { 19 | self.defaultValue = defaultValue 20 | self.key = key 21 | } 22 | } 23 | 24 | public extension UserDefaultPreferenceKeys { 25 | /// Access the chat models set in Copilot for Xcode. 26 | /// 27 | /// - Note: It only works when they are in the same app group. 28 | var chatModelsFromCopilotForXcode: PreferenceKey<[ChatModel]> { 29 | .init(defaultValue: [], key: "ChatModels") 30 | } 31 | 32 | var serviceType: PreferenceKey { 33 | .init(defaultValue: "", key: "CustomSuggestionService-ServiceType") 34 | } 35 | 36 | var customChatModel: PreferenceKey> { 37 | .init( 38 | defaultValue: .init(ChatModel( 39 | id: "ID", 40 | name: "Custom", 41 | format: .openAI, 42 | info: .init() 43 | )), 44 | key: "CustomSuggestionService-CustomChatModel" 45 | ) 46 | } 47 | 48 | var customCompletionModel: PreferenceKey> { 49 | .init( 50 | defaultValue: .init(CompletionModel( 51 | id: "ID", 52 | name: "Custom", 53 | format: .openAI, 54 | info: .init() 55 | )), 56 | key: "CustomSuggestionService-CustomCompletionModel" 57 | ) 58 | } 59 | 60 | var customFIMModel: PreferenceKey> { 61 | .init( 62 | defaultValue: .init(FIMModel( 63 | id: "ID", 64 | name: "Custom", 65 | format: .mistral, 66 | info: .init() 67 | )), 68 | key: "CustomSuggestionService-CustomFIMModel" 69 | ) 70 | } 71 | 72 | var requestStrategyId: PreferenceKey { 73 | .init(defaultValue: "", key: "CustomSuggestionService-RequestStrategyId") 74 | } 75 | 76 | var chatModelId: PreferenceKey { 77 | .init( 78 | defaultValue: CustomModelType.default.rawValue, 79 | key: "CustomSuggestionService-SuggestionChatModelId" 80 | ) 81 | } 82 | 83 | var tabbyModel: PreferenceKey> { 84 | .init( 85 | defaultValue: .init(.init( 86 | url: "", 87 | authorizationMode: .none, 88 | apiKeyName: "", 89 | authorizationHeaderName: "", 90 | username: "" 91 | )), 92 | key: "CustomSuggestionService-TabbyModel" 93 | ) 94 | } 95 | 96 | var maxNumberOfLinesOfSuggestion: PreferenceKey { 97 | .init( 98 | defaultValue: 0, 99 | key: "CustomSuggestionService-MaxNumberOfLinesOfSuggestion" 100 | ) 101 | } 102 | 103 | var installBetaBuild: PreferenceKey { 104 | .init(defaultValue: false, key: "CustomSuggestionService-InstallBetaBuild") 105 | } 106 | 107 | var verboseLog: PreferenceKey { 108 | .init(defaultValue: false, key: "CustomSuggestionService-VerboseLog") 109 | } 110 | 111 | var fimStopToken: PreferenceKey { 112 | .init( 113 | defaultValue: "", 114 | key: "CustomSuggestionService-FimStopToken" 115 | ) 116 | } 117 | 118 | var fimTemplate: PreferenceKey { 119 | .init( 120 | defaultValue: "
 {prefix} {suffix} ",
121 |             key: "CustomSuggestionService-FimTemplate"
122 |         )
123 |     }
124 |     
125 |     var fimPromptIsRaw: PreferenceKey {
126 |         .init(
127 |             defaultValue: false,
128 |             key: "CustomSuggestionService-FimPromptIsRaw"
129 |         )
130 |     }
131 |     
132 |     var fimAttachFileInfo: PreferenceKey {
133 |         .init(
134 |             defaultValue: true,
135 |             key: "CustomSuggestionService-fimAttachFileInfo"
136 |         )
137 |     }
138 |     
139 |     var maxGenerationToken: PreferenceKey {
140 |         .init(
141 |             defaultValue: 200,
142 |             key: "CustomSuggestionService-MaxGenerationToken"
143 |         )
144 |     }
145 | }
146 | 
147 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift:
--------------------------------------------------------------------------------
  1 | import Foundation
  2 | import Parsing
  3 | 
  4 | protocol RawSuggestionPostProcessingStrategy {
  5 |     func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String
  6 | }
  7 | 
  8 | struct DefaultRawSuggestionPostProcessingStrategy: RawSuggestionPostProcessingStrategy {
  9 |     let codeWrappingTags: (opening: String, closing: String)?
 10 | 
 11 |     func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String {
 12 |         var suggestion = extractSuggestion(from: rawSuggestion)
 13 |         removePrefix(from: &suggestion, infillPrefix: infillPrefix)
 14 |         removeSuffix(from: &suggestion, suffix: suffix)
 15 |         return infillPrefix + suggestion
 16 |     }
 17 | 
 18 |     func extractSuggestion(from response: String) -> String {
 19 |         let escapedMarkdownCodeBlock = removeLeadingAndTrailingMarkdownCodeBlockMark(from: response)
 20 |         if let tags = codeWrappingTags {
 21 |             let escapedTags = extractEnclosingSuggestion(
 22 |                 from: escapedMarkdownCodeBlock,
 23 |                 openingTag: tags.opening,
 24 |                 closingTag: tags.closing
 25 |             )
 26 |             return escapedTags
 27 |         } else {
 28 |             return escapedMarkdownCodeBlock
 29 |         }
 30 |     }
 31 | 
 32 |     func removePrefix(from suggestion: inout String, infillPrefix: String) {
 33 |         if suggestion.hasPrefix(infillPrefix) {
 34 |             suggestion.removeFirst(infillPrefix.count)
 35 |         }
 36 |     }
 37 | 
 38 |     /// Window-mapping the lines in suggestion and the suffix to remove the common suffix.
 39 |     func removeSuffix(from suggestion: inout String, suffix: [String]) {
 40 |         let suggestionLines = suggestion.breakLines(appendLineBreakToLastLine: true)
 41 |         if let last = suggestionLines.last, let lastIndex = suffix.firstIndex(of: last) {
 42 |             var i = lastIndex - 1
 43 |             var j = suggestionLines.endIndex - 2
 44 |             while i >= 0, j >= 0, suffix[i] == suggestionLines[j] {
 45 |                 i -= 1
 46 |                 j -= 1
 47 |             }
 48 |             if i < 0 {
 49 |                 let endIndex = max(j, 0)
 50 |                 suggestion = suggestionLines[...endIndex].joined()
 51 |             }
 52 |         }
 53 |     }
 54 | 
 55 |     /// Extract suggestions that is enclosed in tags.
 56 |     fileprivate func extractEnclosingSuggestion(
 57 |         from response: String,
 58 |         openingTag: String,
 59 |         closingTag: String
 60 |     ) -> String {
 61 |         guard !openingTag.isEmpty, !closingTag.isEmpty else {
 62 |             return response
 63 |         }
 64 |         
 65 |         let case_openingTagAtTheStart_parseEverythingInsideTheTag = Parse(input: Substring.self) {
 66 |             openingTag
 67 | 
 68 |             OneOf { // parse until tags or the end
 69 |                 Parse {
 70 |                     OneOf {
 71 |                         PrefixUpTo(openingTag)
 72 |                         PrefixUpTo(closingTag)
 73 |                     }
 74 |                     Skip {
 75 |                         Rest()
 76 |                     }
 77 |                 }
 78 | 
 79 |                 Rest()
 80 |             }
 81 |         }
 82 | 
 83 |         let case_noTagAtTheStart_parseEverythingBeforeTheTag = Parse(input: Substring.self) {
 84 |             OneOf {
 85 |                 PrefixUpTo(openingTag)
 86 |                 PrefixUpTo(closingTag)
 87 |             }
 88 | 
 89 |             Skip {
 90 |                 Rest()
 91 |             }
 92 |         }
 93 | 
 94 |         let parser = Parse(input: Substring.self) {
 95 |             OneOf {
 96 |                 case_openingTagAtTheStart_parseEverythingInsideTheTag
 97 |                 case_noTagAtTheStart_parseEverythingBeforeTheTag
 98 |                 Rest()
 99 |             }
100 |         }
101 | 
102 |         var text = response[...]
103 |         do {
104 |             let suggestion = try parser.parse(&text)
105 |             return String(suggestion)
106 |         } catch {
107 |             return response
108 |         }
109 |     }
110 | 
111 |     /// If the response starts with markdown code block, we should remove it.
112 |     fileprivate func removeLeadingAndTrailingMarkdownCodeBlockMark(from response: String)
113 |         -> String
114 |     {
115 |         let leadingMarkdownCodeBlockMarkParser = Parse(input: Substring.self) {
116 |             Skip {
117 |                 Many {
118 |                     OneOf {
119 |                         " "
120 |                         "\n"
121 |                     }
122 |                 }
123 |                 "```"
124 |             }
125 |         }
126 |         
127 |         let messagePrefixingMarkdownCodeBlockMarkParser = Parse(input: Substring.self) {
128 |             Skip {
129 |                 PrefixThrough(":")
130 |                 "\n```"
131 |             }
132 |         }
133 |         
134 |         let removePrefixMarkdownCodeBlockMark = Parse(input: Substring.self) {
135 |             Skip {
136 |                 OneOf {
137 |                     leadingMarkdownCodeBlockMarkParser
138 |                     messagePrefixingMarkdownCodeBlockMarkParser
139 |                 }
140 |                 PrefixThrough("\n")
141 |             }
142 |             OneOf {
143 |                 Parse {
144 |                     PrefixUpTo("```")
145 |                     Skip { Rest() }
146 |                 }
147 |                 Rest()
148 |             }
149 |         }
150 | 
151 |         do {
152 |             var response = response[...]
153 |             let suggestion = try removePrefixMarkdownCodeBlockMark.parse(&response)
154 |             return String(suggestion)
155 |         } catch {
156 |             return response
157 |         }
158 |     }
159 | }
160 | 
161 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | 
3 | struct NoOpRawSuggestionPostProcessingStrategy: RawSuggestionPostProcessingStrategy {
4 |     func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String {
5 |         infillPrefix + rawSuggestion
6 |     }
7 | }
8 | 
9 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RequestStrategies/DefaultRequestStrategy.swift:
--------------------------------------------------------------------------------
  1 | import CodeCompletionService
  2 | import CopilotForXcodeKit
  3 | import Foundation
  4 | import Fundamental
  5 | 
  6 | /// The default strategy to generate prompts.
  7 | ///
  8 | /// This strategy tries to believe that the model is smart. It will explain carefully what is what
  9 | /// and tell the model to complete the code.
 10 | struct DefaultRequestStrategy: RequestStrategy {
 11 |     var sourceRequest: SuggestionRequest
 12 |     var prefix: [String]
 13 |     var suffix: [String]
 14 | 
 15 |     var shouldSkip: Bool {
 16 |         prefix.last?.trimmingCharacters(in: .whitespaces) == "}"
 17 |     }
 18 | 
 19 |     func createPrompt() -> Prompt {
 20 |         Prompt(
 21 |             sourceRequest: sourceRequest,
 22 |             prefix: prefix,
 23 |             suffix: suffix
 24 |         )
 25 |     }
 26 | 
 27 |     func createStreamStopStrategy(model: Service.Model) -> some StreamStopStrategy {
 28 |         OpeningTagBasedStreamStopStrategy(
 29 |             openingTag: Tag.openingCode,
 30 |             toleranceIfNoOpeningTagFound: { if case .chatModel = model { 4 } else { 0 } }()
 31 |         )
 32 |     }
 33 | 
 34 |     func createRawSuggestionPostProcessor() -> DefaultRawSuggestionPostProcessingStrategy {
 35 |         DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: (
 36 |             Tag.openingCode,
 37 |             Tag.closingCode
 38 |         ))
 39 |     }
 40 | 
 41 |     enum Tag {
 42 |         public static let openingCode = ""
 43 |         public static let closingCode = ""
 44 |         public static let openingSnippet = ""
 45 |         public static let closingSnippet = ""
 46 |     }
 47 | 
 48 |     struct Prompt: PromptStrategy {
 49 |         let systemPrompt: String = """
 50 |         You are a senior programer who take the surrounding code and \
 51 |         references from the codebase into account in order to write high-quality code to \
 52 |         complete the code enclosed in \(Tag.openingCode) tags. \
 53 |         You only respond with code that works and fits seamlessly with surrounding code. \
 54 |         Don't include anything else beyond the code.
 55 | 
 56 |         Code completion means to keep writing the code. For example, if I tell you to 
 57 |         ###
 58 |         Complete code inside \(Tag.openingCode):
 59 | 
 60 |         \(Tag.openingCode)
 61 |         print("Hello
 62 |         ###
 63 | 
 64 |         You should respond with:
 65 |         ###
 66 |          World")\(Tag.closingCode)
 67 |         ###
 68 |         """.trimmingCharacters(in: .whitespacesAndNewlines)
 69 |         var sourceRequest: SuggestionRequest
 70 |         var prefix: [String]
 71 |         var suffix: [String]
 72 |         var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path }
 73 |         var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets }
 74 |         var stopWords: [String] { [Tag.closingCode, "\n\n"] }
 75 |         var language: CodeLanguage? { sourceRequest.language }
 76 | 
 77 |         var suggestionPrefix: SuggestionPrefix {
 78 |             guard let prefix = prefix.last else { return .empty }
 79 |             return .unchanged(prefix).curlyBracesLineBreak()
 80 |         }
 81 | 
 82 |         func createPrompt(
 83 |             truncatedPrefix: [String],
 84 |             truncatedSuffix: [String],
 85 |             includedSnippets: [RelevantCodeSnippet]
 86 |         ) -> [PromptMessage] {
 87 |             return [.init(role: .user, content: [
 88 |                 Self.createSnippetsPrompt(includedSnippets: includedSnippets),
 89 |                 createSourcePrompt(
 90 |                     truncatedPrefix: truncatedPrefix,
 91 |                     truncatedSuffix: truncatedSuffix
 92 |                 ),
 93 |             ].filter { !$0.isEmpty }.joined(separator: "\n\n"))]
 94 |         }
 95 | 
 96 |         func createSourcePrompt(truncatedPrefix: [String], truncatedSuffix: [String]) -> String {
 97 |             guard let (summary, infillBlock) = Self.createCodeSummary(
 98 |                 truncatedPrefix: truncatedPrefix,
 99 |                 truncatedSuffix: truncatedSuffix,
100 |                 suggestionPrefix: suggestionPrefix.infillValue
101 |             ) else { return "" }
102 | 
103 |             return """
104 |             Below is the code from file \(filePath) that you are trying to complete.
105 |             Review the code carefully, detect the functionality, formats, style, patterns, \
106 |             and logics in use and use them to predict the completion. \
107 |             Make sure your completion has the correct syntax and formatting. \
108 |             Enclose the completion the XML tag \(Tag.openingCode). \
109 |             Don't duplicate existing implementations. \
110 | 
111 |             File Path: \(filePath)
112 |             Indentation: \
113 |             \(sourceRequest.indentSize) \(sourceRequest.usesTabsForIndentation ? "tab" : "space")
114 | 
115 |             ---
116 | 
117 |             Here is the code:
118 |             ```
119 |             \(summary)
120 |             ```
121 | 
122 |             Complete code inside \(Tag.openingCode):
123 | 
124 |             \(Tag.openingCode)\(infillBlock)
125 |             """.trimmingCharacters(in: .whitespacesAndNewlines)
126 |         }
127 | 
128 |         static func createSnippetsPrompt(includedSnippets: [RelevantCodeSnippet]) -> String {
129 |             guard !includedSnippets.isEmpty else { return "" }
130 |             var content = "References from codebase: \n\n"
131 |             for snippet in includedSnippets {
132 |                 content += """
133 |                 \(Tag.openingSnippet)
134 |                 \(snippet.content)
135 |                 \(Tag.closingSnippet)
136 |                 """ + "\n\n"
137 |             }
138 |             return content.trimmingCharacters(in: .whitespacesAndNewlines)
139 |         }
140 | 
141 |         static func createCodeSummary(
142 |             truncatedPrefix: [String],
143 |             truncatedSuffix: [String],
144 |             suggestionPrefix: String
145 |         ) -> (summary: String, infillBlock: String)? {
146 |             guard !(truncatedPrefix.isEmpty && truncatedSuffix.isEmpty) else { return nil }
147 |             let promptLinesCount = min(10, max(truncatedPrefix.count, 2))
148 |             let prefixLines = truncatedPrefix.prefix(
149 |                 max(0, truncatedPrefix.count - promptLinesCount)
150 |             )
151 |             let promptLines: [String] = {
152 |                 let proposed = truncatedPrefix.suffix(promptLinesCount)
153 |                 return Array(proposed.dropLast()) + [suggestionPrefix]
154 |             }()
155 | 
156 |             return (
157 |                 summary: "\(prefixLines.joined())\(Tag.openingCode)\(Tag.closingCode)\(truncatedSuffix.joined())",
158 |                 infillBlock: promptLines.joined()
159 |             )
160 |         }
161 |     }
162 | }
163 | 
164 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift:
--------------------------------------------------------------------------------
 1 | import CodeCompletionService
 2 | import CopilotForXcodeKit
 3 | import Foundation
 4 | import Fundamental
 5 | 
 6 | /// A special strategy for FIM endpoints.
 7 | struct FIMEndpointRequestStrategy: RequestStrategy {
 8 |     var sourceRequest: SuggestionRequest
 9 |     var prefix: [String]
10 |     var suffix: [String]
11 | 
12 |     var shouldSkip: Bool {
13 |         prefix.last?.trimmingCharacters(in: .whitespaces) == "}"
14 |     }
15 | 
16 |     func createPrompt() -> Prompt {
17 |         Prompt(
18 |             sourceRequest: sourceRequest,
19 |             prefix: prefix,
20 |             suffix: suffix
21 |         )
22 |     }
23 | 
24 |     func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy {
25 |         DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: nil)
26 |     }
27 | 
28 |     func createStreamStopStrategy(model: Service.Model) -> some StreamStopStrategy {
29 |         FIMStreamStopStrategy(prefix: prefix)
30 |     }
31 | 
32 |     struct Prompt: PromptStrategy {
33 |         let systemPrompt: String = ""
34 |         var sourceRequest: SuggestionRequest
35 |         var prefix: [String]
36 |         var suffix: [String]
37 |         var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path }
38 |         var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets }
39 |         var stopWords: [String] { [] }
40 |         var language: CodeLanguage? { sourceRequest.language }
41 | 
42 |         var suggestionPrefix: SuggestionPrefix {
43 |             guard let prefix = prefix.last else { return .empty }
44 |             return .unchanged(prefix)
45 |         }
46 | 
47 |         init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) {
48 |             self.sourceRequest = sourceRequest
49 | 
50 |             let prefix = sourceRequest.relevantCodeSnippets.map { $0.content + "\n\n" }
51 |                 + prefix
52 | 
53 |             self.prefix = prefix
54 |             self.suffix = suffix
55 |         }
56 | 
57 |         func createPrompt(
58 |             truncatedPrefix: [String],
59 |             truncatedSuffix: [String],
60 |             includedSnippets: [RelevantCodeSnippet]
61 |         ) -> [PromptMessage] {
62 |             let suffix = truncatedSuffix.joined()
63 |             let prefixContent = """
64 |             // File Path: \(filePath)
65 |             // Indentation: \
66 |             \(sourceRequest.indentSize) \
67 |             \(sourceRequest.usesTabsForIndentation ? "tab" : "space")
68 |             \(includedSnippets.map(\.content).joined(separator: "\n\n"))
69 |             \(truncatedPrefix.joined())
70 |             """
71 |             
72 |             let suffixContent = suffix.isEmpty ? "\n// End of file" : suffix
73 |             
74 |             return [
75 |                 .init(role: .prefix, content: prefixContent),
76 |                 .init(role: .suffix, content: suffixContent)
77 |             ]
78 |         }
79 |     }
80 | }
81 | 
82 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift:
--------------------------------------------------------------------------------
  1 | import CodeCompletionService
  2 | import CopilotForXcodeKit
  3 | import Foundation
  4 | import Fundamental
  5 | 
  6 | /// https://ollama.com/library/codellama
  7 | struct FillInTheMiddleRequestStrategy: RequestStrategy {
  8 |     var sourceRequest: SuggestionRequest
  9 |     var prefix: [String]
 10 |     var suffix: [String]
 11 | 
 12 |     var shouldSkip: Bool {
 13 |         prefix.last?.trimmingCharacters(in: .whitespaces) == "}"
 14 |     }
 15 | 
 16 |     func createPrompt() -> Prompt {
 17 |         Prompt(
 18 |             sourceRequest: sourceRequest,
 19 |             prefix: prefix,
 20 |             suffix: suffix
 21 |         )
 22 |     }
 23 | 
 24 |     func createStreamStopStrategy(model: Service.Model) -> some StreamStopStrategy {
 25 |         FIMStreamStopStrategy(prefix: prefix)
 26 |     }
 27 | 
 28 |     func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy {
 29 |         DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: nil)
 30 |     }
 31 | 
 32 |     enum Tag {
 33 |         public static var stop: String { UserDefaults.shared.value(for: \.fimStopToken) }
 34 |     }
 35 | 
 36 |     struct Prompt: PromptStrategy {
 37 |         fileprivate(set) var systemPrompt: String = ""
 38 |         var sourceRequest: SuggestionRequest
 39 |         var prefix: [String]
 40 |         var suffix: [String]
 41 |         var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path }
 42 |         var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets }
 43 |         // Stop words should support multiple comma-separated
 44 |         var stopWords: [String] {
 45 |             return ["\n\n"] + Tag.stop.split(separator: ",").map { String($0) }.filter { !$0.isEmpty }
 46 |         }
 47 |         var language: CodeLanguage? { sourceRequest.language }
 48 |         var promptIsRaw: Bool { UserDefaults.shared.value(for: \.fimPromptIsRaw) }
 49 |         var attachFileInfo: Bool { UserDefaults.shared.value(for: \.fimAttachFileInfo) }
 50 | 
 51 |         var suggestionPrefix: SuggestionPrefix {
 52 |             guard let prefix = prefix.last else { return .empty }
 53 |             return .unchanged(prefix).curlyBracesLineBreak()
 54 |         }
 55 | 
 56 |         func createPrompt(
 57 |             truncatedPrefix: [String],
 58 |             truncatedSuffix: [String],
 59 |             includedSnippets: [RelevantCodeSnippet]
 60 |         ) -> [PromptMessage] {
 61 |             let suffix = truncatedSuffix.joined()
 62 |             var template = UserDefaults.shared.value(for: \.fimTemplate)
 63 |             if template.isEmpty { template = UserDefaults.shared.defaultValue(for: \.fimTemplate) }
 64 |             // Determine whether to append file information according to attachFileInfo
 65 |             let fileInfo = attachFileInfo ? """
 66 |                 // File Path: \(filePath)
 67 |                 // Indentation: \
 68 |                 \(sourceRequest.indentSize) \
 69 |                 \(sourceRequest.usesTabsForIndentation ? "tab" : "space")
 70 |                 """ : ""
 71 |             let prefixContent = """
 72 |                 \(fileInfo)\(fileInfo.isEmpty ? "" : "\n")\(includedSnippets.map(\.content).joined(separator: "\n\n"))
 73 |                 \(truncatedPrefix.joined())
 74 |                 """
 75 |             
 76 |             let suffixContent = suffix.isEmpty ? "\n// End of file" : suffix
 77 |             
 78 |             return [
 79 |                 .init(
 80 |                     role: .user,
 81 |                     content: template
 82 |                         .replacingOccurrences(of: "{prefix}", with: prefixContent)
 83 |                         .replacingOccurrences(of: "{suffix}", with: suffixContent)
 84 |                         .trimmingCharacters(in: .whitespacesAndNewlines)
 85 |                 ),
 86 |             ]
 87 |         }
 88 |     }
 89 | }
 90 | 
 91 | struct FillInTheMiddleWithSystemPromptRequestStrategy: RequestStrategy {
 92 |     let strategy: FillInTheMiddleRequestStrategy
 93 | 
 94 |     init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) {
 95 |         strategy = .init(sourceRequest: sourceRequest, prefix: prefix, suffix: suffix)
 96 |     }
 97 | 
 98 |     func createPrompt() -> some PromptStrategy {
 99 |         var prompt = strategy.createPrompt()
100 |         prompt.systemPrompt = """
101 |         You are a senior programer who take the surrounding code and \
102 |         references from the codebase into account in order to write high-quality code to \
103 |         complete the code enclosed in the given code. \
104 |         You only respond with code that works and fits seamlessly with surrounding code. \
105 |         Don't include anything else beyond the code. \
106 |         The prefix will follow the PRE tag and the suffix will follow the SUF tag. \
107 |         You should write the code that fits seamlessly after the MID tag.
108 |         """.trimmingCharacters(in: .whitespacesAndNewlines)
109 | 
110 |         return prompt
111 |     }
112 | 
113 |     func createStreamStopStrategy(model: Service.Model) -> some StreamStopStrategy {
114 |         strategy.createStreamStopStrategy(model: model)
115 |     }
116 | 
117 |     func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy {
118 |         strategy.createRawSuggestionPostProcessor()
119 |     }
120 | }
121 | 
122 | struct FIMStreamStopStrategy: StreamStopStrategy {
123 |     let prefix: [String]
124 | 
125 |     func shouldStop(
126 |         existedLines: [String],
127 |         currentLine: String,
128 |         proposedLineLimit: Int
129 |     ) -> StreamStopStrategyResult {
130 |         if let prefixLastLine = prefix.last {
131 |             if let lastLineIndex = existedLines.lastIndex(of: prefixLastLine) {
132 |                 if existedLines.count >= lastLineIndex + 1 + proposedLineLimit {
133 |                     return .stop(appendingNewContent: true)
134 |                 }
135 |                 return .continue
136 |             } else {
137 |                 if existedLines.count >= proposedLineLimit {
138 |                     return .stop(appendingNewContent: true)
139 |                 }
140 |                 return .continue
141 |             }
142 |         } else {
143 |             if existedLines.count >= proposedLineLimit {
144 |                 return .stop(appendingNewContent: true)
145 |             }
146 |             return .continue
147 |         }
148 |     }
149 | }
150 | 
151 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RequestStrategies/NaiveRequestStrategy.swift:
--------------------------------------------------------------------------------
 1 | import CodeCompletionService
 2 | import CopilotForXcodeKit
 3 | import Foundation
 4 | import Fundamental
 5 | 
 6 | /// This strategy mixed and rearrange everything naively to make the model think it's writing code
 7 | /// at the end of a file.
 8 | struct NaiveRequestStrategy: RequestStrategy {
 9 |     var sourceRequest: SuggestionRequest
10 |     var prefix: [String]
11 |     var suffix: [String]
12 | 
13 |     var shouldSkip: Bool {
14 |         prefix.last?.trimmingCharacters(in: .whitespaces) == "}"
15 |     }
16 | 
17 |     func createPrompt() -> Request {
18 |         Request(
19 |             sourceRequest: sourceRequest,
20 |             prefix: prefix,
21 |             suffix: suffix
22 |         )
23 |     }
24 | 
25 |     func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy {
26 |         NoOpRawSuggestionPostProcessingStrategy()
27 |     }
28 | 
29 |     func createStreamStopStrategy(model: Service.Model) -> some StreamStopStrategy {
30 |         DefaultStreamStopStrategy()
31 |     }
32 | 
33 |     struct Request: PromptStrategy {
34 |         let systemPrompt: String = ""
35 |         var sourceRequest: SuggestionRequest
36 |         var prefix: [String]
37 |         var suffix: [String]
38 |         var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path }
39 |         var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets }
40 |         var stopWords: [String] { ["\n\n"] }
41 |         var language: CodeLanguage? { sourceRequest.language }
42 | 
43 |         var suggestionPrefix: SuggestionPrefix {
44 |             guard let prefix = prefix.last else { return .empty }
45 |             return .unchanged(prefix).curlyBracesLineBreak()
46 |         }
47 | 
48 |         func createPrompt(
49 |             truncatedPrefix: [String],
50 |             truncatedSuffix: [String],
51 |             includedSnippets: [RelevantCodeSnippet]
52 |         ) -> [PromptMessage] {
53 |             let promptLinesCount = min(10, max(truncatedPrefix.count, 2))
54 |             let prefixLines = truncatedPrefix.prefix(truncatedPrefix.count - promptLinesCount)
55 |             let promptLines: [String] = {
56 |                 let proposed = truncatedPrefix.suffix(promptLinesCount)
57 |                 return Array(proposed.dropLast()) + [suggestionPrefix.infillValue]
58 |             }()
59 | 
60 |             /// Mix and rearrange the file and relevant code snippets.
61 |             let code = {
62 |                 var codes = [String]()
63 |                 if !includedSnippets.isEmpty {
64 |                     codes.append(includedSnippets.map(\.content).joined(separator: "\n\n"))
65 |                 }
66 |                 if !truncatedSuffix.isEmpty {
67 |                     codes.append("""
68 |                     // From the end of the file
69 |                     \(truncatedSuffix.joined())
70 |                     // End
71 |                     """)
72 |                 }
73 |                 codes.append("\(prefixLines.joined())\(promptLines.joined())")
74 |                 return codes.joined(separator: "\n\n")
75 |             }()
76 | 
77 |             return [.init(role: .user, content: """
78 |             File path: \(filePath)
79 | 
80 |             ---
81 | 
82 |             \(code)
83 |             """.trimmingCharacters(in: .whitespacesAndNewlines))]
84 |         }
85 |     }
86 | }
87 | 
88 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RequestStrategies/TabbyRequestStrategy.swift:
--------------------------------------------------------------------------------
 1 | import CodeCompletionService
 2 | import CopilotForXcodeKit
 3 | import Foundation
 4 | import Fundamental
 5 | 
 6 | /// A special strategy for Tabby.
 7 | struct TabbyRequestStrategy: RequestStrategy {
 8 |     var sourceRequest: SuggestionRequest
 9 |     var prefix: [String]
10 |     var suffix: [String]
11 | 
12 |     var shouldSkip: Bool {
13 |         prefix.last?.trimmingCharacters(in: .whitespaces) == "}"
14 |     }
15 | 
16 |     func createPrompt() -> Prompt {
17 |         Prompt(
18 |             sourceRequest: sourceRequest,
19 |             prefix: prefix,
20 |             suffix: suffix
21 |         )
22 |     }
23 | 
24 |     func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy {
25 |         NoOpRawSuggestionPostProcessingStrategy()
26 |     }
27 | 
28 |     func createStreamStopStrategy(model: Service.Model) -> some StreamStopStrategy {
29 |         NeverStreamStopStrategy()
30 |     }
31 | 
32 |     struct Prompt: PromptStrategy {
33 |         let systemPrompt: String = ""
34 |         var sourceRequest: SuggestionRequest
35 |         var prefix: [String]
36 |         var suffix: [String]
37 |         var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path }
38 |         var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets }
39 |         var stopWords: [String] { [] }
40 |         var language: CodeLanguage? { sourceRequest.language }
41 | 
42 |         var suggestionPrefix: SuggestionPrefix {
43 |             guard let prefix = prefix.last else { return .empty }
44 |             return .unchanged(prefix)
45 |         }
46 | 
47 |         init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) {
48 |             self.sourceRequest = sourceRequest
49 | 
50 |             let prefix = sourceRequest.relevantCodeSnippets.map { $0.content + "\n\n" }
51 |                 + prefix
52 | 
53 |             self.prefix = prefix
54 |             self.suffix = suffix
55 |         }
56 | 
57 |         /// Not used by ``TabbyService``.
58 |         func createPrompt(
59 |             truncatedPrefix: [String],
60 |             truncatedSuffix: [String],
61 |             includedSnippets: [RelevantCodeSnippet]
62 |         ) -> [PromptMessage] {
63 |             []
64 |         }
65 |     }
66 | }
67 | 
68 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/RequestStrategy.swift:
--------------------------------------------------------------------------------
 1 | import CodeCompletionService
 2 | import CopilotForXcodeKit
 3 | import Foundation
 4 | import Fundamental
 5 | import Parsing
 6 | 
 7 | /// Prompts may behave differently in different LLMs.
 8 | /// This protocol allows for different strategies to be used to generate prompts.
 9 | protocol RequestStrategy {
10 |     associatedtype Prompt: PromptStrategy
11 |     associatedtype RawSuggestionPostProcessor: RawSuggestionPostProcessingStrategy
12 |     associatedtype SomeStreamStopStrategy: StreamStopStrategy
13 | 
14 |     init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String])
15 | 
16 |     /// If the request should be skipped.
17 |     var shouldSkip: Bool { get }
18 | 
19 |     /// Create a prompt to generate code completion.
20 |     func createPrompt() -> Prompt
21 | 
22 |     /// Control how a stream should stop early.
23 |     func createStreamStopStrategy(model: Service.Model) -> SomeStreamStopStrategy
24 | 
25 |     /// The AI model may not return a suggestion in a ideal format. You can use it to reformat the
26 |     /// suggestions.
27 |     func createRawSuggestionPostProcessor() -> RawSuggestionPostProcessor
28 | }
29 | 
30 | public enum RequestStrategyOption: String, CaseIterable, Codable {
31 |     case `default` = ""
32 |     case naive
33 |     case `continue`
34 |     case codeLlamaFillInTheMiddle
35 |     case codeLlamaFillInTheMiddleWithSystemPrompt
36 |     case anthropic
37 | }
38 | 
39 | extension RequestStrategyOption {
40 |     var strategy: any RequestStrategy.Type {
41 |         switch self {
42 |         case .default:
43 |             return DefaultRequestStrategy.self
44 |         case .naive:
45 |             return NaiveRequestStrategy.self
46 |         case .continue:
47 |             return ContinueRequestStrategy.self
48 |         case .codeLlamaFillInTheMiddle:
49 |             return FillInTheMiddleRequestStrategy.self
50 |         case .codeLlamaFillInTheMiddleWithSystemPrompt:
51 |             return FillInTheMiddleWithSystemPromptRequestStrategy.self
52 |         case .anthropic:
53 |             return AnthropicRequestStrategy.self
54 |         }
55 |     }
56 | }
57 | 
58 | // MARK: - Default Implementations
59 | 
60 | extension RequestStrategy {
61 |     var shouldSkip: Bool { false }
62 | }
63 | 
64 | // MARK: - Suggestion Prefix Helpers
65 | 
66 | extension SuggestionPrefix {
67 |     func curlyBracesLineBreak() -> SuggestionPrefix {
68 |         func mutate(_ string: String) -> String {
69 |             let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
70 |             if trimmed.hasSuffix("{") {
71 |                 return string + " "
72 |             }
73 |             if trimmed.hasSuffix("}") {
74 |                 return string + "\n"
75 |             }
76 |             return string
77 |         }
78 | 
79 |         let infillValue = mutate(infillValue)
80 |         let prependingValue = mutate(prependingValue)
81 |         return .init(original: original, infillValue: infillValue, prependingValue: prependingValue)
82 |     }
83 | }
84 | 
85 | 


--------------------------------------------------------------------------------
/Core/Sources/SuggestionService/SuggestionService.swift:
--------------------------------------------------------------------------------
 1 | import CopilotForXcodeKit
 2 | import Foundation
 3 | import Fundamental
 4 | 
 5 | public class SuggestionService: SuggestionServiceType {
 6 |     let service = Service()
 7 | 
 8 |     public init() {}
 9 | 
10 |     public var configuration: SuggestionServiceConfiguration {
11 |         .init(
12 |             acceptsRelevantCodeSnippets: true,
13 |             mixRelevantCodeSnippetsInSource: false,
14 |             acceptsRelevantSnippetsFromOpenedFiles: true
15 |         )
16 |     }
17 | 
18 |     public func notifyAccepted(_ suggestion: CodeSuggestion, workspace: WorkspaceInfo) async {}
19 | 
20 |     public func notifyRejected(_ suggestions: [CodeSuggestion], workspace: WorkspaceInfo) async {}
21 | 
22 |     public func cancelRequest(workspace: WorkspaceInfo) async {
23 |         await service.cancelRequest()
24 |     }
25 | 
26 |     public func getSuggestions(
27 |         _ request: SuggestionRequest,
28 |         workspace: WorkspaceInfo
29 |     ) async throws -> [CodeSuggestion] {
30 |         try await service.getSuggestions(request, workspace: workspace)
31 |     }
32 | }
33 | 
34 | 


--------------------------------------------------------------------------------
/Core/Tests/CodeCompletionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift:
--------------------------------------------------------------------------------
  1 | import Foundation
  2 | import XCTest
  3 | 
  4 | @testable import CodeCompletionService
  5 | 
  6 | class OpeningTagBasedStreamStopStrategyTests: XCTestCase {
  7 |     func test_no_opening_tag_found_and_not_hitting_limit() {
  8 |         let strategy = OpeningTagBasedStreamStopStrategy(
  9 |             openingTag: "",
 10 |             toleranceIfNoOpeningTagFound: 3
 11 |         )
 12 |         let limiter = StreamLineLimiter(lineLimit: 1, strategy: strategy)
 13 |         let content = """
 14 |         Hello World
 15 |         My Friend
 16 |         """
 17 |         for character in content {
 18 |             let result = limiter.push(String(character))
 19 |             XCTAssertEqual(result, .continue)
 20 |         }
 21 |         XCTAssertEqual(limiter.result, content)
 22 |     }
 23 |     
 24 |     func test_no_opening_tag_found_hitting_limit() {
 25 |         let strategy = OpeningTagBasedStreamStopStrategy(
 26 |             openingTag: "",
 27 |             toleranceIfNoOpeningTagFound: 3
 28 |         )
 29 |         let limiter = StreamLineLimiter(lineLimit: 1, strategy: strategy)
 30 |         let content = """
 31 |         Hello World
 32 |         My Friend
 33 |         How Are You
 34 |         I Am Fine
 35 |         Thank You
 36 |         """
 37 |         
 38 |         let expected = """
 39 |         Hello World
 40 |         My Friend
 41 |         How Are You
 42 |         I Am Fine
 43 |         
 44 |         """
 45 |         
 46 |         for character in content {
 47 |             let result = limiter.push(String(character))
 48 |             if result == .finish(expected) {
 49 |                 XCTAssertEqual(limiter.result, expected)
 50 |                 return
 51 |             }
 52 |         }
 53 |         XCTFail("Should return in the loop\n\n\(limiter.result)")
 54 |     }
 55 |     
 56 |     func test_opening_tag_found_not_hitting_limit() {
 57 |         let strategy = OpeningTagBasedStreamStopStrategy(
 58 |             openingTag: "",
 59 |             toleranceIfNoOpeningTagFound: 3
 60 |         )
 61 |         let limiter = StreamLineLimiter(lineLimit: 2, strategy: strategy)
 62 |         let content = """
 63 |         Hello World
 64 |         
 65 |         How Are You
 66 |         """
 67 |         for character in content {
 68 |             let result = limiter.push(String(character))
 69 |             XCTAssertEqual(result, .continue)
 70 |         }
 71 |         XCTAssertEqual(limiter.result, content)
 72 |     }
 73 |     
 74 |     func test_opening_tag_found_hitting_limit() {
 75 |         let strategy = OpeningTagBasedStreamStopStrategy(
 76 |             openingTag: "",
 77 |             toleranceIfNoOpeningTagFound: 3
 78 |         )
 79 |         let limiter = StreamLineLimiter(lineLimit: 2, strategy: strategy)
 80 |         let content = """
 81 |         Hello World
 82 |         
 83 |         How Are You
 84 |         I Am Fine
 85 |         Thank You
 86 |         """
 87 |         
 88 |         let expected = """
 89 |         Hello World
 90 |         
 91 |         How Are You
 92 |         I Am Fine
 93 |         
 94 |         """
 95 |         
 96 |         for character in content {
 97 |             let result = limiter.push(String(character))
 98 |             if result == .finish(expected) {
 99 |                 XCTAssertEqual(limiter.result, expected)
100 |                 return
101 |             }
102 |         }
103 |         XCTFail("Should return in the loop\n\n\(limiter.result)")
104 |     }
105 | }
106 | 
107 | 


--------------------------------------------------------------------------------
/Core/Tests/CodeCompletionServiceTests/StreamLineLimiterTests.swift:
--------------------------------------------------------------------------------
 1 | import Foundation
 2 | import XCTest
 3 | 
 4 | @testable import CodeCompletionService
 5 | 
 6 | class StreamLineLimiterTests: XCTestCase {
 7 |     func test_pushing_characters_without_hitting_limit() {
 8 |         let limiter = StreamLineLimiter(lineLimit: 2, strategy: DefaultStreamStopStrategy())
 9 |         let content = "hello world\n"
10 |         for character in content {
11 |             let result = limiter.push(String(character))
12 |             XCTAssertEqual(result, .continue)
13 |         }
14 |         XCTAssertEqual(limiter.result, content)
15 |     }
16 | 
17 |     func test_pushing_characters_hitting_limit() {
18 |         let limiter = StreamLineLimiter(lineLimit: 2, strategy: DefaultStreamStopStrategy())
19 |         let content = "hello world\nhello world\nhello world"
20 |         for character in content {
21 |             let result = limiter.push(String(character))
22 |             if result == .finish("hello world\nhello world\n") {
23 |                 XCTAssertEqual(limiter.result, "hello world\nhello world\n")
24 |                 return
25 |             }
26 |         }
27 |         XCTFail("Should return in the loop\n\(limiter.result)")
28 |     }
29 | 
30 |     func test_pushing_characters_with_early_exit_strategy() {
31 |         struct Strategy: StreamStopStrategy {
32 |             func shouldStop(
33 |                 existedLines: [String],
34 |                 currentLine: String,
35 |                 proposedLineLimit: Int
36 |             ) -> StreamStopStrategyResult {
37 |                 let hasPrefixP = currentLine.hasPrefix("p")
38 |                 let hasNewLine = existedLines.first?.hasSuffix("\n") ?? false
39 |                 if hasPrefixP && hasNewLine {
40 |                     return .stop(appendingNewContent: false)
41 |                 }
42 |                 return .continue
43 |             }
44 |         }
45 | 
46 |         let limiter = StreamLineLimiter(lineLimit: 10, strategy: Strategy())
47 |         let content = "hello world\npikachu\n"
48 |         for character in content {
49 |             let result = limiter.push(String(character))
50 |             if result == .finish("hello world\n") {
51 |                 XCTAssertEqual(limiter.result, "hello world\n")
52 |                 return
53 |             }
54 |         }
55 |         XCTFail("Should return in the loop\n\(limiter.result)")
56 |     }
57 | 
58 |     func test_receiving_multiple_line_ending_as_a_single_token() {
59 |         let limiter = StreamLineLimiter(lineLimit: 4, strategy: DefaultStreamStopStrategy())
60 |         let content = "hello world"
61 |         for character in content {
62 |             let result = limiter.push(String(character))
63 |             XCTAssertEqual(result, .continue)
64 |         }
65 |         XCTAssertEqual(limiter.push("\n\n\n"), .continue)
66 |         XCTAssertEqual(limiter.push("\n"), .finish("hello world\n\n\n\n"))
67 |     }
68 | }
69 | 
70 | 


--------------------------------------------------------------------------------
/Core/Tests/FundamentalTests/ConvertRangeTests.swift:
--------------------------------------------------------------------------------
 1 | import CopilotForXcodeKit
 2 | import XCTest
 3 | 
 4 | @testable import Fundamental
 5 | 
 6 | class ConvertRangeTests: XCTestCase {
 7 |     func test_convert_range_0_0() {
 8 |         XCTAssertEqual(
 9 |             convertRangeToCursorRange(0...0, in: "\n".breakLines()),
10 |             CursorRange(start: .zero, end: .init(line: 0, character: 0))
11 |         )
12 |     }
13 | 
14 |     func test_convert_range_same_line() {
15 |         XCTAssertEqual(
16 |             convertRangeToCursorRange(1...5, in: "123456789\n".breakLines()),
17 |             CursorRange(start: .init(line: 0, character: 1), end: .init(line: 0, character: 5))
18 |         )
19 |     }
20 | 
21 |     func test_convert_range_multiple_line() {
22 |         XCTAssertEqual(
23 |             convertRangeToCursorRange(5...25, in: "123456789\n123456789\n123456789\n".breakLines()),
24 |             CursorRange(start: .init(line: 0, character: 5), end: .init(line: 2, character: 5))
25 |         )
26 |     }
27 | 
28 |     func test_convert_range_all_line() {
29 |         XCTAssertEqual(
30 |             convertRangeToCursorRange(0...29, in: "123456789\n123456789\n123456789\n".breakLines()),
31 |             CursorRange(start: .init(line: 0, character: 0), end: .init(line: 2, character: 9))
32 |         )
33 |     }
34 | 
35 |     func test_convert_range_out_of_range() {
36 |         XCTAssertEqual(
37 |             convertRangeToCursorRange(0...70, in: "123456789\n123456789\n123456789\n".breakLines()),
38 |             CursorRange(start: .init(line: 0, character: 0), end: .init(line: 3, character: 0))
39 |         )
40 |     }
41 | }
42 | 
43 | 


--------------------------------------------------------------------------------
/Core/Tests/SuggestionServiceTests/CodeSplitAtCursorTests.swift:
--------------------------------------------------------------------------------
  1 | import Foundation
  2 | import Fundamental
  3 | import XCTest
  4 | 
  5 | @testable import SuggestionService
  6 | 
  7 | class CodeSplitAtCursorTests: XCTestCase {
  8 |     func test_split_at_the_end_of_a_file() {
  9 |         let code = """
 10 |         func mergeSort(_ array: [T]) -> [T] {
 11 |             guard array.count > 1 else { return array }
 12 |             let middle = array.count / 2
 13 |             let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
 31 |             guard array.count > 1 else { return array }
 32 |             let middle = array.count / 2
 33 |             let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
 51 |             guard array.count > 1 else { return array }
 52 |             let middle = array.count / 2
 53 |             let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
 83 |             guard array.count > 1 else { return array }
 84 |             let middle = array.count / 2
 85 |             let left = mergeSort(Array(array[..
 45 |         """)
 46 |         XCTAssertEqual(infillBlock, """
 47 |         print("7")
 48 |         print("8")
 49 |         print("9")
 50 |         print("10")
 51 |         print("11")
 52 |         print("12")
 53 |         print("13")
 54 |         print("14")
 55 |         print("15")
 56 |         let cat:
 57 |         """, "At most 10 lines")
 58 |     }
 59 | 
 60 |     func test_source_prompt_creation_has_suffix() {
 61 |         let prefix = """
 62 |         print("1")
 63 |         print("2")
 64 |         print("3")
 65 |         print("4")
 66 |         print("5")
 67 |         print("6")
 68 |         print("7")
 69 |         print("8")
 70 |         print("9")
 71 |         print("10")
 72 |         print("11")
 73 |         print("12")
 74 |         print("13")
 75 |         print("14")
 76 |         print("15")
 77 |         let cat:
 78 |         """
 79 |         
 80 |         let suffix = """
 81 |         
 82 |         print("1")
 83 |         print("2")
 84 |         print("3")
 85 |         print("4")
 86 |         print("5")
 87 |         """
 88 | 
 89 |         guard let (summary, infillBlock) = DefaultRequestStrategy.Prompt.createCodeSummary(
 90 |             truncatedPrefix: prefix.breakLines(),
 91 |             truncatedSuffix: suffix.breakLines(),
 92 |             suggestionPrefix: "let cat:"
 93 |         ) else {
 94 |             XCTFail()
 95 |             return
 96 |         }
 97 | 
 98 |         XCTAssertEqual(summary, """
 99 |         print("1")
100 |         print("2")
101 |         print("3")
102 |         print("4")
103 |         print("5")
104 |         print("6")
105 |         
106 |         print("1")
107 |         print("2")
108 |         print("3")
109 |         print("4")
110 |         print("5")
111 |         """)
112 |         XCTAssertEqual(infillBlock, """
113 |         print("7")
114 |         print("8")
115 |         print("9")
116 |         print("10")
117 |         print("11")
118 |         print("12")
119 |         print("13")
120 |         print("14")
121 |         print("15")
122 |         let cat:
123 |         """, "At most 10 lines")
124 |     }
125 | }
126 | 
127 | 


--------------------------------------------------------------------------------
/CustomSuggestionService.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 | 
2 | 
4 |    
6 |    
7 | 
8 | 


--------------------------------------------------------------------------------
/CustomSuggestionService.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | 
4 | 
5 | 	IDEDidComputeMac32BitWarning
6 | 	
7 | 
8 | 
9 | 


--------------------------------------------------------------------------------
/CustomSuggestionService.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
  1 | {
  2 |   "pins" : [
  3 |     {
  4 |       "identity" : "codablewrappers",
  5 |       "kind" : "remoteSourceControl",
  6 |       "location" : "https://github.com/GottaGetSwifty/CodableWrappers.git",
  7 |       "state" : {
  8 |         "revision" : "4eb46a4c656333e8514db8aad204445741de7d40",
  9 |         "version" : "2.0.7"
 10 |       }
 11 |     },
 12 |     {
 13 |       "identity" : "combine-schedulers",
 14 |       "kind" : "remoteSourceControl",
 15 |       "location" : "https://github.com/pointfreeco/combine-schedulers",
 16 |       "state" : {
 17 |         "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb",
 18 |         "version" : "1.0.0"
 19 |       }
 20 |     },
 21 |     {
 22 |       "identity" : "copilotforxcodekit",
 23 |       "kind" : "remoteSourceControl",
 24 |       "location" : "https://github.com/intitni/CopilotForXcodeKit.git",
 25 |       "state" : {
 26 |         "revision" : "8065dfaf839a9dd0f20b65b76d009ead2927b18d",
 27 |         "version" : "0.7.2"
 28 |       }
 29 |     },
 30 |     {
 31 |       "identity" : "generative-ai-swift",
 32 |       "kind" : "remoteSourceControl",
 33 |       "location" : "https://github.com/google/generative-ai-swift",
 34 |       "state" : {
 35 |         "revision" : "e2cebcd90645a3a94c0c823696e510a176bc384a",
 36 |         "version" : "0.4.8"
 37 |       }
 38 |     },
 39 |     {
 40 |       "identity" : "sparkle",
 41 |       "kind" : "remoteSourceControl",
 42 |       "location" : "https://github.com/sparkle-project/Sparkle",
 43 |       "state" : {
 44 |         "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99",
 45 |         "version" : "2.7.0"
 46 |       }
 47 |     },
 48 |     {
 49 |       "identity" : "sttextkitplus",
 50 |       "kind" : "remoteSourceControl",
 51 |       "location" : "https://github.com/krzyzanowskim/STTextKitPlus",
 52 |       "state" : {
 53 |         "revision" : "5500fa8811ed339605b0861ae0390677863a7bfe",
 54 |         "version" : "0.0.2"
 55 |       }
 56 |     },
 57 |     {
 58 |       "identity" : "sttextview",
 59 |       "kind" : "remoteSourceControl",
 60 |       "location" : "https://github.com/krzyzanowskim/STTextView",
 61 |       "state" : {
 62 |         "revision" : "e9e54718b882115db69ec1e17ac1bec844906cd9",
 63 |         "version" : "0.9.0"
 64 |       }
 65 |     },
 66 |     {
 67 |       "identity" : "swift-case-paths",
 68 |       "kind" : "remoteSourceControl",
 69 |       "location" : "https://github.com/pointfreeco/swift-case-paths",
 70 |       "state" : {
 71 |         "revision" : "b871e5ed11a23e52c2896a92ce2c829982ff8619",
 72 |         "version" : "1.4.2"
 73 |       }
 74 |     },
 75 |     {
 76 |       "identity" : "swift-clocks",
 77 |       "kind" : "remoteSourceControl",
 78 |       "location" : "https://github.com/pointfreeco/swift-clocks",
 79 |       "state" : {
 80 |         "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33",
 81 |         "version" : "1.0.2"
 82 |       }
 83 |     },
 84 |     {
 85 |       "identity" : "swift-collections",
 86 |       "kind" : "remoteSourceControl",
 87 |       "location" : "https://github.com/apple/swift-collections",
 88 |       "state" : {
 89 |         "revision" : "ee97538f5b81ae89698fd95938896dec5217b148",
 90 |         "version" : "1.1.1"
 91 |       }
 92 |     },
 93 |     {
 94 |       "identity" : "swift-composable-architecture",
 95 |       "kind" : "remoteSourceControl",
 96 |       "location" : "https://github.com/pointfreeco/swift-composable-architecture",
 97 |       "state" : {
 98 |         "revision" : "1f952d8c69ace5e53bb69a218e6ed00e03a4695c",
 99 |         "version" : "1.11.2"
100 |       }
101 |     },
102 |     {
103 |       "identity" : "swift-concurrency-extras",
104 |       "kind" : "remoteSourceControl",
105 |       "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
106 |       "state" : {
107 |         "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71",
108 |         "version" : "1.1.0"
109 |       }
110 |     },
111 |     {
112 |       "identity" : "swift-custom-dump",
113 |       "kind" : "remoteSourceControl",
114 |       "location" : "https://github.com/pointfreeco/swift-custom-dump",
115 |       "state" : {
116 |         "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c",
117 |         "version" : "1.3.0"
118 |       }
119 |     },
120 |     {
121 |       "identity" : "swift-dependencies",
122 |       "kind" : "remoteSourceControl",
123 |       "location" : "https://github.com/pointfreeco/swift-dependencies",
124 |       "state" : {
125 |         "revision" : "adb04a8e35f07edc001877af9f9f97fcc21d409e",
126 |         "version" : "1.2.0"
127 |       }
128 |     },
129 |     {
130 |       "identity" : "swift-identified-collections",
131 |       "kind" : "remoteSourceControl",
132 |       "location" : "https://github.com/pointfreeco/swift-identified-collections",
133 |       "state" : {
134 |         "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b",
135 |         "version" : "1.1.0"
136 |       }
137 |     },
138 |     {
139 |       "identity" : "swift-parsing",
140 |       "kind" : "remoteSourceControl",
141 |       "location" : "https://github.com/pointfreeco/swift-parsing",
142 |       "state" : {
143 |         "revision" : "a0e7d73f462c1c38c59dc40a3969ac40cea42950",
144 |         "version" : "0.13.0"
145 |       }
146 |     },
147 |     {
148 |       "identity" : "swift-perception",
149 |       "kind" : "remoteSourceControl",
150 |       "location" : "https://github.com/pointfreeco/swift-perception",
151 |       "state" : {
152 |         "revision" : "d3ab98dc2887d1cc3bed676f6fa354da4cb22b3c",
153 |         "version" : "1.2.4"
154 |       }
155 |     },
156 |     {
157 |       "identity" : "swift-syntax",
158 |       "kind" : "remoteSourceControl",
159 |       "location" : "https://github.com/apple/swift-syntax",
160 |       "state" : {
161 |         "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d",
162 |         "version" : "509.1.1"
163 |       }
164 |     },
165 |     {
166 |       "identity" : "swiftui-navigation",
167 |       "kind" : "remoteSourceControl",
168 |       "location" : "https://github.com/pointfreeco/swiftui-navigation",
169 |       "state" : {
170 |         "revision" : "d9e72f3083c08375794afa216fb2f89c0114f303",
171 |         "version" : "1.2.1"
172 |       }
173 |     },
174 |     {
175 |       "identity" : "xctest-dynamic-overlay",
176 |       "kind" : "remoteSourceControl",
177 |       "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
178 |       "state" : {
179 |         "revision" : "b58e6627149808b40634c4552fcf2f44d0b3ca87",
180 |         "version" : "1.1.0"
181 |       }
182 |     }
183 |   ],
184 |   "version" : 2
185 | }
186 | 


--------------------------------------------------------------------------------
/CustomSuggestionService.xcodeproj/project.xcworkspace/xcuserdata/intitni.xcuserdatad/IDEFindNavigatorScopes.plist:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | 
4 | 
5 | 
6 | 


--------------------------------------------------------------------------------
/CustomSuggestionService.xcodeproj/xcshareddata/xcschemes/CopilotForXcodeExtension.xcscheme:
--------------------------------------------------------------------------------
  1 | 
  2 | 
  6 |    
  9 |       
 10 |          
 16 |             
 22 |             
 23 |          
 24 |          
 30 |             
 36 |             
 37 |          
 38 |       
 39 |    
 40 |    
 45 |       
 46 |          
 49 |          
 50 |       
 51 |    
 52 |    
 64 |       
 66 |          
 72 |          
 73 |       
 74 |    
 75 |    
 83 |       
 85 |          
 91 |          
 92 |       
 93 |    
 94 |    
 96 |    
 97 |    
100 |    
101 | 
102 | 


--------------------------------------------------------------------------------
/CustomSuggestionService.xcodeproj/xcshareddata/xcschemes/CustomSuggestionService.xcscheme:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 5 |    
 8 |       
 9 |          
15 |             
21 |             
22 |          
23 |       
24 |    
25 |    
30 |       
31 |          
34 |          
35 |       
36 |    
37 |    
47 |       
49 |          
55 |          
56 |       
57 |    
58 |    
64 |       
66 |          
72 |          
73 |       
74 |    
75 |    
77 |    
78 |    
81 |    
82 | 
83 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/App.swift:
--------------------------------------------------------------------------------
 1 | import ComposableArchitecture
 2 | import Foundation
 3 | 
 4 | @Reducer
 5 | struct TheApp {
 6 |     @ObservableState
 7 |     struct State: Equatable {
 8 |         var customChatModel: ChatModelEdit.State = UserDefaults.shared.value(for: \.customChatModel)
 9 |             .toState()
10 |         var customCompletionModel: CompletionModelEdit.State = UserDefaults.shared
11 |             .value(for: \.customCompletionModel).toState()
12 |         var tabbyModel: TabbyModelEdit.State = UserDefaults.shared.value(for: \.tabbyModel)
13 |             .toState()
14 |         var fimModel: FIMModelEdit.State = UserDefaults.shared.value(for: \.customFIMModel)
15 |             .toState()
16 |         var testField: TestField.State = .init()
17 |     }
18 | 
19 |     enum Action: Equatable {
20 |         case customChatModel(ChatModelEdit.Action)
21 |         case customCompletionModel(CompletionModelEdit.Action)
22 |         case tabbyModel(TabbyModelEdit.Action)
23 |         case fimModel(FIMModelEdit.Action)
24 |         case testField(TestField.Action)
25 |     }
26 | 
27 |     var body: some Reducer {
28 |         Scope(state: \.customChatModel, action: \.customChatModel) {
29 |             ChatModelEdit()
30 |         }
31 | 
32 |         Scope(state: \.customCompletionModel, action: \.customCompletionModel) {
33 |             CompletionModelEdit()
34 |         }
35 | 
36 |         Scope(state: \.tabbyModel, action: \.tabbyModel) {
37 |             TabbyModelEdit()
38 |         }
39 |         
40 |         Scope(state: \.fimModel, action: \.fimModel) {
41 |             FIMModelEdit()
42 |         }
43 | 
44 |         Scope(state: \.testField, action: \.testField) {
45 |             TestField()
46 |         }
47 |     }
48 | }
49 | 
50 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "idiom" : "universal"
 5 |     }
 6 |   ],
 7 |   "info" : {
 8 |     "author" : "xcode",
 9 |     "version" : 1
10 |   }
11 | }
12 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "idiom" : "mac",
 5 |       "scale" : "1x",
 6 |       "size" : "16x16"
 7 |     },
 8 |     {
 9 |       "idiom" : "mac",
10 |       "scale" : "2x",
11 |       "size" : "16x16"
12 |     },
13 |     {
14 |       "idiom" : "mac",
15 |       "scale" : "1x",
16 |       "size" : "32x32"
17 |     },
18 |     {
19 |       "idiom" : "mac",
20 |       "scale" : "2x",
21 |       "size" : "32x32"
22 |     },
23 |     {
24 |       "idiom" : "mac",
25 |       "scale" : "1x",
26 |       "size" : "128x128"
27 |     },
28 |     {
29 |       "idiom" : "mac",
30 |       "scale" : "2x",
31 |       "size" : "128x128"
32 |     },
33 |     {
34 |       "idiom" : "mac",
35 |       "scale" : "1x",
36 |       "size" : "256x256"
37 |     },
38 |     {
39 |       "idiom" : "mac",
40 |       "scale" : "2x",
41 |       "size" : "256x256"
42 |     },
43 |     {
44 |       "idiom" : "mac",
45 |       "scale" : "1x",
46 |       "size" : "512x512"
47 |     },
48 |     {
49 |       "idiom" : "mac",
50 |       "scale" : "2x",
51 |       "size" : "512x512"
52 |     }
53 |   ],
54 |   "info" : {
55 |     "author" : "xcode",
56 |     "version" : 1
57 |   }
58 | }
59 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 |   "info" : {
3 |     "author" : "xcode",
4 |     "version" : 1
5 |   }
6 | }
7 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/APIKeyManagement/APIKeyManagementView.swift:
--------------------------------------------------------------------------------
  1 | import ComposableArchitecture
  2 | import SwiftUI
  3 | 
  4 | struct APIKeyManagementView: View {
  5 |     @Perception.Bindable var store: StoreOf
  6 | 
  7 |     var body: some View {
  8 |         WithPerceptionTracking {
  9 |             VStack(spacing: 0) {
 10 |                 HStack {
 11 |                     Button(action: {
 12 |                         store.send(.closeButtonClicked)
 13 |                     }) {
 14 |                         Image(systemName: "xmark.circle.fill")
 15 |                             .foregroundStyle(.secondary)
 16 |                             .padding()
 17 |                     }
 18 |                     .buttonStyle(.plain)
 19 |                     Text("API Keys")
 20 |                     Spacer()
 21 |                     Button(action: {
 22 |                         store.send(.addButtonClicked)
 23 |                     }) {
 24 |                         Image(systemName: "plus.circle.fill")
 25 |                             .foregroundStyle(.secondary)
 26 |                             .padding()
 27 |                     }
 28 |                     .buttonStyle(.plain)
 29 |                 }
 30 |                 .background(Color(nsColor: .separatorColor))
 31 |                 
 32 |                 List {
 33 |                     ForEach(store.availableAPIKeyNames, id: \.self) { name in
 34 |                         HStack {
 35 |                             Text(name)
 36 |                                 .contextMenu {
 37 |                                     Button("Remove") {
 38 |                                         store.send(.deleteButtonClicked(name: name))
 39 |                                     }
 40 |                                 }
 41 |                             Spacer()
 42 |                             
 43 |                             Button(action: {
 44 |                                 store.send(.deleteButtonClicked(name: name))
 45 |                             }) {
 46 |                                 Image(systemName: "trash.fill")
 47 |                                     .foregroundStyle(.secondary)
 48 |                             }
 49 |                             .buttonStyle(.plain)
 50 |                         }
 51 |                     }
 52 |                     .modify { view in
 53 |                         if #available(macOS 13.0, *) {
 54 |                             view.listRowSeparator(.hidden).listSectionSeparator(.hidden)
 55 |                         } else {
 56 |                             view
 57 |                         }
 58 |                     }
 59 |                 }
 60 |                 .removeBackground()
 61 |                 .overlay {
 62 |                     if store.availableAPIKeyNames.isEmpty {
 63 |                         Text("""
 64 |                     Empty
 65 |                     Add a new key by clicking the add button
 66 |                     """)
 67 |                         .multilineTextAlignment(.center)
 68 |                         .padding()
 69 |                     }
 70 |                 }
 71 |             }
 72 |             .focusable(false)
 73 |             .frame(width: 300, height: 400)
 74 |             .background(.thickMaterial)
 75 |             .onAppear {
 76 |                 store.send(.appear)
 77 |             }
 78 |             .sheet(store: store.scope(
 79 |                 state: \.$apiKeySubmission,
 80 |                 action: \.apiKeySubmission
 81 |             )) { store in
 82 |                 APIKeySubmissionView(store: store)
 83 |                     .frame(minWidth: 400)
 84 |             }
 85 |         }
 86 |     }
 87 | }
 88 | 
 89 | struct APIKeySubmissionView: View {
 90 |     @Perception.Bindable var store: StoreOf
 91 | 
 92 |     var body: some View {
 93 |         WithPerceptionTracking {
 94 |             ScrollView {
 95 |                 VStack(spacing: 0) {
 96 |                     Form {
 97 |                         TextField("Name", text: $store.name)
 98 |                         SecureField("Key", text: $store.key)
 99 |                     }.padding()
100 |                     
101 |                     Divider()
102 |                     
103 |                     HStack {
104 |                         Spacer()
105 |                         
106 |                         Button("Cancel") { store.send(.cancelButtonClicked) }
107 |                             .keyboardShortcut(.cancelAction)
108 |                         
109 |                         Button("Save", action: { store.send(.saveButtonClicked) })
110 |                             .keyboardShortcut(.defaultAction)
111 |                     }.padding()
112 |                 }
113 |             }
114 |             .textFieldStyle(.roundedBorder)
115 |         }
116 |     }
117 | }
118 | 
119 | class APIKeyManagementView_Preview: PreviewProvider {
120 |     static var previews: some View {
121 |         APIKeyManagementView(
122 |             store:  .init(
123 |                 initialState: .init(
124 |                     availableAPIKeyNames: ["test1", "test2"]
125 |                 ),
126 |                 reducer: { APIKeyManagement() }
127 |             )
128 |         )
129 |     }
130 | }
131 | 
132 | class APIKeySubmissionView_Preview: PreviewProvider {
133 |     static var previews: some View {
134 |         APIKeySubmissionView(
135 |             store: .init(
136 |                 initialState: .init(),
137 |                 reducer: { APIKeySubmission() }
138 |             )
139 |         )
140 |     }
141 | }
142 | 
143 | extension List {
144 |     @ViewBuilder
145 |     func removeBackground() -> some View {
146 |         if #available(macOS 13.0, *) {
147 |             scrollContentBackground(.hidden)
148 |                 .listRowBackground(EmptyView())
149 |         } else {
150 |             background(Color.clear)
151 |                 .listRowBackground(EmptyView())
152 |         }
153 |     }
154 | }
155 | 
156 | public extension View {
157 |     @ViewBuilder func modify(@ViewBuilder transform: (Self) -> Content)
158 |         -> some View
159 |     {
160 |         transform(self)
161 |     }
162 | }
163 | 
164 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/APIKeyManagement/APIKeyManangement.swift:
--------------------------------------------------------------------------------
 1 | import ComposableArchitecture
 2 | import Foundation
 3 | 
 4 | @Reducer
 5 | struct APIKeyManagement {
 6 |     @ObservableState
 7 |     struct State: Equatable {
 8 |         var availableAPIKeyNames: [String] = []
 9 |         @Presents var apiKeySubmission: APIKeySubmission.State?
10 |     }
11 | 
12 |     enum Action: Equatable {
13 |         case appear
14 |         case closeButtonClicked
15 |         case addButtonClicked
16 |         case deleteButtonClicked(name: String)
17 |         case refreshAvailableAPIKeyNames
18 | 
19 |         case apiKeySubmission(PresentationAction)
20 |     }
21 | 
22 |     @Dependency(\.toast) var toast
23 |     @Dependency(\.apiKeyKeychain) var keychain
24 | 
25 |     var body: some Reducer {
26 |         Reduce { state, action in
27 |             switch action {
28 |             case .appear:
29 |                 if isPreview { return .none }
30 |                 
31 |                 return .run { send in
32 |                     await send(.refreshAvailableAPIKeyNames)
33 |                 }
34 |             case .closeButtonClicked:
35 |                 return .none
36 |                 
37 |             case .addButtonClicked:
38 |                 state.apiKeySubmission = .init()
39 |                 
40 |                 return .none
41 | 
42 |             case let .deleteButtonClicked(name):
43 |                 do {
44 |                     try keychain.remove(name)
45 |                     return .run { send in
46 |                         await send(.refreshAvailableAPIKeyNames)
47 |                     }
48 |                 } catch {
49 |                     toast(error.localizedDescription, .error)
50 |                     return .none
51 |                 }
52 | 
53 |             case .refreshAvailableAPIKeyNames:
54 |                 do {
55 |                     let pairs = try keychain.getAll()
56 |                     state.availableAPIKeyNames = Array(pairs.keys).sorted()
57 |                 } catch {
58 |                     toast(error.localizedDescription, .error)
59 |                 }
60 | 
61 |                 return .none
62 | 
63 |             case .apiKeySubmission(.presented(.saveFinished)):
64 |                 state.apiKeySubmission = nil
65 |                 return .run { send in
66 |                     await send(.refreshAvailableAPIKeyNames)
67 |                 }
68 | 
69 |             case .apiKeySubmission(.presented(.cancelButtonClicked)):
70 |                 state.apiKeySubmission = nil
71 |                 return .none
72 | 
73 |             case .apiKeySubmission:
74 |                 return .none
75 |             }
76 |         }
77 |         .ifLet(\.$apiKeySubmission, action: \.apiKeySubmission) {
78 |             APIKeySubmission()
79 |         }
80 |     }
81 | }
82 | 
83 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/APIKeyManagement/APIKeyPicker.swift:
--------------------------------------------------------------------------------
 1 | import ComposableArchitecture
 2 | import SwiftUI
 3 | 
 4 | struct APIKeyPicker: View {
 5 |     @Perception.Bindable var store: StoreOf
 6 |     var title: String = "API Key"
 7 | 
 8 |     var body: some View {
 9 |         WithPerceptionTracking {
10 |             HStack {
11 |                 Picker(
12 |                     selection: $store.apiKeyName,
13 |                     content: {
14 |                         Text("No API Key").tag("")
15 |                         if store.availableAPIKeyNames.isEmpty {
16 |                             Text("No API key found, please add a new one →")
17 |                         }
18 |                         
19 |                         if !store.availableAPIKeyNames.contains(store.apiKeyName),
20 |                            !store.apiKeyName.isEmpty {
21 |                             Text("Key not found: \(store.state.apiKeyName)")
22 |                                 .tag(store.apiKeyName)
23 |                         }
24 |                         
25 |                         ForEach(store.availableAPIKeyNames, id: \.self) { name in
26 |                             Text(name).tag(name)
27 |                         }
28 |                         
29 |                     },
30 |                     label: { Text(title) }
31 |                 )
32 |                 
33 |                 Button(action: { store.send(.manageAPIKeysButtonClicked) }) {
34 |                     Text(Image(systemName: "key"))
35 |                 }
36 |             }.sheet(isPresented: $store.isAPIKeyManagementPresented) {
37 |                 APIKeyManagementView(store: store.scope(
38 |                     state: \.apiKeyManagement,
39 |                     action: \.apiKeyManagement
40 |                 ))
41 |             }
42 |             .onAppear {
43 |                 store.send(.appear)
44 |             }
45 |         }
46 |     }
47 | }
48 | 
49 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/APIKeyManagement/APIKeySelection.swift:
--------------------------------------------------------------------------------
 1 | import ComposableArchitecture
 2 | import Foundation
 3 | import SwiftUI
 4 | 
 5 | @Reducer
 6 | struct APIKeySelection {
 7 |     @ObservableState
 8 |     struct State: Equatable {
 9 |         var apiKeyName: String = ""
10 |         var availableAPIKeyNames: [String] {
11 |             apiKeyManagement.availableAPIKeyNames
12 |         }
13 | 
14 |         var apiKeyManagement: APIKeyManagement.State = .init()
15 |         var isAPIKeyManagementPresented: Bool = false
16 |     }
17 | 
18 |     enum Action: Equatable, BindableAction {
19 |         case appear
20 |         case manageAPIKeysButtonClicked
21 | 
22 |         case binding(BindingAction)
23 |         case apiKeyManagement(APIKeyManagement.Action)
24 |     }
25 | 
26 |     @Dependency(\.toast) var toast
27 |     @Dependency(\.apiKeyKeychain) var keychain
28 | 
29 |     var body: some Reducer {
30 |         BindingReducer()
31 |         
32 |         Scope(state: \.apiKeyManagement, action: /Action.apiKeyManagement) {
33 |             APIKeyManagement()
34 |         }
35 | 
36 |         Reduce { state, action in
37 |             switch action {
38 |             case .appear:
39 |                 return .run { send in
40 |                     await send(.apiKeyManagement(.refreshAvailableAPIKeyNames))
41 |                 }
42 | 
43 |             case .manageAPIKeysButtonClicked:
44 |                 state.isAPIKeyManagementPresented = true
45 |                 return .none
46 | 
47 |             case .apiKeyManagement(.closeButtonClicked):
48 |                 state.isAPIKeyManagementPresented = false
49 |                 return .none
50 | 
51 |             case .apiKeyManagement:
52 |                 return .none
53 |                 
54 |             case .binding:
55 |                 return .none
56 |             }
57 |         }
58 |     }
59 | }
60 | 
61 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/APIKeyManagement/APIKeySubmission.swift:
--------------------------------------------------------------------------------
 1 | import ComposableArchitecture
 2 | import Foundation
 3 | 
 4 | @Reducer
 5 | struct APIKeySubmission {
 6 |     @ObservableState
 7 |     struct State: Equatable {
 8 |         var name: String = ""
 9 |         var key: String = ""
10 |     }
11 | 
12 |     enum Action: Equatable, BindableAction {
13 |         case binding(BindingAction)
14 |         case saveButtonClicked
15 |         case cancelButtonClicked
16 |         case saveFinished
17 |     }
18 | 
19 |     @Dependency(\.toast) var toast
20 |     @Dependency(\.apiKeyKeychain) var keychain
21 | 
22 |     enum E: Error, LocalizedError {
23 |         case nameIsEmpty
24 |         case keyIsEmpty
25 |     }
26 | 
27 |     var body: some Reducer {
28 |         BindingReducer()
29 | 
30 |         Reduce { state, action in
31 |             switch action {
32 |             case .saveButtonClicked:
33 |                 do {
34 |                     guard !state.name.isEmpty else { throw E.nameIsEmpty }
35 |                     guard !state.key.isEmpty else { throw E.keyIsEmpty }
36 | 
37 |                     try keychain.update(
38 |                         state.key,
39 |                         key: state.name.trimmingCharacters(in: .whitespacesAndNewlines)
40 |                     )
41 |                     return .run { send in
42 |                         await send(.saveFinished)
43 |                     }
44 |                 } catch {
45 |                     toast(error.localizedDescription, .error)
46 |                     return .none
47 |                 }
48 | 
49 |             case .cancelButtonClicked:
50 |                 return .none
51 | 
52 |             case .saveFinished:
53 |                 return .none
54 | 
55 |             case .binding:
56 |                 return .none
57 |             }
58 |         }
59 |     }
60 | }
61 | 
62 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/BaseURLPicker.swift:
--------------------------------------------------------------------------------
 1 | import ComposableArchitecture
 2 | import SwiftUI
 3 | 
 4 | struct BaseURLPicker: View {
 5 |     let title: String
 6 |     let prompt: Text?
 7 |     @Perception.Bindable var store: StoreOf
 8 |     @ViewBuilder let trailingContent: () -> TrailingContent
 9 | 
10 |     var body: some View {
11 |         WithPerceptionTracking {
12 |             HStack {
13 |                 TextField(title, text: $store.baseURL, prompt: prompt)
14 |                     .overlay(alignment: .trailing) {
15 |                         Picker(
16 |                             "",
17 |                             selection: $store.baseURL,
18 |                             content: {
19 |                                 if !store.availableBaseURLs
20 |                                     .contains(store.baseURL),
21 |                                     !store.baseURL.isEmpty
22 |                                 {
23 |                                     Text("Custom Value").tag(store.baseURL)
24 |                                 }
25 | 
26 |                                 Text("Empty (Default Value)").tag("")
27 | 
28 |                                 ForEach(store.availableBaseURLs, id: \.self) { baseURL in
29 |                                     Text(baseURL).tag(baseURL)
30 |                                 }
31 |                             }
32 |                         )
33 |                         .frame(width: 20)
34 |                     }
35 | 
36 |                 trailingContent()
37 |                     .foregroundStyle(.secondary)
38 |             }
39 |             .onAppear {
40 |                 store.send(.appear)
41 |             }
42 |         }
43 |     }
44 | }
45 | 
46 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/BaseURLSelection.swift:
--------------------------------------------------------------------------------
 1 | import ComposableArchitecture
 2 | import Foundation
 3 | import Storage
 4 | import SwiftUI
 5 | 
 6 | @Reducer
 7 | struct BaseURLSelection {
 8 |     @ObservableState
 9 |     struct State: Equatable {
10 |         var baseURL: String = ""
11 |         var isFullURL: Bool = false
12 |         var availableBaseURLs: [String] = []
13 |     }
14 | 
15 |     enum Action: Equatable, BindableAction {
16 |         case appear
17 |         case refreshAvailableBaseURLNames
18 |         case binding(BindingAction)
19 |     }
20 | 
21 |     @Dependency(\.toast) var toast
22 |     @Dependency(\.userDefaults) var userDefaults
23 | 
24 |     var body: some Reducer {
25 |         BindingReducer()
26 | 
27 |         Reduce { state, action in
28 |             switch action {
29 |             case .appear:
30 |                 return .run { send in
31 |                     await send(.refreshAvailableBaseURLNames)
32 |                 }
33 | 
34 |             case .refreshAvailableBaseURLNames:
35 |                 let chatModels = userDefaults.value(for: \.chatModelsFromCopilotForXcode)
36 |                 var allBaseURLs = Set(
37 |                     chatModels.map(\.info.baseURL)
38 |                         .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
39 |                 )
40 |                 allBaseURLs.remove("")
41 |                 state.availableBaseURLs = Array(allBaseURLs).sorted()
42 |                 return .none
43 | 
44 |             case .binding:
45 |                 return .none
46 |             }
47 |         }
48 |     }
49 | }
50 | 
51 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/ChatModelEdit.swift:
--------------------------------------------------------------------------------
  1 | import CodeCompletionService
  2 | import ComposableArchitecture
  3 | import Dependencies
  4 | import Fundamental
  5 | import Storage
  6 | import SwiftUI
  7 | 
  8 | @Reducer
  9 | struct ChatModelEdit {
 10 |     @ObservableState
 11 |     struct State: Equatable, Identifiable {
 12 |         var id: String = "Custom"
 13 |         var format: ChatModel.Format
 14 |         var maxTokens: Int = 4000
 15 |         var modelName: String = ""
 16 |         var apiKeyName: String { apiKeySelection.apiKeyName }
 17 |         var baseURL: String { baseURLSelection.baseURL }
 18 |         var availableModelNames: [String] = []
 19 |         var availableAPIKeys: [String] = []
 20 |         var suggestedMaxTokens: Int?
 21 |         var apiKeySelection: APIKeySelection.State = .init()
 22 |         var baseURLSelection: BaseURLSelection.State = .init()
 23 |         var ollamaKeepAlive: String = ""
 24 |     }
 25 | 
 26 |     enum Action: Equatable, BindableAction {
 27 |         case binding(BindingAction)
 28 |         case appear
 29 |         case saveButtonClicked
 30 |         case refreshAvailableModelNames
 31 |         case checkSuggestedMaxTokens
 32 |         case readCustomModelFromDisk
 33 |         case apiKeySelection(APIKeySelection.Action)
 34 |         case baseURLSelection(BaseURLSelection.Action)
 35 |     }
 36 | 
 37 |     @Dependency(\.toast) var toast
 38 |     @Dependency(\.apiKeyKeychain) var keychain
 39 | 
 40 |     enum DebounceID: Hashable {
 41 |         case save
 42 |     }
 43 | 
 44 |     var body: some Reducer {
 45 |         BindingReducer()
 46 | 
 47 |         Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
 48 |             APIKeySelection()
 49 |         }
 50 | 
 51 |         Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
 52 |             BaseURLSelection()
 53 |         }
 54 | 
 55 |         Reduce { state, action in
 56 |             switch action {
 57 |             case .appear:
 58 |                 return .run { send in
 59 |                     await send(.readCustomModelFromDisk)
 60 |                 }
 61 | 
 62 |             case .saveButtonClicked:
 63 |                 let model = ChatModel(state: state)
 64 |                 return .run { _ in
 65 |                     UserDefaults.shared.set(model, for: \.customChatModel)
 66 |                 }
 67 | 
 68 |             case .refreshAvailableModelNames:
 69 |                 if state.format == .openAI {
 70 |                     state.availableModelNames = OpenAIService.ChatCompletionModels.allCases
 71 |                         .map(\.rawValue)
 72 |                 }
 73 | 
 74 |                 return .none
 75 | 
 76 |             case .readCustomModelFromDisk:
 77 |                 let model = UserDefaults.shared.value(for: \.customChatModel)
 78 |                 state = model.toState()
 79 | 
 80 |                 return .run { send in
 81 |                     await send(.checkSuggestedMaxTokens)
 82 |                     await send(.refreshAvailableModelNames)
 83 |                 }
 84 | 
 85 |             case .checkSuggestedMaxTokens:
 86 |                 switch state.format {
 87 |                 case .openAI:
 88 |                     if let knownModel = OpenAIService
 89 |                         .ChatCompletionModels(rawValue: state.modelName)
 90 |                     {
 91 |                         state.suggestedMaxTokens = knownModel.maxToken
 92 |                     } else {
 93 |                         state.suggestedMaxTokens = nil
 94 |                     }
 95 |                     return .none
 96 |                 case .googleAI:
 97 |                     if let knownModel = GoogleGeminiService.KnownModels(rawValue: state.modelName) {
 98 |                         state.suggestedMaxTokens = knownModel.maxToken
 99 |                     } else {
100 |                         state.suggestedMaxTokens = nil
101 |                     }
102 |                     return .none
103 |                 case .claude:
104 |                     if let knownModel = AnthropicService.Models(rawValue: state.modelName) {
105 |                         state.suggestedMaxTokens = knownModel.maxToken
106 |                     } else {
107 |                         state.suggestedMaxTokens = nil
108 |                     }
109 |                     return .none
110 |                 default:
111 |                     state.suggestedMaxTokens = nil
112 |                     return .none
113 |                 }
114 | 
115 |             case .apiKeySelection:
116 |                 return .none
117 | 
118 |             case .baseURLSelection:
119 |                 return .none
120 | 
121 |             case .binding(\.format):
122 |                 return .run { send in
123 |                     await send(.refreshAvailableModelNames)
124 |                     await send(.checkSuggestedMaxTokens)
125 |                 }
126 | 
127 |             case .binding(\.modelName):
128 |                 return .run { send in
129 |                     await send(.checkSuggestedMaxTokens)
130 |                 }
131 | 
132 |             case .binding:
133 |                 return .none
134 |             }
135 |         }
136 |     }
137 | 
138 |     func persistState(
139 |         _: ChatModelEdit.State,
140 |         _ newValue: ChatModelEdit.State
141 |     ) -> some Reducer {
142 |         Reduce { _, _ in
143 |             .run { _ in
144 |                 let model = ChatModel(state: newValue)
145 |                 UserDefaults.shared.set(model, for: \.customChatModel)
146 |             }
147 |             .debounce(id: DebounceID.save, for: 1, scheduler: DispatchQueue.main)
148 |         }
149 |     }
150 | }
151 | 
152 | extension ChatModel {
153 |     func toState() -> ChatModelEdit.State {
154 |         .init(
155 |             id: id,
156 |             format: format,
157 |             maxTokens: info.maxTokens,
158 |             modelName: info.modelName,
159 |             apiKeySelection: .init(
160 |                 apiKeyName: info.apiKeyName,
161 |                 apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
162 |             ),
163 |             baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
164 |             ollamaKeepAlive: info.ollamaInfo.keepAlive
165 |         )
166 |     }
167 | 
168 |     init(state: ChatModelEdit.State) {
169 |         self.init(
170 |             id: state.id,
171 |             name: "Custom Model (Chat Completion API)",
172 |             format: state.format,
173 |             info: .init(
174 |                 apiKeyName: state.apiKeyName, 
175 |                 baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
176 |                 isFullURL: state.baseURLSelection.isFullURL,
177 |                 maxTokens: state.maxTokens,
178 |                 supportsFunctionCalling: false,
179 |                 modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
180 |                 ollamaInfo: .init(keepAlive: state.ollamaKeepAlive)
181 |             )
182 |         )
183 |     }
184 | }
185 | 
186 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/CompletionModelEdit.swift:
--------------------------------------------------------------------------------
  1 | import CodeCompletionService
  2 | import ComposableArchitecture
  3 | import Dependencies
  4 | import Fundamental
  5 | import Storage
  6 | import SwiftUI
  7 | 
  8 | @Reducer
  9 | struct CompletionModelEdit {
 10 |     @ObservableState
 11 |     struct State: Equatable, Identifiable {
 12 |         var id: String = "Custom"
 13 |         var format: CompletionModel.Format
 14 |         var maxTokens: Int = 4000
 15 |         var modelName: String = ""
 16 |         var apiKeyName: String { apiKeySelection.apiKeyName }
 17 |         var baseURL: String { baseURLSelection.baseURL }
 18 |         var availableModelNames: [String] = []
 19 |         var availableAPIKeys: [String] = []
 20 |         var suggestedMaxTokens: Int?
 21 |         var apiKeySelection: APIKeySelection.State = .init()
 22 |         var baseURLSelection: BaseURLSelection.State = .init()
 23 |         var ollamaKeepAlive: String = ""
 24 |     }
 25 | 
 26 |     enum Action: Equatable, BindableAction {
 27 |         case binding(BindingAction)
 28 |         case appear
 29 |         case saveButtonClicked
 30 |         case refreshAvailableModelNames
 31 |         case checkSuggestedMaxTokens
 32 |         case readCustomModelFromDisk
 33 |         case apiKeySelection(APIKeySelection.Action)
 34 |         case baseURLSelection(BaseURLSelection.Action)
 35 |     }
 36 | 
 37 |     @Dependency(\.toast) var toast
 38 |     @Dependency(\.apiKeyKeychain) var keychain
 39 | 
 40 |     enum DebounceID: Hashable {
 41 |         case save
 42 |     }
 43 | 
 44 |     var body: some Reducer {
 45 |         BindingReducer()
 46 | 
 47 |         Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
 48 |             APIKeySelection()
 49 |         }
 50 | 
 51 |         Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
 52 |             BaseURLSelection()
 53 |         }
 54 | 
 55 |         Reduce { state, action in
 56 |             switch action {
 57 |             case .appear:
 58 |                 return .run { send in
 59 |                     await send(.readCustomModelFromDisk)
 60 |                 }
 61 | 
 62 |             case .saveButtonClicked:
 63 |                 let model = CompletionModel(state: state)
 64 |                 return .run { _ in
 65 |                     UserDefaults.shared.set(model, for: \.customCompletionModel)
 66 |                 }
 67 | 
 68 |             case .refreshAvailableModelNames:
 69 |                 if state.format == .openAI {
 70 |                     state.availableModelNames = OpenAIService.ChatCompletionModels.allCases
 71 |                         .map(\.rawValue)
 72 |                 }
 73 | 
 74 |                 return .none
 75 | 
 76 |             case .readCustomModelFromDisk:
 77 |                 let model = UserDefaults.shared.value(for: \.customCompletionModel)
 78 |                 state = model.toState()
 79 | 
 80 |                 return .run { send in
 81 |                     await send(.checkSuggestedMaxTokens)
 82 |                     await send(.refreshAvailableModelNames)
 83 |                 }
 84 | 
 85 |             case .checkSuggestedMaxTokens:
 86 |                 switch state.format {
 87 |                 case .openAI:
 88 |                     if let knownModel = OpenAIService.CompletionModels(rawValue: state.modelName) {
 89 |                         state.suggestedMaxTokens = knownModel.maxToken
 90 |                     } else {
 91 |                         state.suggestedMaxTokens = nil
 92 |                     }
 93 |                     return .none
 94 |                 default:
 95 |                     state.suggestedMaxTokens = nil
 96 |                     return .none
 97 |                 }
 98 | 
 99 |             case .apiKeySelection:
100 |                 return .none
101 | 
102 |             case .baseURLSelection:
103 |                 return .none
104 | 
105 |             case .binding(\.format):
106 |                 return .run { send in
107 |                     await send(.refreshAvailableModelNames)
108 |                     await send(.checkSuggestedMaxTokens)
109 |                 }
110 | 
111 |             case .binding(\.modelName):
112 |                 return .run { send in
113 |                     await send(.checkSuggestedMaxTokens)
114 |                 }
115 | 
116 |             case .binding:
117 |                 return .none
118 |             }
119 |         }
120 |     }
121 | 
122 |     func persistState(
123 |         _: CompletionModelEdit.State,
124 |         _ newValue: CompletionModelEdit.State
125 |     ) -> some Reducer {
126 |         Reduce { _, _ in
127 |             .run { _ in
128 |                 let model = CompletionModel(state: newValue)
129 |                 UserDefaults.shared.set(model, for: \.customCompletionModel)
130 |             }
131 |             .debounce(id: DebounceID.save, for: 1, scheduler: DispatchQueue.main)
132 |         }
133 |     }
134 | }
135 | 
136 | extension CompletionModel {
137 |     func toState() -> CompletionModelEdit.State {
138 |         .init(
139 |             id: id,
140 |             format: format,
141 |             maxTokens: info.maxTokens,
142 |             modelName: info.modelName,
143 |             apiKeySelection: .init(
144 |                 apiKeyName: info.apiKeyName,
145 |                 apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
146 |             ),
147 |             baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
148 |             ollamaKeepAlive: info.ollamaInfo.keepAlive
149 |         )
150 |     }
151 | 
152 |     init(state: CompletionModelEdit.State) {
153 |         self.init(
154 |             id: state.id,
155 |             name: "Custom Model (Completion API)",
156 |             format: state.format,
157 |             info: .init(
158 |                 apiKeyName: state.apiKeyName,
159 |                 baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
160 |                 isFullURL: state.baseURLSelection.isFullURL,
161 |                 maxTokens: state.maxTokens,
162 |                 modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
163 |                 ollamaInfo: .init(keepAlive: state.ollamaKeepAlive)
164 |             )
165 |         )
166 |     }
167 | }
168 | 
169 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/FIMModelEdit.swift:
--------------------------------------------------------------------------------
  1 | import CodeCompletionService
  2 | import ComposableArchitecture
  3 | import Dependencies
  4 | import Fundamental
  5 | import Storage
  6 | import SwiftUI
  7 | 
  8 | @Reducer
  9 | struct FIMModelEdit {
 10 |     @ObservableState
 11 |     struct State: Equatable, Identifiable {
 12 |         var id: String = "Custom"
 13 |         var format: FIMModel.Format
 14 |         var maxTokens: Int = 4000
 15 |         var modelName: String = ""
 16 |         var apiKeyName: String { apiKeySelection.apiKeyName }
 17 |         var baseURL: String { baseURLSelection.baseURL }
 18 |         var availableModelNames: [String] = []
 19 |         var availableAPIKeys: [String] = []
 20 |         var suggestedMaxTokens: Int?
 21 |         var apiKeySelection: APIKeySelection.State = .init()
 22 |         var baseURLSelection: BaseURLSelection.State = .init()
 23 |         var ollamaKeepAlive: String = ""
 24 |         var authenticationMode: FIMModel.Info.AuthenticationMode = .bearerToken
 25 |         var authenticationHeaderFieldName: String = ""
 26 |     }
 27 | 
 28 |     enum Action: Equatable, BindableAction {
 29 |         case binding(BindingAction)
 30 |         case appear
 31 |         case saveButtonClicked
 32 |         case refreshAvailableModelNames
 33 |         case checkSuggestedMaxTokens
 34 |         case readCustomModelFromDisk
 35 |         case apiKeySelection(APIKeySelection.Action)
 36 |         case baseURLSelection(BaseURLSelection.Action)
 37 |     }
 38 | 
 39 |     @Dependency(\.toast) var toast
 40 |     @Dependency(\.apiKeyKeychain) var keychain
 41 | 
 42 |     enum DebounceID: Hashable {
 43 |         case save
 44 |     }
 45 | 
 46 |     var body: some Reducer {
 47 |         BindingReducer()
 48 | 
 49 |         Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
 50 |             APIKeySelection()
 51 |         }
 52 | 
 53 |         Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
 54 |             BaseURLSelection()
 55 |         }
 56 | 
 57 |         Reduce { state, action in
 58 |             switch action {
 59 |             case .appear:
 60 |                 return .run { send in
 61 |                     await send(.readCustomModelFromDisk)
 62 |                 }
 63 | 
 64 |             case .saveButtonClicked:
 65 |                 let model = FIMModel(state: state)
 66 |                 return .run { _ in
 67 |                     UserDefaults.shared.set(model, for: \.customFIMModel)
 68 |                 }
 69 | 
 70 |             case .refreshAvailableModelNames:
 71 |                 if state.format == .mistral {
 72 |                     state.availableModelNames = [
 73 |                         "codestral-latest",
 74 |                         "codestral-2405",
 75 |                     ]
 76 |                 }
 77 | 
 78 |                 return .none
 79 | 
 80 |             case .readCustomModelFromDisk:
 81 |                 let model = UserDefaults.shared.value(for: \.customFIMModel)
 82 |                 state = model.toState()
 83 | 
 84 |                 return .run { send in
 85 |                     await send(.checkSuggestedMaxTokens)
 86 |                     await send(.refreshAvailableModelNames)
 87 |                 }
 88 | 
 89 |             case .checkSuggestedMaxTokens:
 90 |                 switch state.format {
 91 |                 case .mistral:
 92 |                     return .none
 93 |                 default:
 94 |                     state.suggestedMaxTokens = nil
 95 |                     return .none
 96 |                 }
 97 | 
 98 |             case .apiKeySelection:
 99 |                 return .none
100 | 
101 |             case .baseURLSelection:
102 |                 return .none
103 | 
104 |             case .binding(\.format):
105 |                 return .run { send in
106 |                     await send(.refreshAvailableModelNames)
107 |                     await send(.checkSuggestedMaxTokens)
108 |                 }
109 | 
110 |             case .binding(\.modelName):
111 |                 return .run { send in
112 |                     await send(.checkSuggestedMaxTokens)
113 |                 }
114 | 
115 |             case .binding:
116 |                 return .none
117 |             }
118 |         }
119 |     }
120 | 
121 |     func persistState(
122 |         _: FIMModelEdit.State,
123 |         _ newValue: FIMModelEdit.State
124 |     ) -> some Reducer {
125 |         Reduce { _, _ in
126 |             .run { _ in
127 |                 let model = FIMModel(state: newValue)
128 |                 UserDefaults.shared.set(model, for: \.customFIMModel)
129 |             }
130 |             .debounce(id: DebounceID.save, for: 1, scheduler: DispatchQueue.main)
131 |         }
132 |     }
133 | }
134 | 
135 | extension FIMModel {
136 |     func toState() -> FIMModelEdit.State {
137 |         .init(
138 |             id: id,
139 |             format: format,
140 |             maxTokens: info.maxTokens,
141 |             modelName: info.modelName,
142 |             apiKeySelection: .init(
143 |                 apiKeyName: info.apiKeyName,
144 |                 apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
145 |             ),
146 |             baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
147 |             ollamaKeepAlive: info.ollamaInfo.keepAlive,
148 |             authenticationMode: info.authenticationMode,
149 |             authenticationHeaderFieldName: info.authenticationHeaderFieldName
150 |         )
151 |     }
152 | 
153 |     init(state: FIMModelEdit.State) {
154 |         self.init(
155 |             id: state.id,
156 |             name: "Custom Model (Completion API)",
157 |             format: state.format,
158 |             info: .init(
159 |                 apiKeyName: state.apiKeyName,
160 |                 baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
161 |                 isFullURL: state.baseURLSelection.isFullURL,
162 |                 maxTokens: state.maxTokens,
163 |                 modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
164 |                 authenticationMode: state.authenticationMode,
165 |                 authenticationHeaderFieldName: state.authenticationHeaderFieldName,
166 |                 ollamaInfo: .init(keepAlive: state.ollamaKeepAlive)
167 |             )
168 |         )
169 |     }
170 | }
171 | 
172 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/TabbyModelEdit.swift:
--------------------------------------------------------------------------------
  1 | import ComposableArchitecture
  2 | import Dependencies
  3 | import Fundamental
  4 | import Storage
  5 | import SwiftUI
  6 | 
  7 | @Reducer
  8 | struct TabbyModelEdit {
  9 |     @ObservableState
 10 |     struct State: Equatable {
 11 |         var apiKeyName: String { apiKeySelection.apiKeyName }
 12 |         var url: String { urlSelection.baseURL }
 13 |         var apiKeySelection: APIKeySelection.State = .init()
 14 |         var urlSelection: BaseURLSelection.State = .init()
 15 |         var authorizationMode: TabbyModel.AuthorizationMode
 16 |         var authorizationHeaderName: String
 17 |         var username: String
 18 |     }
 19 | 
 20 |     enum Action: Equatable, BindableAction {
 21 |         case binding(BindingAction)
 22 |         case appear
 23 |         case saveButtonClicked
 24 |         case readCustomModelFromDisk
 25 |         case apiKeySelection(APIKeySelection.Action)
 26 |         case urlSelection(BaseURLSelection.Action)
 27 |     }
 28 | 
 29 |     @Dependency(\.toast) var toast
 30 |     @Dependency(\.apiKeyKeychain) var keychain
 31 | 
 32 |     enum DebounceID: Hashable {
 33 |         case save
 34 |     }
 35 | 
 36 |     var body: some Reducer {
 37 |         BindingReducer()
 38 | 
 39 |         Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
 40 |             APIKeySelection()
 41 |         }
 42 | 
 43 |         Scope(state: \.urlSelection, action: \.urlSelection) {
 44 |             BaseURLSelection()
 45 |         }
 46 | 
 47 |         Reduce { state, action in
 48 |             switch action {
 49 |             case .appear:
 50 |                 return .run { send in
 51 |                     await send(.readCustomModelFromDisk)
 52 |                 }
 53 | 
 54 |             case .saveButtonClicked:
 55 |                 let model = TabbyModel(state: state)
 56 |                 return .run { _ in
 57 |                     UserDefaults.shared.set(model, for: \.tabbyModel)
 58 |                 }
 59 | 
 60 |             case .readCustomModelFromDisk:
 61 |                 let model = UserDefaults.shared.value(for: \.tabbyModel)
 62 |                 state = model.toState()
 63 |                 return .none
 64 | 
 65 |             case .apiKeySelection:
 66 |                 return .none
 67 | 
 68 |             case .urlSelection:
 69 |                 return .none
 70 | 
 71 |             case .binding:
 72 |                 return .none
 73 |             }
 74 |         }
 75 |     }
 76 | 
 77 |     func persistState(
 78 |         _: TabbyModelEdit.State,
 79 |         _ newValue: TabbyModelEdit.State
 80 |     ) -> some Reducer {
 81 |         Reduce { _, _ in
 82 |             .run { _ in
 83 |                 let model = TabbyModel(state: newValue)
 84 |                 UserDefaults.shared.set(model, for: \.tabbyModel)
 85 |             }
 86 |             .debounce(id: DebounceID.save, for: 1, scheduler: DispatchQueue.main)
 87 |         }
 88 |     }
 89 | }
 90 | 
 91 | extension TabbyModel {
 92 |     func toState() -> TabbyModelEdit.State {
 93 |         .init(
 94 |             apiKeySelection: .init(
 95 |                 apiKeyName: apiKeyName,
 96 |                 apiKeyManagement: .init(availableAPIKeyNames: [apiKeyName])
 97 |             ),
 98 |             urlSelection: .init(baseURL: url),
 99 |             authorizationMode: authorizationMode,
100 |             authorizationHeaderName: authorizationHeaderName,
101 |             username: username
102 |         )
103 |     }
104 | 
105 |     init(state: TabbyModelEdit.State) {
106 |         self = .init(
107 |             url: state.urlSelection.baseURL,
108 |             authorizationMode: state.authorizationMode,
109 |             apiKeyName: state.apiKeySelection.apiKeyName,
110 |             authorizationHeaderName: state.authorizationHeaderName,
111 |             username: state.username
112 |         )
113 |     }
114 | }
115 | 
116 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/ChatModelManagement/TabbyModelEditView.swift:
--------------------------------------------------------------------------------
  1 | import ComposableArchitecture
  2 | import Fundamental
  3 | import Storage
  4 | import SwiftUI
  5 | 
  6 | @MainActor
  7 | struct TabbyModelEditView: View {
  8 |     @Perception.Bindable var store: StoreOf
  9 | 
 10 |     @Environment(\.dismiss) var dismiss
 11 | 
 12 |     var body: some View {
 13 |         WithPerceptionTracking {
 14 |             ScrollView {
 15 |                 VStack(spacing: 0) {
 16 |                     Form {
 17 |                         form
 18 |                     }
 19 |                     .padding()
 20 | 
 21 |                     Divider()
 22 | 
 23 |                     HStack {
 24 |                         Spacer()
 25 | 
 26 |                         Button("Cancel") {
 27 |                             dismiss()
 28 |                         }
 29 |                         .keyboardShortcut(.cancelAction)
 30 | 
 31 |                         Button(action: {
 32 |                             store.send(.saveButtonClicked)
 33 |                             dismiss()
 34 |                         }) {
 35 |                             Text("Save")
 36 |                         }
 37 |                         .keyboardShortcut(.defaultAction)
 38 |                     }
 39 |                     .padding()
 40 |                 }
 41 |             }
 42 |             .textFieldStyle(.roundedBorder)
 43 |             .onAppear {
 44 |                 store.send(.appear)
 45 |             }
 46 |             .fixedSize(horizontal: false, vertical: true)
 47 |         }
 48 |     }
 49 | 
 50 |     var authorizationModePicker: some View {
 51 |         Picker(
 52 |             selection: $store.authorizationMode,
 53 |             content: {
 54 |                 ForEach(
 55 |                     TabbyModel.AuthorizationMode.allCases,
 56 |                     id: \.rawValue
 57 |                 ) { format in
 58 |                     switch format {
 59 |                     case .none:
 60 |                         Text("None").tag(format)
 61 |                     case .bearerToken:
 62 |                         Text("Bearer Token").tag(format)
 63 |                     case .basic:
 64 |                         Text("Basic").tag(format)
 65 |                     case .customHeaderField:
 66 |                         Text("Custom Header Field").tag(format)
 67 |                     }
 68 |                 }
 69 |             },
 70 |             label: { Text("Format") }
 71 |         )
 72 |         .pickerStyle(.segmented)
 73 |     }
 74 | 
 75 |     func urlTextField(
 76 |         title: String = "URL",
 77 |         prompt: Text?
 78 |     ) -> some View {
 79 |         BaseURLPicker(
 80 |             title: title,
 81 |             prompt: prompt,
 82 |             store: store.scope(
 83 |                 state: \.urlSelection,
 84 |                 action: \.urlSelection
 85 |             )
 86 |         ) {
 87 |             EmptyView()
 88 |         }
 89 |     }
 90 | 
 91 |     @ViewBuilder
 92 |     func apiKeyNamePicker(title: String = "API Key") -> some View {
 93 |         APIKeyPicker(store: store.scope(
 94 |             state: \.apiKeySelection,
 95 |             action: \.apiKeySelection
 96 |         ), title: title)
 97 |     }
 98 | 
 99 |     @ViewBuilder
100 |     var form: some View {
101 |         urlTextField(prompt: Text("http://127.0.0.1:8080/v1/completions"))
102 |         authorizationModePicker
103 | 
104 |         switch store.authorizationMode {
105 |         case .none:
106 |             EmptyView()
107 |         case .basic:
108 |             TextField("Username", text: $store.username)
109 |             apiKeyNamePicker(title: "Password")
110 |         case .customHeaderField:
111 |             TextField("Header Name", text: $store.authorizationHeaderName)
112 |             apiKeyNamePicker(title: "Value")
113 |         case .bearerToken:
114 |             apiKeyNamePicker(title: "Token")
115 |         }
116 |     }
117 | }
118 | 
119 | #Preview("No Authorization") {
120 |     TabbyModelEditView(
121 |         store: .init(
122 |             initialState: TabbyModel(
123 |                 url: "http://127.0.0.1", authorizationMode: .none, apiKeyName: "",
124 |                 authorizationHeaderName: "Key", username: "User"
125 |             ).toState(),
126 |             reducer: { TabbyModelEdit() }
127 |         )
128 |     )
129 | }
130 | 
131 | #Preview("Bearer Token") {
132 |     TabbyModelEditView(
133 |         store: .init(
134 |             initialState: TabbyModel(
135 |                 url: "http://127.0.0.1", authorizationMode: .bearerToken, apiKeyName: "",
136 |                 authorizationHeaderName: "Key", username: "User"
137 |             ).toState(),
138 |             reducer: { TabbyModelEdit() }
139 |         )
140 |     )
141 | }
142 | 
143 | #Preview("Custom Header Field") {
144 |     TabbyModelEditView(
145 |         store: .init(
146 |             initialState: TabbyModel(
147 |                 url: "http://127.0.0.1", authorizationMode: .customHeaderField, apiKeyName: "",
148 |                 authorizationHeaderName: "Key", username: "User"
149 |             ).toState(),
150 |             reducer: { TabbyModelEdit() }
151 |         )
152 |     )
153 | }
154 | 
155 | #Preview("Basic") {
156 |     TabbyModelEditView(
157 |         store: .init(
158 |             initialState: TabbyModel(
159 |                 url: "http://127.0.0.1", authorizationMode: .basic, apiKeyName: "",
160 |                 authorizationHeaderName: "Key", username: "User"
161 |             ).toState(),
162 |             reducer: { TabbyModelEdit() }
163 |         )
164 |     )
165 | }
166 | 
167 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/CustomSuggestionService.entitlements:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 | 	com.apple.security.app-sandbox
 6 | 	
 7 | 	com.apple.security.application-groups
 8 | 	
 9 | 		$(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE)
10 | 	
11 | 	com.apple.security.files.user-selected.read-only
12 | 	
13 | 	com.apple.security.network.client
14 | 	
15 | 	keychain-access-groups
16 | 	
17 | 		$(AppIdentifierPrefix)$(BUNDLE_IDENTIFIER_BASE).Shared
18 | 	
19 | 
20 | 
21 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/CustomSuggestionServiceApp.swift:
--------------------------------------------------------------------------------
 1 | import SwiftUI
 2 | 
 3 | @main
 4 | struct CustomSuggestionServiceApp: App {
 5 |     var body: some Scene {
 6 |         WindowGroup {
 7 |             ContentView()
 8 |                 .environment(\.updateChecker, UpdateChecker(hostBundle: Bundle.main))
 9 |         }
10 |         .defaultSize(width: 800, height: 900)
11 |     }
12 | }
13 | 
14 | var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" }
15 | 
16 | struct UpdateCheckerKey: EnvironmentKey {
17 |     static var defaultValue: UpdateChecker = .init(hostBundle: nil)
18 | }
19 | 
20 | public extension EnvironmentValues {
21 |     var updateChecker: UpdateChecker {
22 |         get { self[UpdateCheckerKey.self] }
23 |         set { self[UpdateCheckerKey.self] = newValue }
24 |     }
25 | }
26 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Dependency.swift:
--------------------------------------------------------------------------------
 1 | import CopilotForXcodeKit
 2 | import Dependencies
 3 | import SuggestionService
 4 | 
 5 | // MARK: - SuggestionService
 6 | 
 7 | struct SuggestionServiceDependencyKey: DependencyKey {
 8 |     static var liveValue: SuggestionServiceType = SuggestionService()
 9 |     static var previewValue: SuggestionServiceType = MockSuggestionService()
10 | }
11 | 
12 | struct MockSuggestionService: SuggestionServiceType {
13 |     var configuration: SuggestionServiceConfiguration {
14 |         .init(
15 |             acceptsRelevantCodeSnippets: true,
16 |             mixRelevantCodeSnippetsInSource: false,
17 |             acceptsRelevantSnippetsFromOpenedFiles: true
18 |         )
19 |     }
20 | 
21 |     func getSuggestions(
22 |         _: SuggestionRequest,
23 |         workspace: WorkspaceInfo
24 |     ) async throws -> [CodeSuggestion] {
25 |         [.init(id: "id", text: "Hello World", position: .zero, range: .zero)]
26 |     }
27 | 
28 |     func notifyAccepted(_: CodeSuggestion, workspace: WorkspaceInfo) async {
29 |         print("Accepted")
30 |     }
31 | 
32 |     func notifyRejected(_: [CodeSuggestion], workspace: WorkspaceInfo) async {
33 |         print("Rejected")
34 |     }
35 | 
36 |     func cancelRequest(workspace: WorkspaceInfo) async {
37 |         print("Cancelled")
38 |     }
39 | }
40 | 
41 | extension DependencyValues {
42 |     var suggestionService: SuggestionServiceType {
43 |         get { self[SuggestionServiceDependencyKey.self] }
44 |         set { self[SuggestionServiceDependencyKey.self] = newValue }
45 |     }
46 | }
47 | 
48 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Info.plist:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 | 	BUNDLE_IDENTIFIER_BASE
 6 | 	$(BUNDLE_IDENTIFIER_BASE)
 7 | 	SUFeedURL
 8 | 	$(SPARKLE_FEED_URL)
 9 | 	SUPublicEDKey
10 | 	$(SPARKLE_PUBLIC_KEY)
11 | 	TEAM_ID_PREFIX
12 | 	$(TeamIdentifierPrefix)
13 | 
14 | 
15 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 |   "info" : {
3 |     "author" : "xcode",
4 |     "version" : 1
5 |   }
6 | }
7 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/TestField/TestField.swift:
--------------------------------------------------------------------------------
  1 | import ComposableArchitecture
  2 | import CopilotForXcodeKit
  3 | import Foundation
  4 | import Fundamental
  5 | import Storage
  6 | 
  7 | @Reducer
  8 | struct TestField {
  9 |     @ObservableState
 10 |     struct State: Equatable {
 11 |         var text: AttributedString = """
 12 |         struct Foo {
 13 |             var bar: Int
 14 |         }
 15 | 
 16 |         struct Owner {
 17 |             var name: String
 18 |             var age: Int
 19 |             var pets: [Animal]
 20 |         }
 21 | 
 22 |         var peter = Owner(name: "Peter", age: 30, pets: [])
 23 | 
 24 |         /// Cat is a type of animal
 25 |         struct Cat
 26 |         """
 27 | 
 28 |         var relevantCodeSnippets: AttributedString = """
 29 |         protocol Animal {
 30 |             var name: String { get }
 31 |             var age: Int { get }
 32 |             var isPet: Bool { get }
 33 |         }
 34 |         """
 35 |         var suggestions: [CodeSuggestion] = []
 36 |         var suggestionIndex: Int = 0
 37 |         var cursorPosition: CursorPosition = .zero
 38 |         var suggestion: AttributedString = ""
 39 |         var suggestionRange: CursorRange = .outOfScope
 40 |     }
 41 | 
 42 |     enum Action: Equatable, BindableAction {
 43 |         case appear
 44 |         case textChanged(String, CursorPosition)
 45 |         case suggestionReceived([CodeSuggestion])
 46 |         case suggestionRequestFailed(String)
 47 |         case nextSuggestionButtonClicked
 48 |         case previousSuggestionButtonClicked
 49 |         case generateSuggestionButtonClicked
 50 |         case generateSuggestion
 51 |         case cancelSuggestion
 52 |         case binding(BindingAction)
 53 |     }
 54 | 
 55 |     @Dependency(\.suggestionService) var suggestionService
 56 |     @Dependency(\.toast) var toast
 57 | 
 58 |     let workspace = WorkspaceInfo(
 59 |         workspaceURL: .init(filePath: "/"),
 60 |         projectURL: .init(filePath: "/")
 61 |     )
 62 | 
 63 |     enum CancellationID: Hashable {
 64 |         case textChanged
 65 |     }
 66 | 
 67 |     var body: some Reducer {
 68 |         BindingReducer()
 69 | 
 70 |         Reduce { state, action in
 71 |             switch action {
 72 |             case .appear:
 73 |                 let code = String(state.text.characters[...])
 74 |                 let lines = code.breakLines()
 75 |                 let endPosition = code.utf16.count
 76 |                 let range = endPosition...endPosition
 77 |                 let cursorRange = convertRangeToCursorRange(range, in: lines)
 78 |                 state.cursorPosition = cursorRange.end
 79 | 
 80 |                 return .none
 81 | 
 82 |             case let .textChanged(_, position):
 83 |                 state.cursorPosition = position
 84 |                 return .run { send in
 85 |                     await send(.cancelSuggestion)
 86 |                     try await Task.sleep(for: .milliseconds(400))
 87 |                     await send(.generateSuggestion)
 88 |                 }.cancellable(id: CancellationID.textChanged, cancelInFlight: true)
 89 | 
 90 |             case .generateSuggestion:
 91 |                 guard !isPreview else { return .none }
 92 |                 let relevantCodeSnippet = String(state.relevantCodeSnippets.characters[...])
 93 |                 let text = String(state.text.characters[...])
 94 |                 let position = state.cursorPosition
 95 |                 return .run { send in
 96 |                     await suggestionService.cancelRequest(workspace: workspace)
 97 |                     do {
 98 |                         let result = try await suggestionService.getSuggestions(
 99 |                             .init(
100 |                                 fileURL: .init(filePath: "/file.swift"),
101 |                                 relativePath: "/file.swift",
102 |                                 language: .builtIn(.swift),
103 |                                 content: text, 
104 |                                 originalContent: text,
105 |                                 cursorPosition: position,
106 |                                 tabSize: 4,
107 |                                 indentSize: 4,
108 |                                 usesTabsForIndentation: false,
109 |                                 relevantCodeSnippets: [
110 |                                     .init(
111 |                                         content: relevantCodeSnippet,
112 |                                         priority: 999,
113 |                                         filePath: ""
114 |                                     ),
115 |                                 ]
116 |                             ),
117 |                             workspace: workspace
118 |                         )
119 |                         await send(.suggestionReceived(result))
120 |                     } catch {
121 |                         await send(.suggestionRequestFailed(error.localizedDescription))
122 |                     }
123 |                 }
124 | 
125 |             case .cancelSuggestion:
126 |                 return .run { _ in await suggestionService.cancelRequest(workspace: workspace) }
127 | 
128 |             case let .suggestionReceived(suggestions):
129 |                 state.suggestions = suggestions
130 |                 state.suggestionIndex = 0
131 |                 updateDisplayedSuggestion(&state)
132 |                 return .none
133 | 
134 |             case let .suggestionRequestFailed(error):
135 |                 print(error)
136 |                 toast(error, .error)
137 | 
138 |                 return .none
139 | 
140 |             case .nextSuggestionButtonClicked:
141 |                 if state.suggestionIndex >= state.suggestions.endIndex - 1 {
142 |                     state.suggestionIndex = 0
143 |                 } else {
144 |                     state.suggestionIndex += 1
145 |                 }
146 |                 updateDisplayedSuggestion(&state)
147 |                 return .none
148 | 
149 |             case .previousSuggestionButtonClicked:
150 |                 if state.suggestionIndex <= 0 {
151 |                     state.suggestionIndex = state.suggestions.endIndex - 1
152 |                 } else {
153 |                     state.suggestionIndex -= 1
154 |                 }
155 |                 updateDisplayedSuggestion(&state)
156 |                 return .none
157 | 
158 |             case .generateSuggestionButtonClicked:
159 |                 return .run { send in await send(.generateSuggestion) }
160 | 
161 |             case .binding:
162 |                 return .none
163 |             }
164 |         }
165 |     }
166 | 
167 |     func updateDisplayedSuggestion(_ state: inout State) {
168 |         if state.suggestions.isEmpty {
169 |             state.suggestionRange = .outOfScope
170 |             state.suggestion = ""
171 |         } else {
172 |             state.suggestionRange = state.suggestions[state.suggestionIndex].range
173 |             state.suggestion = .init(state.suggestions[state.suggestionIndex].text)
174 |         }
175 |     }
176 | }
177 | 
178 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Toast/HandleToast.swift:
--------------------------------------------------------------------------------
 1 | import Dependencies
 2 | import SwiftUI
 3 | 
 4 | struct ToastHandler: View {
 5 |     @ObservedObject var toastController: ToastController
 6 |     let namespace: String?
 7 | 
 8 |     init(toastController: ToastController, namespace: String?) {
 9 |         _toastController = .init(wrappedValue: toastController)
10 |         self.namespace = namespace
11 |     }
12 | 
13 |     var body: some View {
14 |         VStack(spacing: 4) {
15 |             ForEach(toastController.messages) { message in
16 |                 if let n = message.namespace, n != namespace {
17 |                     EmptyView()
18 |                 } else {
19 |                     message.content
20 |                         .foregroundColor(.white)
21 |                         .padding(8)
22 |                         .background({
23 |                             switch message.type {
24 |                             case .info: return Color.accentColor
25 |                             case .error: return Color(nsColor: .systemRed)
26 |                             case .warning: return Color(nsColor: .systemOrange)
27 |                             }
28 |                         }() as Color, in: RoundedRectangle(cornerRadius: 8))
29 |                         .shadow(color: Color.black.opacity(0.2), radius: 4)
30 |                 }
31 |             }
32 |         }
33 |         .padding()
34 |         .allowsHitTesting(false)
35 |     }
36 | }
37 | 
38 | extension View {
39 |     func handleToast(namespace: String? = nil) -> some View {
40 |         @Dependency(\.toastController) var toastController
41 |         return overlay(alignment: .bottom) {
42 |             ToastHandler(toastController: toastController, namespace: namespace)
43 |         }.environment(\.toast) { [toastController] content, type in
44 |             toastController.toast(content: content, type: type, namespace: namespace)
45 |         }
46 |     }
47 | }
48 | 
49 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Toast/Toast.swift:
--------------------------------------------------------------------------------
  1 | import ComposableArchitecture
  2 | import Dependencies
  3 | import Foundation
  4 | import SwiftUI
  5 | 
  6 | public enum ToastType {
  7 |     case info
  8 |     case warning
  9 |     case error
 10 | }
 11 | 
 12 | public struct ToastKey: EnvironmentKey {
 13 |     public static var defaultValue: (String, ToastType) -> Void = { _, _ in }
 14 | }
 15 | 
 16 | public extension EnvironmentValues {
 17 |     var toast: (String, ToastType) -> Void {
 18 |         get { self[ToastKey.self] }
 19 |         set { self[ToastKey.self] = newValue }
 20 |     }
 21 | }
 22 | 
 23 | public struct ToastControllerDependencyKey: DependencyKey {
 24 |     public static let liveValue = ToastController(messages: [])
 25 | }
 26 | 
 27 | public extension DependencyValues {
 28 |     var toastController: ToastController {
 29 |         get { self[ToastControllerDependencyKey.self] }
 30 |         set { self[ToastControllerDependencyKey.self] = newValue }
 31 |     }
 32 | 
 33 |     var toast: (String, ToastType) -> Void {
 34 |         return { content, type in
 35 |             toastController.toast(content: content, type: type, namespace: nil)
 36 |         }
 37 |     }
 38 | 
 39 |     var namespacedToast: (String, ToastType, String) -> Void {
 40 |         return {
 41 |             content, type, namespace in
 42 |             toastController.toast(content: content, type: type, namespace: namespace)
 43 |         }
 44 |     }
 45 | }
 46 | 
 47 | public class ToastController: ObservableObject {
 48 |     public struct Message: Identifiable, Equatable {
 49 |         public struct MessageButton: Equatable {
 50 |             public static func == (lhs: Self, rhs: Self) -> Bool {
 51 |                 lhs.label == rhs.label
 52 |             }
 53 | 
 54 |             public var label: Text
 55 |             public var action: () -> Void
 56 |             public init(label: Text, action: @escaping () -> Void) {
 57 |                 self.label = label
 58 |                 self.action = action
 59 |             }
 60 |         }
 61 | 
 62 |         public var namespace: String?
 63 |         public var id: UUID
 64 |         public var type: ToastType
 65 |         public var content: Text
 66 |         public var buttons: [MessageButton]
 67 |         public init(
 68 |             id: UUID,
 69 |             type: ToastType,
 70 |             namespace: String? = nil,
 71 |             content: Text,
 72 |             buttons: [MessageButton] = []
 73 |         ) {
 74 |             self.namespace = namespace
 75 |             self.id = id
 76 |             self.type = type
 77 |             self.content = content
 78 |             self.buttons = buttons
 79 |         }
 80 |     }
 81 | 
 82 |     @Published public var messages: [Message] = []
 83 | 
 84 |     public init(messages: [Message]) {
 85 |         self.messages = messages
 86 |     }
 87 | 
 88 |     public func toast(
 89 |         content: String,
 90 |         type: ToastType,
 91 |         namespace: String? = nil,
 92 |         buttons: [Message.MessageButton] = [],
 93 |         duration: TimeInterval = 4
 94 |     ) {
 95 |         let id = UUID()
 96 |         let message = Message(
 97 |             id: id,
 98 |             type: type,
 99 |             namespace: namespace,
100 |             content: Text(content),
101 |             buttons: buttons.map { b in
102 |                 Message.MessageButton(label: b.label, action: { [weak self] in
103 |                     b.action()
104 |                     withAnimation(.easeInOut(duration: 0.2)) {
105 |                         self?.messages.removeAll { $0.id == id }
106 |                     }
107 |                 })
108 |             }
109 |         )
110 | 
111 |         Task { @MainActor in
112 |             withAnimation(.easeInOut(duration: 0.2)) {
113 |                 messages.append(message)
114 |                 messages = messages.suffix(3)
115 |             }
116 |             try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
117 |             withAnimation(.easeInOut(duration: 0.2)) {
118 |                 messages.removeAll { $0.id == id }
119 |             }
120 |         }
121 |     }
122 | }
123 | 
124 | @Reducer
125 | public struct Toast {
126 |     public typealias Message = ToastController.Message
127 | 
128 |     @ObservableState
129 |     public struct State: Equatable {
130 |         var isObservingToastController = false
131 |         public var messages: [Message] = []
132 | 
133 |         public init(messages: [Message] = []) {
134 |             self.messages = messages
135 |         }
136 |     }
137 | 
138 |     public enum Action: Equatable {
139 |         case start
140 |         case updateMessages([Message])
141 |         case toast(String, ToastType, String?)
142 |     }
143 | 
144 |     @Dependency(\.toastController) var toastController
145 | 
146 |     struct CancelID: Hashable {}
147 | 
148 |     public init() {}
149 | 
150 |     public var body: some ReducerOf {
151 |         Reduce { state, action in
152 |             switch action {
153 |             case .start:
154 |                 guard !state.isObservingToastController else { return .none }
155 |                 state.isObservingToastController = true
156 |                 return .run { send in
157 |                     let stream = AsyncStream<[Message]> { continuation in
158 |                         let cancellable = toastController.$messages.sink { newValue in
159 |                             continuation.yield(newValue)
160 |                         }
161 |                         continuation.onTermination = { _ in
162 |                             cancellable.cancel()
163 |                         }
164 |                     }
165 |                     for await newValue in stream {
166 |                         try Task.checkCancellation()
167 |                         await send(.updateMessages(newValue), animation: .linear(duration: 0.2))
168 |                     }
169 |                 }.cancellable(id: CancelID(), cancelInFlight: true)
170 |             case let .updateMessages(messages):
171 |                 state.messages = messages
172 |                 return .none
173 |             case let .toast(content, type, namespace):
174 |                 toastController.toast(content: content, type: type, namespace: namespace)
175 |                 return .none
176 |             }
177 |         }
178 |     }
179 | }
180 | 
181 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/Tutorial/TutorialView.swift:
--------------------------------------------------------------------------------
 1 | import SwiftUI
 2 | 
 3 | struct TutorialView: View {
 4 |     var body: some View {
 5 |         Text("Hello, World!")
 6 |     }
 7 | }
 8 | 
 9 | #Preview {
10 |     TutorialView()
11 | }
12 | 


--------------------------------------------------------------------------------
/CustomSuggestionService/UpdaterChecker.swift:
--------------------------------------------------------------------------------
 1 | import Sparkle
 2 | 
 3 | public final class UpdateChecker {
 4 |     let updater: SPUUpdater
 5 |     let hostBundleFound: Bool
 6 |     let delegate = UpdaterDelegate()
 7 | 
 8 |     public init(hostBundle: Bundle?) {
 9 |         if hostBundle == nil {
10 |             hostBundleFound = false
11 |             print("Host bundle not found")
12 |         } else {
13 |             hostBundleFound = true
14 |         }
15 |         updater = SPUUpdater(
16 |             hostBundle: hostBundle ?? Bundle.main,
17 |             applicationBundle: Bundle.main,
18 |             userDriver: SPUStandardUserDriver(hostBundle: hostBundle ?? Bundle.main, delegate: nil),
19 |             delegate: delegate
20 |         )
21 |         do {
22 |             try updater.start()
23 |         } catch {
24 |             print(error.localizedDescription)
25 |         }
26 |     }
27 | 
28 |     public func checkForUpdates() {
29 |         updater.checkForUpdates()
30 |     }
31 | 
32 |     public var automaticallyChecksForUpdates: Bool {
33 |         get { updater.automaticallyChecksForUpdates }
34 |         set { updater.automaticallyChecksForUpdates = newValue }
35 |     }
36 | }
37 | 
38 | class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
39 |     func allowedChannels(for updater: SPUUpdater) -> Set {
40 |         if UserDefaults.shared.value(for: \.installBetaBuild) {
41 |             Set(["beta"])
42 |         } else {
43 |             []
44 |         }
45 |     }
46 | }
47 | 
48 | 


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2024 Shangxin Guo
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # Custom Suggestion Service for Copilot for Xcode
 2 | 
 3 | This extension offers a custom suggestion service for [Copilot for Xcode](https://github.com/intitni/CopilotForXcode), allowing you to leverage a chat model to enhance the suggestions provided as you write code.
 4 | 
 5 | ## Installation
 6 | 
 7 | 1. Install the application in the Applications folder.
 8 | 2. Launch the application.
 9 | 3. Open Copilot for Xcode and navigate to "Extensions".
10 | 4. Click "Select Extensions" and enable this extension.
11 | 5. You can now set this application as the suggestion provider in the suggestion settings.
12 | 
13 | ## Update
14 | 
15 | To update the app, you can do so directly within the app itself. Once updated, you should perform one of the following steps to ensure Copilot for Xcode recognizes the new version:
16 | 
17 | 1. Restart the `CopilotForXcodeExtensionService`.
18 | 2. Alternatively, terminate the "Custom Suggestion Service (CopilotForXcodeExtensionService)" process, open the extension manager in Copilot for Xcode, and click "Restart Extensions".
19 | 
20 | We are exploring better methods to tweak the update process.
21 | 
22 | ## Settings
23 | 
24 | The app supports three types of suggestion services:
25 | 
26 | - Models with chat completions API
27 | - Models with completions API
28 | - Models with FIM API
29 | - [Tabby](https://tabby.tabbyml.com)
30 | 
31 | If you are new to running a model locally, you can try [Ollama](https://ollama.com) and [LM Studio](https://lmstudio.ai).
32 | 
33 | ### Recommended Settings
34 | 
35 | - Use Tabby since they have extensive experience in code completion.
36 | - Use models with completions API with Fill-in-the-Middle support (for example, codellama:7b-code), and use the "Fill-in-the-Middle" strategy.
37 | 
38 |     You can find some [examples here](#example-use-of-local-models).
39 | - Use models with FIM API.
40 | 
41 | When using custom models to generate suggestions, it is recommended to setup a lower suggestion limit for faster generation.
42 | 
43 | ### Others
44 | 
45 | In other situations, it is advisable to use a custom model with the completions API over a chat completions API, and employ the default request strategy.
46 | 
47 | Ensure that the prompt format remains as simple as the following:
48 | 
49 | ```
50 | {System}
51 | {User}
52 | {Assistant}
53 | ```
54 | 
55 | The template format differs in different tools.
56 | 
57 | ## Strategies
58 | 
59 | - Fill-in-the-Middle: It uses special tokens to guide the models to generate suggestions. The models need to support FIM to use it (codellama:xb-code, startcoder, etc.). You need to setup a prompt format to allow it to work properly. The default prompt format is for codellama.
60 | - Fill-in-the-Middle with System Prompt: The previous one doesn't have a system prompt telling it what to do. You can try to use it in models that don't support FIM.
61 | - Anthropic Optimized: This strategy is optimized for Anthropic chat models. You can try it on other chat models, too.
62 | - Default: This strategy meticulously explains the context to the model, prompting it to generate a suggestion.
63 | - Naive: This strategy rearranges the code in a naive way to trick the model into believing it's appending code at the end of a file.
64 | - Continue: This strategy employs the "Please Continue" technique to persuade the model that it has started a suggestion and must continue to complete it. (Only effective with the chat completion API).
65 | 
66 | ## Example Use of Local Models
67 | 
68 | ### Qwen Type in the Middle
69 | 
70 | 1. Load `qwen2.5-coder:32b` with Ollama.
71 | 2. Setup the model with the completions API.
72 | 3. Set the request strategy to "Fill-in-the-Middle".
73 | 4. Set prompt format to `<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>` and turn on Raw Prompt.
74 | 
75 | ## Contribution
76 | 
77 | Prompt engineering is a challenging task, and your assistance is invaluable.
78 | 
79 | The most complex things are located within the `Core` package.
80 | 
81 | - To add a new service, please refer to the `CodeCompletionService` folder.
82 | - To add new request strategies, check out the `SuggestionService` folder.
83 | - To add a new instructions of using a local model, update the `Example Use of Local Models` in the README.md.
84 | 


--------------------------------------------------------------------------------
/TestPlan.xctestplan:
--------------------------------------------------------------------------------
 1 | {
 2 |   "configurations" : [
 3 |     {
 4 |       "id" : "2CD745CA-E543-4414-9155-E331D21582A0",
 5 |       "name" : "Configuration 1",
 6 |       "options" : {
 7 | 
 8 |       }
 9 |     }
10 |   ],
11 |   "defaultOptions" : {
12 |     "testTimeoutsEnabled" : true
13 |   },
14 |   "testTargets" : [
15 |     {
16 |       "target" : {
17 |         "containerPath" : "container:Core",
18 |         "identifier" : "CodeCompletionServiceTests",
19 |         "name" : "CodeCompletionServiceTests"
20 |       }
21 |     },
22 |     {
23 |       "target" : {
24 |         "containerPath" : "container:Core",
25 |         "identifier" : "FundamentalTests",
26 |         "name" : "FundamentalTests"
27 |       }
28 |     },
29 |     {
30 |       "target" : {
31 |         "containerPath" : "container:Core",
32 |         "identifier" : "SuggestionServiceTests",
33 |         "name" : "SuggestionServiceTests"
34 |       }
35 |     }
36 |   ],
37 |   "version" : 1
38 | }
39 | 


--------------------------------------------------------------------------------
/Version.xcconfig:
--------------------------------------------------------------------------------
1 | APP_VERSION = 0.7.0
2 | APP_BUILD = 74
3 |  
4 | 


--------------------------------------------------------------------------------
/appcast.xml:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |     
 4 |         Custom Suggestion Service
 5 |         
 6 |             0.7.0
 7 |             Sat, 05 Apr 2025 16:06:39 +0800
 8 |             74
 9 |             0.7.0
10 |             13.0
11 |             https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode/releases/tag/0.7.0
12 |             
13 |         
14 |         
15 |             0.6.0
16 |             Sun, 29 Dec 2024 23:59:31 +0800
17 |             60
18 |             0.6.0
19 |             13.0
20 |             https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode/releases/tag/0.6.0
21 |             
22 |         
23 |         
24 |             0.5.0
25 |             Tue, 24 Sep 2024 16:57:22 +0800
26 |             50
27 |             0.5.0
28 |             13.0
29 |             https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode/releases/tag/0.5.0
30 |             
31 |         
32 |     
33 | 


--------------------------------------------------------------------------------
/makefile:
--------------------------------------------------------------------------------
 1 | GITHUB_URL := https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode/
 2 | ZIPNAME_BASE := Custom.Suggestion.Service.app
 3 | APPCAST_PATH := ./appcast.xml
 4 | 
 5 | setup:
 6 | 	echo "Setup."
 7 | 
 8 | # Usage: make appcast app=path/to/bundle.app tag=1.0.0 [channel=beta] [release=1]
 9 | appcast:
10 | 	$(eval RELEASEDIR := ~/Library/Caches/CodeiumForXcodeRelease/$(shell uuidgen))
11 | 	$(eval BUNDLENAME := $(shell basename "$(app)"))
12 | 	$(eval WORKDIR := $(shell dirname "$(app)"))
13 | 	$(eval ZIPNAME := $(ZIPNAME_BASE)$(if $(channel),.$(channel).$(if $(release),$(release),1)))
14 | 	$(eval RELEASENOTELINK := $(GITHUB_URL)releases/tag/$(tag))
15 | 	mkdir -p $(RELEASEDIR)
16 | 	cp "$(APPCAST_PATH)" $(RELEASEDIR)/appcast.xml
17 | 	cd $(WORKDIR) && ditto -c -k --sequesterRsrc --keepParent "$(BUNDLENAME)" "$(ZIPNAME).zip"
18 | 	cd $(WORKDIR) && cp "$(ZIPNAME).zip" $(RELEASEDIR)/
19 | 	touch $(RELEASEDIR)/$(ZIPNAME).html
20 | 	echo "" > $(RELEASEDIR)/$(ZIPNAME).html
21 | 	-sparkle/bin/generate_appcast $(RELEASEDIR) --download-url-prefix "$(GITHUB_URL)releases/download/$(tag)/" --release-notes-url-prefix "$(RELEASENOTELINK)" $(if $(channel),--channel "$(channel)")
22 | 	mv -f $(RELEASEDIR)/appcast.xml "$(APPCAST_PATH)"
23 | 	rm -rf $(RELEASEDIR)
24 | 	sed -i '' 's/$(ZIPNAME).html/$(tag)/g' "$(APPCAST_PATH)"
25 | 
26 | .PHONY: setup appcast
27 | 
28 | 


--------------------------------------------------------------------------------