diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..8a3f650e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +on: + pull_request: + +jobs: + test: + name: Copilot for Xcode Debug + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode 26 + id: xcode + run: | + shopt -s nullglob + apps=(/Applications/Xcode_26*.app) + if [ ${#apps[@]} -eq 0 ]; then + echo "has_xcode26=false" >> "$GITHUB_OUTPUT" + echo "Xcode 26 not found; using runner default." + else + IFS=$'\n' sorted=($(printf '%s\n' "${apps[@]}" | sort -V)) + app="${sorted[${#sorted[@]}-1]}" + sudo xcode-select -s "$app/Contents/Developer" + echo "has_xcode26=true" >> "$GITHUB_OUTPUT" + echo "Selected $app" + fi + xcodebuild -version + + - name: Test + continue-on-error: ${{ steps.xcode.outputs.has_xcode26 != 'true' }} + run: | + xcodebuild -workspace "Copilot for Xcode.xcworkspace" -scheme "Copilot for Xcode Debug" \ + -configuration Debug -destination 'platform=macOS' -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO \ + test -parallel-testing-enabled NO diff --git a/.gitignore b/.gitignore index 488722ae..715afa87 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,7 @@ iOSInjectionProject/ https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager Secrets.xcconfig +Local.xcconfig Python/Python.xcframework Python/python-stdlib Python/site-packages/* diff --git a/ChatPlugins/Package.swift b/ChatPlugins/Package.swift index 4defd772..f4f2ebe7 100644 --- a/ChatPlugins/Package.swift +++ b/ChatPlugins/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "ChatPlugins", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "ChatPlugins", diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift index 8a064aef..3ca279a7 100644 --- a/CommunicationBridge/ServiceDelegate.swift +++ b/CommunicationBridge/ServiceDelegate.swift @@ -8,11 +8,27 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate { _: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection ) -> Bool { + guard let teamID = XPCPeerRequirement.teamID(fromPrefix: teamIDPrefix) else { + Logger.communicationBridge.error( + "Rejected XPC connection from pid \(newConnection.processIdentifier): Team ID is unavailable." + ) + return false + } + let requirement = XPCPeerRequirement.codeSigningRequirement(teamID: teamID) + do { + try XPCPeerRequirement.setCodeSigningRequirement(on: newConnection, teamID: teamID) + } catch { + Logger.communicationBridge.error( + "Rejected XPC connection from pid \(newConnection.processIdentifier): invalid code signing requirement (\(requirement))." + ) + return false + } + newConnection.exportedInterface = NSXPCInterface( with: CommunicationBridgeXPCServiceProtocol.self ) - let exportedObject = XPCService() + let exportedObject = XPCService(processIdentifier: newConnection.processIdentifier) newConnection.exportedObject = exportedObject newConnection.resume() @@ -24,6 +40,11 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate { class XPCService: CommunicationBridgeXPCServiceProtocol { static let eventHandler = EventHandler() + let processIdentifier: pid_t + + init(processIdentifier: pid_t) { + self.processIdentifier = processIdentifier + } func launchExtensionServiceIfNeeded( withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void @@ -44,13 +65,18 @@ class XPCService: CommunicationBridgeXPCServiceProtocol { withReply reply: @escaping () -> Void ) { Task { - await Self.eventHandler.updateServiceEndpoint(endpoint: endpoint, withReply: reply) + await Self.eventHandler.updateServiceEndpoint( + endpoint: endpoint, + processIdentifier: processIdentifier, + withReply: reply + ) } } } actor EventHandler { var endpoint: NSXPCListenerEndpoint? + var endpointPID: pid_t? let launcher = ExtensionServiceLauncher() var exitTask: Task? @@ -62,21 +88,27 @@ actor EventHandler { withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void ) async { rescheduleExitTask() - #if DEBUG - if let endpoint, !(await testXPCListenerEndpoint(endpoint)) { - self.endpoint = nil + if let endpoint, + let pid = endpointPID, + let running = NSRunningApplication(processIdentifier: pid), + !running.isTerminated + { + Logger.communicationBridge.info("Service app is still valid") + await launcher.attach(running) + reply(endpoint) + return } - reply(endpoint) - #else + // Stale anonymous listener: do not keep a handle just because some other + // process with the same bundle id is still running. + endpoint = nil + endpointPID = nil if await launcher.isApplicationValid { Logger.communicationBridge.info("Service app is still valid") - reply(endpoint) + reply(nil) } else { - endpoint = nil await launcher.launch() reply(nil) } - #endif } func quit(withReply reply: () -> Void) { @@ -85,9 +117,24 @@ actor EventHandler { exit(0) } - func updateServiceEndpoint(endpoint: NSXPCListenerEndpoint, withReply reply: () -> Void) { + func updateServiceEndpoint( + endpoint: NSXPCListenerEndpoint, + processIdentifier: pid_t, + withReply reply: () -> Void + ) { rescheduleExitTask() + let expectedBundleID = bundleIdentifierBase + ".ExtensionService" + let actualBundleID = NSRunningApplication(processIdentifier: processIdentifier)? + .bundleIdentifier + guard actualBundleID == expectedBundleID else { + Logger.communicationBridge.error( + "Ignoring service endpoint from pid \(processIdentifier) (\(actualBundleID ?? "unknown bundle id")); expected \(expectedBundleID)." + ) + reply() + return + } self.endpoint = endpoint + endpointPID = processIdentifier reply() } @@ -131,7 +178,16 @@ actor ExtensionServiceLauncher { return false } + func attach(_ application: NSRunningApplication) { + self.application = application + } + func launch() { + if let running = runningApplicationMatchingAppURL() { + application = running + return + } + guard !isLaunching else { return } isLaunching = true @@ -161,5 +217,16 @@ actor ExtensionServiceLauncher { self.isLaunching = false } } -} + /// Prefer the instance launched from this package; other installs with the same + /// bundle id fall through to `openApplication(at: appURL)`. + private func runningApplicationMatchingAppURL() -> NSRunningApplication? { + let wanted = appURL.standardizedFileURL + return NSRunningApplication.runningApplications(withBundleIdentifier: appIdentifier) + .first { running in + guard !running.isTerminated else { return false } + guard let bundleURL = running.bundleURL else { return false } + return bundleURL.standardizedFileURL == wanted + } + } +} diff --git a/CommunicationBridge/main.swift b/CommunicationBridge/main.swift index bb449566..91cb6156 100644 --- a/CommunicationBridge/main.swift +++ b/CommunicationBridge/main.swift @@ -3,9 +3,13 @@ import Foundation class AppDelegate: NSObject, NSApplicationDelegate {} -let bundleIdentifierBase = Bundle(url: Bundle.main.bundleURL.appendingPathComponent( +let extensionServiceBundle = Bundle(url: Bundle.main.bundleURL.appendingPathComponent( "CopilotForXcodeExtensionService.app" -))?.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as? String ?? "com.intii.CopilotForXcode" +)) +let bundleIdentifierBase = extensionServiceBundle? + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as? String ?? "com.intii.CopilotForXcode" +let teamIDPrefix = extensionServiceBundle? + .object(forInfoDictionaryKey: "TEAM_ID_PREFIX") as? String let serviceIdentifier = bundleIdentifierBase + ".CommunicationBridge" let appDelegate = AppDelegate() diff --git a/Config.debug.xcconfig b/Config.debug.xcconfig index 5417881b..146b1ac0 100644 --- a/Config.debug.xcconfig +++ b/Config.debug.xcconfig @@ -1,6 +1,15 @@ #include "Version.xcconfig" SLASH = / +// Apple Silicon only: no Intel slice is built, so no target can fall back to +// ARCHS_STANDARD and quietly reintroduce x86_64. +ARCHS = arm64 + +// Belt and braces: Xcode re-materializes ARCHS = $(ARCHS_STANDARD) at target level whenever the +// Architectures row is touched in the UI, which silently overrides the line above. EXCLUDED_ARCHS +// is subtracted from whatever ARCHS ends up being, so the Intel slice stays out either way. +EXCLUDED_ARCHS = x86_64 + HOST_APP_NAME = Copilot for Xcode Dev BUNDLE_IDENTIFIER_BASE = dev.com.intii.CopilotForXcode SPARKLE_FEED_URL = http:$(SLASH)$(SLASH)127.0.0.1:9433/appcast.xml @@ -11,3 +20,5 @@ EXTENSION_BUNDLE_DISPLAY_NAME = Copilot Dev EXTENSION_SERVICE_NAME = CopilotForXcodeExtensionService // see also target Configs + +#include? "Local.xcconfig" diff --git a/Config.xcconfig b/Config.xcconfig index 81d6e2ba..de738d4f 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -1,6 +1,15 @@ #include "Version.xcconfig" SLASH = / +// Apple Silicon only: no Intel slice is built, so no target can fall back to +// ARCHS_STANDARD and quietly reintroduce x86_64. +ARCHS = arm64 + +// Belt and braces: Xcode re-materializes ARCHS = $(ARCHS_STANDARD) at target level whenever the +// Architectures row is touched in the UI, which silently overrides the line above. EXCLUDED_ARCHS +// is subtracted from whatever ARCHS ends up being, so the Intel slice stays out either way. +EXCLUDED_ARCHS = x86_64 + HOST_APP_NAME = Copilot for Xcode BUNDLE_IDENTIFIER_BASE = com.intii.CopilotForXcode SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)copilotforxcode.intii.com/appcast.xml @@ -11,3 +20,5 @@ EXTENSION_BUNDLE_DISPLAY_NAME = Copilot EXTENSION_SERVICE_NAME = CopilotForXcodeExtensionService // see also target Configs + +#include? "Local.xcconfig" diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 056e5761..14637229 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + C8A1180029000000000000A1 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C8A1180029000000000000A0 /* Localizable.xcstrings */; }; + C8A1180029000000000000A2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C8A1180029000000000000A0 /* Localizable.xcstrings */; }; + C8A1180029000000000000A3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C8A1180029000000000000A0 /* Localizable.xcstrings */; }; C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* RealtimeSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */; }; C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; @@ -55,6 +58,7 @@ C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */; }; C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; }; + C8F4D1026F0AF0FD00A4C002 /* ExecutableFingerprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -177,6 +181,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + C8A1180029000000000000A0 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Localization/Localizable.xcstrings; sourceTree = ""; }; C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeSuggestionCommand.swift; sourceTree = ""; }; C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; }; @@ -240,6 +245,7 @@ C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; }; C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; }; C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; }; + C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutableFingerprint.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -342,6 +348,7 @@ C81458AD293A009600135263 /* Config.xcconfig */, C81458AE293A009800135263 /* Config.debug.xcconfig */, C8CD828229B88006008D044D /* TestPlan.xctestplan */, + C8A1180029000000000000A0 /* Localizable.xcstrings */, C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */, C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */, C84FD9D72CC671C600BE5093 /* ChatPlugins */, @@ -408,6 +415,7 @@ C861E6102994F6070056CB02 /* AppDelegate.swift */, C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */, C8738B702BE4F8B700609E7F /* XPCController.swift */, + C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */, C81291D52994FE6900196E12 /* Main.storyboard */, C861E6142994F6080056CB02 /* Assets.xcassets */, C861E6192994F6080056CB02 /* ExtensionService.entitlements */, @@ -615,6 +623,8 @@ knownRegions = ( en, Base, + "zh-Hans", + "zh-Hant", ); mainGroup = C8189B0D2938972F00C9DCDA; packageReferences = ( @@ -640,6 +650,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C8A1180029000000000000A3 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -647,6 +658,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C8A1180029000000000000A1 /* Localizable.xcstrings in Resources */, C8189B212938973000C9DCDA /* Preview Assets.xcassets in Resources */, C8189B1E2938973000C9DCDA /* Assets.xcassets in Resources */, ); @@ -656,6 +668,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C8A1180029000000000000A2 /* Localizable.xcstrings in Resources */, C861E6152994F6080056CB02 /* Assets.xcassets in Resources */, C81291D72994FE6900196E12 /* Main.storyboard in Resources */, ); @@ -719,6 +732,7 @@ files = ( C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */, C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */, + C8F4D1026F0AF0FD00A4C002 /* ExecutableFingerprint.swift in Sources */, C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */, C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */, ); @@ -776,7 +790,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = EditorExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)"; @@ -786,7 +800,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -804,7 +818,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = EditorExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)"; @@ -814,7 +828,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -877,7 +891,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -934,7 +948,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -955,7 +969,7 @@ CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\""; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -967,7 +981,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -989,7 +1003,7 @@ CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\""; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1001,7 +1015,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -1015,9 +1029,9 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1029,9 +1043,9 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1048,7 +1062,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1061,7 +1075,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1081,7 +1095,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1094,7 +1108,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1109,12 +1123,12 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1128,12 +1142,12 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1152,7 +1166,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\""; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1165,7 +1179,7 @@ "@executable_path/../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.2; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1186,7 +1200,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\""; - DEVELOPMENT_TEAM = 5YKZ4Y3DAW; + DEVELOPMENT_TEAM = "$(inherited)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1199,7 +1213,7 @@ "@executable_path/../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.2; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 87fd4d4e..d048a5ab 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "aexml", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tadija/AEXML.git", - "state" : { - "revision" : "db806756c989760b35108146381535aec231092b", - "version" : "4.7.0" - } - }, { "identity" : "cgeventoverride", "kind" : "remoteSourceControl", @@ -81,15 +72,6 @@ "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2" } }, - { - "identity" : "indexstore-db", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/indexstore-db.git", - "state" : { - "branch" : "release/6.1", - "revision" : "54212fce1aecb199070808bdb265e7f17e396015" - } - }, { "identity" : "jsonrpc", "kind" : "remoteSourceControl", @@ -126,15 +108,6 @@ "version" : "0.8.0" } }, - { - "identity" : "messagepacker", - "kind" : "remoteSourceControl", - "location" : "https://github.com/hirotakan/MessagePacker.git", - "state" : { - "revision" : "4d8346c6bc579347e4df0429493760691c5aeca2", - "version" : "0.4.7" - } - }, { "identity" : "networkimage", "kind" : "remoteSourceControl", @@ -153,15 +126,6 @@ "version" : "1.6.0" } }, - { - "identity" : "pathkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/PathKit.git", - "state" : { - "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", - "version" : "1.0.1" - } - }, { "identity" : "processenv", "kind" : "remoteSourceControl", @@ -171,15 +135,6 @@ "version" : "0.3.1" } }, - { - "identity" : "sourcekitten", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/SourceKitten", - "state" : { - "revision" : "eb6656ed26bdef967ad8d07c27e2eab34dc582f2", - "version" : "0.37.0" - } - }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -189,15 +144,6 @@ "version" : "2.7.0" } }, - { - "identity" : "spectre", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/Spectre.git", - "state" : { - "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", - "version" : "0.10.1" - } - }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -257,8 +203,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "69247baf7be2fd6f5820192caef0082d01849cd0", - "version" : "1.16.1" + "revision" : "377da4061db10d26337a71bb279c506bb951f50f", + "version" : "1.26.2" } }, { @@ -311,8 +257,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", - "version" : "2.3.0" + "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", + "version" : "2.8.0" } }, { @@ -333,6 +279,15 @@ "version" : "1.6.0" } }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "3552faf8a6c18ce896ec5a72bd8c34755f0f03e0", + "version" : "2.10.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -351,15 +306,6 @@ "version" : "2.6.1" } }, - { - "identity" : "swiftterm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/migueldeicaza/SwiftTerm", - "state" : { - "revision" : "e2b431dbf73f775fb4807a33e4572ffd3dc6933a", - "version" : "1.2.5" - } - }, { "identity" : "swifttreesitter", "kind" : "remoteSourceControl", @@ -369,24 +315,6 @@ "revision" : "fd499bfafcccfae12a1a579dc922d8418025a35d" } }, - { - "identity" : "swiftui-introspect", - "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/swiftui-introspect", - "state" : { - "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", - "version" : "1.3.0" - } - }, - { - "identity" : "swxmlhash", - "kind" : "remoteSourceControl", - "location" : "https://github.com/drmohundro/SWXMLHash.git", - "state" : { - "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", - "version" : "7.0.2" - } - }, { "identity" : "tiktoken", "kind" : "remoteSourceControl", @@ -414,31 +342,13 @@ "version" : "0.19.3" } }, - { - "identity" : "xcodeproj", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tuist/XcodeProj.git", - "state" : { - "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4", - "version" : "8.27.7" - } - }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", - "version" : "1.5.2" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams.git", - "state" : { - "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b", - "version" : "5.3.1" + "revision" : "d9308ad679143c26a30dec52daefe82f54af2b82", + "version" : "1.13.1" } } ], diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift index 99a0a044..736d9b8b 100644 --- a/Copilot for Xcode/App.swift +++ b/Copilot for Xcode/App.swift @@ -1,6 +1,7 @@ import Client import HostApp import LaunchAgentManager +import Preferences import SwiftUI import UpdateChecker import XPCShared @@ -24,6 +25,10 @@ let updateCheckerDelegate = TheUpdateCheckerDelegate() @main struct CopilotForXcodeApp: App { + init() { + AppLanguage.applyPreferredLanguage() + } + var body: some Scene { WindowGroup { TabContainer() diff --git a/Core/Package.swift b/Core/Package.swift index 6cd0910a..348aaf3f 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -46,7 +46,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - exact: "1.16.1" + exact: "1.26.2" ), // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts @@ -72,6 +72,7 @@ let package = Package( .target( name: "Service", dependencies: [ + .product(name: "CustomSuggestionService", package: "Tool"), "SuggestionWidget", "SuggestionService", "ChatService", @@ -123,6 +124,7 @@ let package = Package( dependencies: [ "Client", "LaunchAgentManager", + "UpdateChecker", "PlusFeatureFlag", .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Toast", package: "Tool"), @@ -138,12 +140,22 @@ let package = Package( "ProHostApp", ]) ), + .testTarget( + name: "HostAppTests", + dependencies: [ + "HostApp", + .product(name: "Preferences", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), // MARK: - Suggestion Service .target( name: "SuggestionService", dependencies: [ + .product(name: "CustomSuggestionService", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), @@ -233,6 +245,7 @@ let package = Package( .product(name: "Logger", package: "Tool"), .product(name: "CustomAsyncAlgorithms", package: "Tool"), .product(name: "CodeDiff", package: "Tool"), + .product(name: "CommandHandler", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -244,6 +257,10 @@ let package = Package( .target(name: "FileChangeChecker"), .target(name: "LaunchAgentManager"), + .testTarget( + name: "LaunchAgentManagerTests", + dependencies: ["LaunchAgentManager"] + ), .target( name: "UpdateChecker", dependencies: [ diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift index 57e853d4..dea50bcd 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -3,6 +3,7 @@ import SwiftUI struct APIKeyPicker: View { @Perception.Bindable var store: StoreOf + var title: LocalizedStringKey = "API Key" var body: some View { WithPerceptionTracking { @@ -27,7 +28,7 @@ struct APIKeyPicker: View { } }, - label: { Text("API Key") } + label: { Text(title) } ) Button(action: { store.send(.manageAPIKeysButtonClicked) }) { diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index d16b7556..373ed593 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -151,7 +151,7 @@ struct ChatModelEditView: View { struct BaseURLTextField: View { let store: StoreOf - var title: String = "Base URL" + var title: LocalizedStringKey = "Base URL" let prompt: Text? @ViewBuilder var trailingContent: () -> V diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift index 64eadd57..f4117868 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -87,6 +87,9 @@ struct ChatModelManagement: AIModelManagement { case let .removeModel(id): state.models.remove(id: id) + if userDefaults.value(for: \.customSuggestionModelId) == id { + userDefaults.set("", for: \.customSuggestionModelId) + } persist(state) return .none diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift new file mode 100644 index 00000000..c1350a4f --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift @@ -0,0 +1,165 @@ +import AIModel +import ComposableArchitecture +import Preferences +import Toast + +@Reducer +struct CompletionModelEdit { + @ObservableState + struct State: Equatable, Identifiable { + var id: String = "Custom" + var format: CompletionModel.Format + var maxTokens: Int = 4000 + var modelName: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } + var availableModelNames: [String] = [] + var suggestedMaxTokens: Int? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() + var ollamaKeepAlive: String = "" + var didSave = false + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case refreshAvailableModelNames + case checkSuggestedMaxTokens + case readCustomModelFromDisk + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some Reducer { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + state.didSave = false + return .run { send in + await send(.readCustomModelFromDisk) + } + + case .saveButtonClicked: + let modelName = state.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL = state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return .none + } + guard !baseURL.isEmpty else { + toast("Base URL cannot be empty", .error) + return .none + } + userDefaults.set(CompletionModel(state: state), for: \.customSuggestionCompletionModel) + state.didSave = true + return .none + + case .refreshAvailableModelNames: + if state.format == .openAI { + state.availableModelNames = KnownCompletionModels.allCases.map(\.rawValue) + } + + return .none + + case .readCustomModelFromDisk: + let model = userDefaults.value(for: \.customSuggestionCompletionModel) + state.id = model.id + state.format = model.format + state.maxTokens = model.info.maxTokens + state.modelName = model.info.modelName + state.apiKeySelection.apiKeyName = model.info.apiKeyName + state.baseURLSelection.baseURL = model.info.baseURL + state.baseURLSelection.isFullURL = model.info.isFullURL + state.ollamaKeepAlive = model.info.ollamaInfo.keepAlive + + return .run { send in + await send(.checkSuggestedMaxTokens) + await send(.refreshAvailableModelNames) + } + + case .checkSuggestedMaxTokens: + switch state.format { + case .openAI: + if let knownModel = KnownCompletionModels(rawValue: state.modelName) { + state.suggestedMaxTokens = knownModel.maxToken + } else { + state.suggestedMaxTokens = nil + } + return .none + default: + state.suggestedMaxTokens = nil + return .none + } + + case .apiKeySelection: + return .none + + case .baseURLSelection: + return .none + + case .binding(\.format): + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .binding(\.modelName): + return .run { send in + await send(.checkSuggestedMaxTokens) + } + + case .binding: + return .none + } + } + } + +} + +extension CompletionModel { + func toState() -> CompletionModelEdit.State { + .init( + id: id, + format: format, + maxTokens: info.maxTokens, + modelName: info.modelName, + apiKeySelection: .init( + apiKeyName: info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL), + ollamaKeepAlive: info.ollamaInfo.keepAlive + ) + } + + init(state: CompletionModelEdit.State) { + self.init( + id: state.id, + name: "Custom Model (Completion API)", + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + isFullURL: state.baseURLSelection.isFullURL, + maxTokens: state.maxTokens, + modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), + ollamaInfo: .init(keepAlive: state.ollamaKeepAlive) + ) + ) + } +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift new file mode 100644 index 00000000..90493387 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift @@ -0,0 +1,270 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +@MainActor +struct CompletionModelEditView: View { + @Perception.Bindable var store: StoreOf + + @Environment(\.dismiss) var dismiss + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + formatPicker + + switch store.format { + case .openAI: + openAI + case .azureOpenAI: + azureOpenAI + case .openAICompatible: + openAICompatible + case .ollama: + ollama + case .unknown: + EmptyView() + } + } + .padding() + + Divider() + + HStack { + Spacer() + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(action: { + store.send(.saveButtonClicked) + }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .onChange(of: store.didSave) { didSave in + if didSave { dismiss() } + } + .fixedSize(horizontal: false, vertical: true) + } + } + + var formatPicker: some View { + Picker( + selection: $store.format, + content: { + ForEach( + CompletionModel.Format.allCases, + id: \.rawValue + ) { format in + switch format { + case .openAI: + Text("OpenAI").tag(format) + case .azureOpenAI: + Text("Azure OpenAI").tag(format) + case .openAICompatible: + Text("OpenAI Compatible").tag(format) + case .ollama: + Text("Ollama").tag(format) + case .unknown: + EmptyView() + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + + func baseURLTextField( + title: LocalizedStringKey = "Base URL", + prompt: Text?, + @ViewBuilder trailingContent: @escaping () -> V + ) -> some View { + BaseURLTextField( + title: title, + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: \.baseURLSelection + ), + trailingContent: trailingContent + ) + } + + func baseURLTextField( + title: LocalizedStringKey = "Base URL", + prompt: Text? + ) -> some View { + baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) + } + + @ViewBuilder + var openAI: some View { + baseURLTextField(prompt: Text("https://api.openai.com")) { + Text("/v1/completions") + } + ApiKeyNamePicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if !store.availableModelNames.contains(store.modelName) { + Text("Custom Model").tag(store.modelName) + } + ForEach(store.availableModelNames, id: \.self) { model in + Text(model).tag(model) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField( + maxTokens: $store.maxTokens, + suggestedMaxTokens: store.suggestedMaxTokens + ) + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + ) + } + .padding(.vertical) + } + + @ViewBuilder + var azureOpenAI: some View { + baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) + ApiKeyNamePicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + + TextField("Deployment Name", text: $store.modelName) + + MaxTokensTextField( + maxTokens: $store.maxTokens, + suggestedMaxTokens: store.suggestedMaxTokens + ) + } + + @ViewBuilder + var openAICompatible: some View { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + baseURLTextField( + title: "", + prompt: store.baseURLSelection.isFullURL + ? Text("https://api.openai.com/v1/completions") + : Text("https://api.openai.com") + ) { + if !store.baseURLSelection.isFullURL { + Text("/v1/completions") + } + } + ApiKeyNamePicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField( + maxTokens: $store.maxTokens, + suggestedMaxTokens: store.suggestedMaxTokens + ) + } + + @ViewBuilder + var ollama: some View { + baseURLTextField( + title: "", + prompt: Text("https://127.0.0.1:11434/api/generate") + ) { + Text("/api/generate") + } + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField( + maxTokens: $store.maxTokens, + suggestedMaxTokens: store.suggestedMaxTokens + ) + + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { + Text("Keep Alive") + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://ollama.com](https://ollama.com)" + ) + } + .padding(.vertical) + } +} + +#Preview("OpenAI") { + CompletionModelEditView( + store: .init( + initialState: CompletionModel( + id: "3", + name: "Test Model 3", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ).toState(), + reducer: { CompletionModelEdit() } + ) + ) +} + +#Preview("OpenAI Compatible") { + CompletionModelEditView( + store: .init( + initialState: CompletionModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ).toState(), + reducer: { CompletionModelEdit() } + ) + ) +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestion.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestion.swift new file mode 100644 index 00000000..3beacab8 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestion.swift @@ -0,0 +1,35 @@ +import ComposableArchitecture +import Foundation + +/// Settings of the built-in custom model suggestion provider. +@Reducer +struct CustomModelSuggestion { + @ObservableState + struct State: Equatable { + var completionModel = CompletionModelEdit.State(format: .openAI) + var fimModel = FIMModelEdit.State(format: .mistral) + var tabbyModel = TabbyModelEdit.State( + authorizationMode: .none, + authorizationHeaderName: "", + username: "" + ) + } + + enum Action { + case completionModel(CompletionModelEdit.Action) + case fimModel(FIMModelEdit.Action) + case tabbyModel(TabbyModelEdit.Action) + } + + var body: some ReducerOf { + Scope(state: \.completionModel, action: \.completionModel) { + CompletionModelEdit() + } + Scope(state: \.fimModel, action: \.fimModel) { + FIMModelEdit() + } + Scope(state: \.tabbyModel, action: \.tabbyModel) { + TabbyModelEdit() + } + } +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift new file mode 100644 index 00000000..b3166af7 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift @@ -0,0 +1,325 @@ +import AIModel +import ComposableArchitecture +import Preferences +import SwiftUI + +struct CustomModelSuggestionView: View { + final class Settings: ObservableObject { + @AppStorage(\.customSuggestionModelId) var modelId + @AppStorage(\.customSuggestionVerboseLog) var verboseLog + @AppStorage(\.customSuggestionMaxLines) var maxLines + @AppStorage(\.customSuggestionMaxGenerationToken) var maxGenerationToken + } + + let store: StoreOf + @StateObject var settings = Settings() + @State var isEditingCustomModel = false + + var body: some View { + WithPerceptionTracking { + let modelType = CustomModelType(rawValue: settings.modelId) + Form { + Section { + HStack { + CustomModelPicker() + if modelType != nil { + Button("Edit Model") { + isEditingCustomModel = true + } + } + } + + switch modelType { + case .completionModel, nil: + RequestStrategyPicker() + case .tabby, .fimModel: + EmptyView() + } + + NumberInput(value: settings.$maxLines, range: 0...Int.max, step: 1) { + Text("Suggestion Line Limit (0 means stop words only)") + } + + NumberInput( + value: settings.$maxGenerationToken, + range: 0...Int.max, + step: 100 + ) { + Text("Suggestion Token Limit") + } + } header: { + Text("Custom Model") + } footer: { + Text( + "Generate suggestions with locally run or self-hosted models. Select \"Custom Model\" as the suggestion provider in Feature - Suggestion to use it." + ) + } + + if modelType == nil { + Section { + ChatAPIOptionsPicker() + } header: { + Text("Chat Model API") + } footer: { + Text( + "Applies to the selected chat model. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages. Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream." + ) + } + } + + Section { + Toggle( + "Verbose Log (writes prompts including your source code to Console.app)", + isOn: settings.$verboseLog + ) + } + } + .formStyle(.grouped) + .sheet(isPresented: $isEditingCustomModel) { + switch CustomModelType(rawValue: settings.modelId) { + case .completionModel: + CompletionModelEditView(store: store.scope( + state: \.completionModel, + action: \.completionModel + )) + .frame(width: 800) + case .fimModel: + FIMModelEditView(store: store.scope( + state: \.fimModel, + action: \.fimModel + )) + .frame(width: 800) + case .tabby: + TabbyModelEditView(store: store.scope( + state: \.tabbyModel, + action: \.tabbyModel + )) + .frame(width: 800) + case nil: + EmptyView() + } + } + } + } +} + +/// Picks either a built-in custom model type or one of the chat models configured in the host. +struct CustomModelPicker: View { + final class Settings: ObservableObject { + @AppStorage(\.chatModels) var chatModels: [ChatModel] + @AppStorage(\.customSuggestionModelId) var modelId: String + } + + @StateObject var settings = Settings() + + var body: some View { + let selectedChatModel = settings.chatModels.first { $0.id == settings.modelId } + let isUnsupportedGitHubCopilot = selectedChatModel?.format == .gitHubCopilot + let unknownId: String? = + if isUnsupportedGitHubCopilot { + settings.modelId + } else if !settings.modelId.isEmpty, + CustomModelType(rawValue: settings.modelId) == nil, + selectedChatModel == nil + { + settings.modelId + } else { + nil + } + + Picker(selection: settings.$modelId, label: Text("Model")) { + if let unknownId { + if isUnsupportedGitHubCopilot { + Text("GitHub Copilot (pick another model)").tag(unknownId) + } else { + Text("Unknown Model (Use Custom Model Instead)").tag(unknownId) + } + } + + ForEach(CustomModelType.allCases, id: \.rawValue) { + switch $0 { + case .completionModel: + Text("Custom Model (Completion API)").tag($0.rawValue) + case .fimModel: + Text("Custom Model (FIM API)").tag($0.rawValue) + case .tabby: + Text("Tabby").tag($0.rawValue) + } + } + + ForEach(settings.chatModels.filter { $0.format != .gitHubCopilot }, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } + } + } +} + +struct RequestStrategyPicker: View { + final class Settings: ObservableObject { + @AppStorage(\.customSuggestionRequestStrategyId) var requestStrategyId + @AppStorage(\.customSuggestionFIMTemplate) var fimTemplate + @AppStorage(\.customSuggestionFIMPromptIsRaw) var fimPromptIsRaw + @AppStorage(\.customSuggestionFIMStopToken) var fimStopToken + @AppStorage(\.customSuggestionFIMAttachFileInfo) var fimAttachFileInfo + } + + @StateObject var settings = Settings() + + var body: some View { + let option = RequestStrategyOption(rawValue: settings.requestStrategyId) + let unknownId: String? = option == nil ? settings.requestStrategyId : nil + + Picker(selection: settings.$requestStrategyId, label: Text("Request Strategy")) { + if let unknownId { + Text("Unknown Strategy (Use Default Strategy Instead)").tag(unknownId) + } + + ForEach(RequestStrategyOption.allCases, id: \.rawValue) { option in + switch option { + case .default: + Text("Default").tag(option.rawValue) + case .naive: + Text("Naive").tag(option.rawValue) + case .continue: + Text("Continue").tag(option.rawValue) + case .codeLlamaFillInTheMiddle: + Text("Fill-in-the-Middle (for models with FIM support, e.g. codellama:xb-code)") + .tag(option.rawValue) + case .codeLlamaFillInTheMiddleWithSystemPrompt: + Text("Fill-in-the-Middle with System Prompt").tag(option.rawValue) + case .anthropic: + Text("Anthropic Optimized").tag(option.rawValue) + } + } + } + + if option == .codeLlamaFillInTheMiddle + || option == .codeLlamaFillInTheMiddleWithSystemPrompt + { + TextField( + text: $settings.fimTemplate, + prompt: Text(UserDefaultPreferenceKeys().customSuggestionFIMTemplate.defaultValue) + ) { Text("FIM Template") } + Toggle(isOn: $settings.fimPromptIsRaw) { Text("Raw Prompt") } + Toggle(isOn: $settings.fimAttachFileInfo) { Text("Attach File Info") } + TextField(text: $settings.fimStopToken) { Text("FIM Stop Token") } + } + } +} + +struct ChatAPIOptionsPicker: View { + final class Settings: ObservableObject { + @AppStorage(\.customSuggestionModelId) var modelId + @AppStorage(\.chatModels) var chatModels: [ChatModel] + } + + @StateObject var settings = Settings() + + private var selectedModel: ChatModel? { + settings.chatModels.first { $0.id == settings.modelId } + } + + private var options: ChatModelAPIOptions { + ChatModelAPIOptions.stored(for: settings.modelId) + } + + private func update(_ mutate: (inout ChatModelAPIOptions) -> Void) { + var current = options + mutate(¤t) + ChatModelAPIOptions.update(current, for: settings.modelId) + settings.objectWillChange.send() + } + + var body: some View { + let format = selectedModel?.format + let currentOptions = options + let showsEndpoint = format == .openAI || format == .openAICompatible + let showsEffort = format == .openAI || format == .openAICompatible + || format == .claude || format == .azureOpenAI + let showsBudget = format == .openAI || format == .openAICompatible + || format == .azureOpenAI + + if showsEndpoint { + Picker( + selection: Binding( + get: { currentOptions.api.rawValue }, + set: { newValue in + update { $0.api = OpenAIChatAPI(rawValue: newValue) ?? .responses } + } + ), + label: Text("OpenAI Endpoint") + ) { + ForEach(OpenAIChatAPI.allCases, id: \.rawValue) { api in + switch api { + case .chatCompletions: + Text("Chat Completions (/v1/chat/completions)").tag(api.rawValue) + case .responses: + Text("Responses (/v1/responses)").tag(api.rawValue) + } + } + } + } + + if showsEffort { + Picker( + selection: Binding( + get: { currentOptions.reasoningEffort?.rawValue ?? "" }, + set: { newValue in + update { $0.reasoningEffort = ReasoningEffort(rawValue: newValue) } + } + ), + label: Text("Reasoning Effort") + ) { + Text("Not Set").tag("") + ForEach(ReasoningEffort.allCases, id: \.rawValue) { effort in + Text(effort.rawValue.capitalized).tag(effort.rawValue) + } + } + } + + if showsBudget, currentOptions.reasoningEffort != nil { + NumberInput( + value: Binding( + get: { currentOptions.reasoningTokenBudget }, + set: { newValue in + update { $0.reasoningTokenBudget = newValue } + } + ), + range: 0...Int.max, + step: 100 + ) { + Text("Reasoning Token Budget") + } + } + } +} + +private struct NumberInput: View { + @Binding var value: V + let formatter = NumberFormatter() + let range: ClosedRange + let step: V.Stride + @ViewBuilder var label: () -> Label + + var body: some View { + TextField(value: .init(get: { + min(max(value, range.lowerBound), range.upperBound) + }, set: { newValue in + value = min(max(newValue, range.lowerBound), range.upperBound) + }), formatter: formatter, prompt: nil) { + label() + } + .padding(.trailing) + .overlay(alignment: .trailing) { + Stepper(value: $value, in: range, step: step) { + EmptyView() + } + } + .padding(.trailing, 4) + } +} + +#Preview { + CustomModelSuggestionView(store: .init(initialState: .init(), reducer: { CustomModelSuggestion() })) + .frame(width: 800, height: 600) +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift new file mode 100644 index 00000000..05a6217a --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift @@ -0,0 +1,171 @@ +import AIModel +import ComposableArchitecture +import Preferences +import Toast + +@Reducer +struct FIMModelEdit { + @ObservableState + struct State: Equatable, Identifiable { + var id: String = "Custom" + var format: FIMModel.Format + var maxTokens: Int = 4000 + var modelName: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } + var availableModelNames: [String] = [] + var suggestedMaxTokens: Int? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() + var ollamaKeepAlive: String = "" + var authenticationMode: FIMModel.Info.AuthenticationMode = .bearerToken + var authenticationHeaderFieldName: String = "" + var didSave = false + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case refreshAvailableModelNames + case checkSuggestedMaxTokens + case readCustomModelFromDisk + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some Reducer { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: \.baseURLSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + state.didSave = false + return .run { send in + await send(.readCustomModelFromDisk) + } + + case .saveButtonClicked: + let modelName = state.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL = state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return .none + } + guard !baseURL.isEmpty else { + toast("Base URL cannot be empty", .error) + return .none + } + userDefaults.set(FIMModel(state: state), for: \.customSuggestionFIMModel) + state.didSave = true + return .none + + case .refreshAvailableModelNames: + if state.format == .mistral { + state.availableModelNames = [ + "codestral-latest", + "codestral-2405", + ] + } + + return .none + + case .readCustomModelFromDisk: + let model = userDefaults.value(for: \.customSuggestionFIMModel) + state.id = model.id + state.format = model.format + state.maxTokens = model.info.maxTokens + state.modelName = model.info.modelName + state.apiKeySelection.apiKeyName = model.info.apiKeyName + state.baseURLSelection.baseURL = model.info.baseURL + state.baseURLSelection.isFullURL = model.info.isFullURL + state.ollamaKeepAlive = model.info.ollamaInfo.keepAlive + state.authenticationMode = model.info.authenticationMode + state.authenticationHeaderFieldName = model.info.authenticationHeaderFieldName + + return .run { send in + await send(.checkSuggestedMaxTokens) + await send(.refreshAvailableModelNames) + } + + case .checkSuggestedMaxTokens: + switch state.format { + case .mistral: + return .none + default: + state.suggestedMaxTokens = nil + return .none + } + + case .apiKeySelection: + return .none + + case .baseURLSelection: + return .none + + case .binding(\.format): + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .binding(\.modelName): + return .run { send in + await send(.checkSuggestedMaxTokens) + } + + case .binding: + return .none + } + } + } + +} + +extension FIMModel { + func toState() -> FIMModelEdit.State { + .init( + id: id, + format: format, + maxTokens: info.maxTokens, + modelName: info.modelName, + apiKeySelection: .init( + apiKeyName: info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL), + ollamaKeepAlive: info.ollamaInfo.keepAlive, + authenticationMode: info.authenticationMode, + authenticationHeaderFieldName: info.authenticationHeaderFieldName + ) + } + + init(state: FIMModelEdit.State) { + self.init( + id: state.id, + name: "Custom Model (FIM API)", + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + isFullURL: state.baseURLSelection.isFullURL, + maxTokens: state.maxTokens, + modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), + authenticationMode: state.authenticationMode, + authenticationHeaderFieldName: state.authenticationHeaderFieldName, + ollamaInfo: .init(keepAlive: state.ollamaKeepAlive) + ) + ) + } +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift new file mode 100644 index 00000000..3b39147a --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift @@ -0,0 +1,277 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +@MainActor +struct FIMModelEditView: View { + @Perception.Bindable var store: StoreOf + + @Environment(\.dismiss) var dismiss + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + formatPicker + + switch store.format { + case .mistral: + mistralForm + case .ollama: + ollama + case .ollamaCompatible: + ollamaCompatible + case .unknown: + EmptyView() + } + } + .padding() + + Divider() + + HStack { + Spacer() + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(action: { + store.send(.saveButtonClicked) + }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .onChange(of: store.didSave) { didSave in + if didSave { dismiss() } + } + .fixedSize(horizontal: false, vertical: true) + } + } + + var formatPicker: some View { + Picker( + selection: $store.format, + content: { + ForEach( + FIMModel.Format.allCases, + id: \.rawValue + ) { format in + switch format { + case .mistral: + Text("Mistral").tag(format) + case .ollama: + Text("Ollama").tag(format) + case .ollamaCompatible: + Text("Ollama Compatible").tag(format) + case .unknown: + EmptyView() + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + + func baseURLTextField( + title: LocalizedStringKey = "Base URL", + prompt: Text?, + @ViewBuilder trailingContent: @escaping () -> V + ) -> some View { + BaseURLTextField( + title: title, + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: \.baseURLSelection + ), + trailingContent: trailingContent + ) + } + + func baseURLTextField( + title: LocalizedStringKey = "Base URL", + prompt: Text? + ) -> some View { + baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() }) + } + + @ViewBuilder + var mistralForm: some View { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + baseURLTextField( + title: "", + prompt: store.baseURLSelection.isFullURL + ? Text("https://api.mistral.ai/v1/fim/completions") + : Text("https://api.mistral.ai") + ) { + if !store.baseURLSelection.isFullURL { + Text("/v1/fim/completions") + } + } + ApiKeyNamePicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if !store.availableModelNames.contains(store.modelName) { + Text("Custom Model").tag(store.modelName) + } + ForEach(store.availableModelNames, id: \.self) { model in + Text(model).tag(model) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField( + maxTokens: $store.maxTokens, + suggestedMaxTokens: store.suggestedMaxTokens + ) + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + """ + Compatible with: + - Mistral FIM API + - DeepSeek FIM API + + or any API that takes a prompt and a suffix and responds with + an OpenAI-compatible streaming response, using bearer token authentication. + """ + ) + } + .padding(.vertical) + } + + @ViewBuilder + var ollama: some View { + baseURLTextField( + title: "", + prompt: Text("https://127.0.0.1:11434") + ) { + Text("/api/generate") + } + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField( + maxTokens: $store.maxTokens, + suggestedMaxTokens: store.suggestedMaxTokens + ) + + TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) { + Text("Keep Alive") + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://ollama.com](https://ollama.com)" + ) + } + .padding(.vertical) + } + + @ViewBuilder + var ollamaCompatible: some View { + Picker( + selection: $store.baseURLSelection.isFullURL, + content: { + Text("Base URL").tag(false) + Text("Full URL").tag(true) + }, + label: { Text("URL") } + ) + .pickerStyle(.segmented) + + baseURLTextField( + title: "", + prompt: store.baseURLSelection.isFullURL + ? Text("https://127.0.0.1:11434/api/generate") + : Text("https://127.0.0.1:11434") + ) { + if !store.baseURLSelection.isFullURL { + Text("/api/generate") + } + } + + Picker( + selection: $store.authenticationMode, + content: { + Text("Bearer Token").tag(FIMModel.Info.AuthenticationMode.bearerToken) + Text("Header Field").tag(FIMModel.Info.AuthenticationMode.header) + }, + label: { Text("Authentication Mode") } + ) + .pickerStyle(.segmented) + + if store.authenticationMode == .header { + TextField("Header Field Name", text: $store.authenticationHeaderFieldName) + } + + ApiKeyNamePicker(store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + )) + + TextField("Model Name", text: $store.modelName) + + MaxTokensTextField( + maxTokens: $store.maxTokens, + suggestedMaxTokens: store.suggestedMaxTokens + ) + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " Use with any API that has the same format as Ollama. For more details, please visit [https://ollama.com](https://ollama.com)" + ) + } + .padding(.vertical) + } +} + +#Preview("Mistral") { + FIMModelEditView( + store: .init( + initialState: FIMModel( + id: "3", + name: "Test Model 3", + format: .mistral, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ).toState(), + reducer: { FIMModelEdit() } + ) + ) +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift new file mode 100644 index 00000000..8fa1a668 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift @@ -0,0 +1,84 @@ +import ComposableArchitecture +import SwiftUI + +struct MaxTokensTextField: View { + @Binding var maxTokens: Int + var suggestedMaxTokens: Int? + + var body: some View { + HStack { + let textFieldBinding = Binding( + get: { String(maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + maxTokens = selectionMaxToken + } else { + maxTokens = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Context Window") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: $maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = suggestedMaxTokens else { + return .primary + } + if maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = suggestedMaxTokens { + Text("Max: \(max)") + } + } + } +} + +struct BaseURLTextField: View { + var title: LocalizedStringKey = "Base URL" + var prompt: Text? + let store: StoreOf + @ViewBuilder var trailingContent: () -> TrailingContent + + var body: some View { + BaseURLPicker( + title: title, + prompt: prompt, + store: store, + trailingContent: trailingContent + ) + } +} + +extension BaseURLTextField where TrailingContent == EmptyView { + init( + title: LocalizedStringKey = "Base URL", + prompt: Text?, + store: StoreOf + ) { + self.init(title: title, prompt: prompt, store: store, trailingContent: { EmptyView() }) + } +} + +struct ApiKeyNamePicker: View { + let store: StoreOf + var title: LocalizedStringKey = "API Key" + + var body: some View { + APIKeyPicker(store: store, title: title) + } +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift new file mode 100644 index 00000000..b6285c7e --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift @@ -0,0 +1,108 @@ +import AIModel +import ComposableArchitecture +import Foundation +import Preferences +import Toast + +@Reducer +struct TabbyModelEdit { + @ObservableState + struct State: Equatable { + var apiKeyName: String { apiKeySelection.apiKeyName } + var url: String { urlSelection.baseURL } + var apiKeySelection: APIKeySelection.State = .init() + var urlSelection: BaseURLSelection.State = .init() + var authorizationMode: TabbyModel.AuthorizationMode + var authorizationHeaderName: String + var username: String + var didSave = false + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case readCustomModelFromDisk + case apiKeySelection(APIKeySelection.Action) + case urlSelection(BaseURLSelection.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some Reducer { + BindingReducer() + + Scope(state: \.apiKeySelection, action: \.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.urlSelection, action: \.urlSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + state.didSave = false + return .run { send in + await send(.readCustomModelFromDisk) + } + + case .saveButtonClicked: + let urlString = state.url.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: urlString), url.scheme != nil, url.host != nil else { + toast("URL is invalid", .error) + return .none + } + userDefaults.set(TabbyModel(state: state), for: \.customSuggestionTabbyModel) + state.didSave = true + return .none + + case .readCustomModelFromDisk: + let model = userDefaults.value(for: \.customSuggestionTabbyModel) + state.apiKeySelection.apiKeyName = model.apiKeyName + state.urlSelection.baseURL = model.url + state.authorizationMode = model.authorizationMode + state.authorizationHeaderName = model.authorizationHeaderName + state.username = model.username + return .none + + case .apiKeySelection: + return .none + + case .urlSelection: + return .none + + case .binding: + return .none + } + } + } + +} + +extension TabbyModel { + func toState() -> TabbyModelEdit.State { + .init( + apiKeySelection: .init( + apiKeyName: apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [apiKeyName]) + ), + urlSelection: .init(baseURL: url), + authorizationMode: authorizationMode, + authorizationHeaderName: authorizationHeaderName, + username: username + ) + } + + init(state: TabbyModelEdit.State) { + self = .init( + url: state.urlSelection.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + authorizationMode: state.authorizationMode, + apiKeyName: state.apiKeySelection.apiKeyName, + authorizationHeaderName: state.authorizationHeaderName, + username: state.username + ) + } +} diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift new file mode 100644 index 00000000..4492cfcb --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift @@ -0,0 +1,167 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +@MainActor +struct TabbyModelEditView: View { + @Perception.Bindable var store: StoreOf + + @Environment(\.dismiss) var dismiss + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Form { + form + } + .padding() + + Divider() + + HStack { + Spacer() + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(action: { + store.send(.saveButtonClicked) + }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + .onChange(of: store.didSave) { didSave in + if didSave { dismiss() } + } + .fixedSize(horizontal: false, vertical: true) + } + } + + var authorizationModePicker: some View { + Picker( + selection: $store.authorizationMode, + content: { + ForEach( + TabbyModel.AuthorizationMode.allCases, + id: \.rawValue + ) { format in + switch format { + case .none: + Text("None").tag(format) + case .bearerToken: + Text("Bearer Token").tag(format) + case .basic: + Text("Basic").tag(format) + case .customHeaderField: + Text("Custom Header Field").tag(format) + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + + @ViewBuilder + var form: some View { + BaseURLTextField( + title: "URL", + prompt: Text("http://127.0.0.1:8080/v1/completions"), + store: store.scope( + state: \.urlSelection, + action: \.urlSelection + ) + ) + authorizationModePicker + + switch store.authorizationMode { + case .none: + EmptyView() + case .basic: + TextField("Username", text: $store.username) + ApiKeyNamePicker( + store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + ), + title: "Password" + ) + case .customHeaderField: + TextField("Header Name", text: $store.authorizationHeaderName) + ApiKeyNamePicker( + store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + ), + title: "Value" + ) + case .bearerToken: + ApiKeyNamePicker( + store: store.scope( + state: \.apiKeySelection, + action: \.apiKeySelection + ), + title: "Token" + ) + } + } +} + +#Preview("No Authorization") { + TabbyModelEditView( + store: .init( + initialState: TabbyModel( + url: "http://127.0.0.1", authorizationMode: .none, apiKeyName: "", + authorizationHeaderName: "Key", username: "User" + ).toState(), + reducer: { TabbyModelEdit() } + ) + ) +} + +#Preview("Bearer Token") { + TabbyModelEditView( + store: .init( + initialState: TabbyModel( + url: "http://127.0.0.1", authorizationMode: .bearerToken, apiKeyName: "", + authorizationHeaderName: "Key", username: "User" + ).toState(), + reducer: { TabbyModelEdit() } + ) + ) +} + +#Preview("Custom Header Field") { + TabbyModelEditView( + store: .init( + initialState: TabbyModel( + url: "http://127.0.0.1", authorizationMode: .customHeaderField, apiKeyName: "", + authorizationHeaderName: "Key", username: "User" + ).toState(), + reducer: { TabbyModelEdit() } + ) + ) +} + +#Preview("Basic") { + TabbyModelEditView( + store: .init( + initialState: TabbyModel( + url: "http://127.0.0.1", authorizationMode: .basic, apiKeyName: "", + authorizationHeaderName: "Key", username: "User" + ).toState(), + reducer: { TabbyModelEdit() } + ) + ) +} diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index 46f4effd..720bd45a 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -119,7 +119,7 @@ struct EmbeddingModelEditView: View { struct BaseURLTextField: View { let store: StoreOf - var title: String = "Base URL" + var title: LocalizedStringKey = "Base URL" let prompt: Text? @ViewBuilder var trailingContent: () -> V diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift index 9f4b0d8d..257a31bc 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift @@ -30,13 +30,13 @@ public struct GitHubCopilotModelPicker: View { } } - let title: String + let title: LocalizedStringKey let hasDefaultModel: Bool @Binding var gitHubCopilotModelId: String @State var viewModel: ViewModel init( - title: String, + title: LocalizedStringKey, hasDefaultModel: Bool = true, gitHubCopilotModelId: Binding ) { diff --git a/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift b/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift deleted file mode 100644 index 2497c9b5..00000000 --- a/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import SwiftUI - -struct OtherSuggestionServicesView: View { - @Environment(\.openURL) var openURL - var body: some View { - VStack(alignment: .leading) { - Text( - "You can use other locally run services (Tabby, Ollma, etc.) to generate suggestions with the Custom Suggestion Service extension." - ) - .lineLimit(nil) - .multilineTextAlignment(.leading) - - Button(action: { - if let url = URL( - string: "https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode" - ) { - openURL(url) - } - }) { - Text("Get It Now") - } - } - } -} - -#Preview { - OtherSuggestionServicesView() - .frame(width: 200) -} - diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift index 9456946e..00a7803c 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift @@ -2,7 +2,7 @@ import ComposableArchitecture import SwiftUI struct BaseURLPicker: View { - let title: String + let title: LocalizedStringKey let prompt: Text? @Perception.Bindable var store: StoreOf @ViewBuilder let trailingContent: () -> TrailingContent @@ -45,7 +45,7 @@ struct BaseURLPicker: View { extension BaseURLPicker where TrailingContent == EmptyView { init( - title: String, + title: LocalizedStringKey, prompt: Text? = nil, store: StoreOf ) { diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift index 390c7f98..21cac492 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift @@ -143,6 +143,9 @@ struct SuggestionSettingsGeneralSectionView: View { case .codeium: Text("Codeium") .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0)) + case .customModel: + Text("Custom Model") + .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0)) } } diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index e8c1e38f..5dd93597 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -52,8 +52,8 @@ struct FeatureSettingsView: View { } .sidebarItem( tag: 4 + index, - title: tab.title, - subtitle: tab.description, + title: LocalizedStringKey(tab.title), + subtitle: LocalizedStringKey(tab.description), image: tab.image ) } diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 96ade16c..49592392 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -43,6 +43,7 @@ struct General { @Dependency(\.toast) var toast struct ReloadStatusCancellableId: Hashable {} + struct SetupLaunchAgentCancellableId: Hashable {} static var didWarnInstallationPosition: Bool { get { UserDefaults.standard.bool(forKey: "didWarnInstallationPosition") } @@ -53,11 +54,19 @@ struct General { Bundle.main.bundleURL.path.hasPrefix("/Applications") } + static var isDebugBuild: Bool { + #if DEBUG + true + #else + false + #endif + } + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: - if Self.bundleIsInApplicationsFolder { + if Self.bundleIsInApplicationsFolder || Self.isDebugBuild { return .run { send in await send(.setupLaunchAgentIfNeeded) } @@ -85,18 +94,15 @@ struct General { case .setupLaunchAgentIfNeeded: return .run { send in - #if DEBUG - // do not auto install on debug build - #else do { try await LaunchAgentManager() .setupLaunchAgentForTheFirstTimeIfNeeded() + } catch is CancellationError { } catch { toast(error.localizedDescription, .error) } - #endif await send(.reloadStatus) - } + }.cancellable(id: SetupLaunchAgentCancellableId(), cancelInFlight: true) case .setupLaunchAgentClicked: if Self.bundleIsInApplicationsFolder { diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index b69c0127..feebf7ec 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -5,6 +5,7 @@ import LaunchAgentManager import Preferences import SharedUIComponents import SwiftUI +import Toast struct GeneralView: View { let store: StoreOf @@ -101,12 +102,14 @@ struct ExtensionServiceView: View { var body: some View { WithPerceptionTracking { VStack(alignment: .leading) { - Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")") + Text("Extension Service Version: \(store.xpcServiceVersion ?? String(localized: "Loading.."))") let grantedStatus: String = { guard let granted = store.isAccessibilityPermissionGranted - else { return "Loading.." } - return granted ? "Granted" : "Not Granted" + else { return String(localized: "Loading..") } + return granted + ? String(localized: "Granted") + : String(localized: "Not Granted") }() Text("Accessibility Permission: \(grantedStatus)") @@ -200,14 +203,33 @@ struct GeneralSettingsView: View { var showHideWidgetShortcutGlobally @AppStorage(\.installBetaBuilds) var installBetaBuilds + @AppStorage(\.appLanguage) + var appLanguage } @StateObject var settings = Settings() @Environment(\.updateChecker) var updateChecker + @Environment(\.toast) var toast @State var automaticallyCheckForUpdate: Bool? var body: some View { Form { + Picker(selection: $settings.appLanguage) { + ForEach(AppLanguage.allCases, id: \.rawValue) { language in + Text(language.displayName).tag(language.rawValue) + } + } label: { + Text("Language") + } + .onChange(of: settings.appLanguage) { _ in + // The running processes keep the localization they resolved at launch. + AppLanguage.applyPreferredLanguage() + toast( + String(localized: "Restart the app and the extension service to switch language."), + .info + ) + } + Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) { Text("Quit service when Xcode and host app are terminated") } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index f2b90303..312d7b70 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -19,6 +19,7 @@ struct HostApp { var chatModelManagement = ChatModelManagement.State() var embeddingModelManagement = EmbeddingModelManagement.State() var webSearchSettings = WebSearchSettings.State() + var customModelSuggestion = CustomModelSuggestion.State() } enum Action { @@ -27,6 +28,7 @@ struct HostApp { case chatModelManagement(ChatModelManagement.Action) case embeddingModelManagement(EmbeddingModelManagement.Action) case webSearchSettings(WebSearchSettings.Action) + case customModelSuggestion(CustomModelSuggestion.Action) } @Dependency(\.toast) var toast @@ -52,6 +54,10 @@ struct HostApp { WebSearchSettings() } + Scope(state: \.customModelSuggestion, action: \.customModelSuggestion) { + CustomModelSuggestion() + } + Reduce { _, action in switch action { case .appear: @@ -71,6 +77,9 @@ struct HostApp { case .webSearchSettings: return .none + + case .customModelSuggestion: + return .none } } } diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index bf81eb51..112783ec 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -58,10 +58,13 @@ struct ServiceView: View { ) ScrollView { - OtherSuggestionServicesView().padding() + CustomModelSuggestionView(store: store.scope( + state: \.customModelSuggestion, + action: \.customModelSuggestion + )) }.sidebarItem( tag: 5, - title: "Other Suggestion Services", + title: "Custom Model", subtitle: "Suggestion", image: "globe" ) diff --git a/Core/Sources/HostApp/SidebarTabView.swift b/Core/Sources/HostApp/SidebarTabView.swift index ae5b009e..5a6f842b 100644 --- a/Core/Sources/HostApp/SidebarTabView.swift +++ b/Core/Sources/HostApp/SidebarTabView.swift @@ -3,8 +3,8 @@ import SwiftUI private struct SidebarItem: Identifiable, Equatable { var id: Int { tag } var tag: Int - var title: String - var subtitle: String? = nil + var title: LocalizedStringKey + var subtitle: LocalizedStringKey? = nil var image: String? = nil } @@ -29,8 +29,8 @@ private extension EnvironmentValues { private struct SidebarTabViewWrapper: View { @Environment(\.sidebarTabTag) var sidebarTabTag var tag: Int - var title: String - var subtitle: String? = nil + var title: LocalizedStringKey + var subtitle: LocalizedStringKey? = nil var image: String? = nil var content: () -> Content @@ -52,8 +52,8 @@ private struct SidebarTabViewWrapper: View { extension View { func sidebarItem( tag: Int, - title: String, - subtitle: String? = nil, + title: LocalizedStringKey, + subtitle: LocalizedStringKey? = nil, image: String? = nil ) -> some View { SidebarTabViewWrapper( diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 8616b5af..ade8d803 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -65,7 +65,7 @@ public struct TabContainer: View { let tab = externalTabContainer.tabs[index] tab.viewBuilder().tabBarItem( tag: 5 + index, - title: tab.title, + title: LocalizedStringKey(tab.title), image: "plus.diamond" ) } @@ -116,7 +116,7 @@ struct TabBarButton: View { @Binding var currentTag: Int @State var isHovered = false var tag: Int - var title: String + var title: LocalizedStringKey var image: String var body: some View { @@ -157,7 +157,7 @@ struct TabBarButton: View { private struct TabBarTabViewWrapper: View { @Environment(\.tabBarTabTag) var tabBarTabTag var tag: Int - var title: String + var title: LocalizedStringKey var image: String var content: () -> Content @@ -179,7 +179,7 @@ private struct TabBarTabViewWrapper: View { private extension View { func tabBarItem( tag: Int, - title: String, + title: LocalizedStringKey, image: String ) -> some View { TabBarTabViewWrapper( @@ -194,7 +194,7 @@ private extension View { private struct TabBarItem: Identifiable, Equatable { var id: Int { tag } var tag: Int - var title: String + var title: LocalizedStringKey var image: String } diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index 9c81038f..4e9c5039 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -176,7 +176,7 @@ final class TabToAcceptSuggestion { Logger.service.info("TabToAcceptSuggestion: Xcode not found") return .unchanged } - guard let editor = ThreadSafeAccessToXcodeInspector.shared.focusedEditor + guard ThreadSafeAccessToXcodeInspector.shared.focusedEditor != nil else { Logger.service.info("TabToAcceptSuggestion: No editor found") return .unchanged @@ -194,12 +194,7 @@ final class TabToAcceptSuggestion { return .unchanged } - let editorContent = editor.getContent() - let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion( - lines: editorContent.lines, - cursorPosition: editorContent.cursorPosition, - codeMetadata: filespace.codeMetadata, presentingSuggestionText: presentingSuggestion.text ) @@ -243,39 +238,14 @@ final class TabToAcceptSuggestion { } extension TabToAcceptSuggestion { - static func checkIfAcceptSuggestion( - lines: [String], - cursorPosition: CursorPosition, - codeMetadata: FilespaceCodeMetadata, - presentingSuggestionText: String - ) -> Bool { - let line = cursorPosition.line - guard line >= 0, line < lines.endIndex else { - return true - } - let col = cursorPosition.character - let prefixEndIndex = lines[line].utf16.index( - lines[line].utf16.startIndex, - offsetBy: col, - limitedBy: lines[line].utf16.endIndex - ) ?? lines[line].utf16.endIndex - let prefix = String(lines[line][.. Bool { + !presentingSuggestionText.isEmpty } } diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift new file mode 100644 index 00000000..fcb77c9a --- /dev/null +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift @@ -0,0 +1,182 @@ +import Foundation +#if DEBUG +import os +#endif + +public enum DebugLaunchAgentPlist { + public static func xmlData( + serviceIdentifier: String, + programPath: String, + bundleIdentifier: String + ) throws -> Data { + let plist: [String: Any] = [ + "Label": serviceIdentifier, + "Program": programPath, + "MachServices": [serviceIdentifier: true], + "AssociatedBundleIdentifiers": [bundleIdentifier, serviceIdentifier], + ] + return try PropertyListSerialization.data( + fromPropertyList: plist, + format: .xml, + options: 0 + ) + } + + public static func propertyListsEqual(_ lhs: Data, _ rhs: Data) -> Bool { + func object(_ data: Data) -> NSObject? { + (try? PropertyListSerialization.propertyList(from: data, options: [], format: nil)) as? NSObject + } + guard let left = object(lhs), let right = object(rhs) else { return false } + return left == right + } +} + +#if DEBUG +/// Debug builds run straight from DerivedData, so the release flow (`SMAppService` with the +/// plist bundled for /Applications) can't be used. Upstream expects developers to run every +/// target from Xcode by hand; this fork instead registers the bridge automatically so a plain +/// "Run" of the host app brings the whole service chain up. +extension LaunchAgentManager { + static var didRunInThisProcess = false + + private static let logger = os.Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.intii.CopilotForXcode", + category: "LaunchAgentManager" + ) + + /// Modification date of the bridge executable the last time launchd (re)started it. + private static let bridgeModificationDateKey = "DebugBuild.LaunchAgentBridgeModificationDate" + + /// Idempotent, safe to call on every launch: + /// 1. writes `~/Library/LaunchAgents/.plist`, replacing it when the + /// executable path changed (for example a new DerivedData location); + /// 2. bootstraps it into the user's launchd domain when it isn't loaded; + /// 3. restarts the bridge when its binary was rebuilt since the last restart, so the freshly + /// built code is the one serving XPC requests. + /// Automatic `.appear` path: register at most once per host process. + public func setupLaunchAgentForDebugBuildIfNeeded() async throws { + if Self.didRunInThisProcess { return } + Self.didRunInThisProcess = true + do { + try await setupLaunchAgentForDebugBuild() + } catch { + Self.didRunInThisProcess = false + throw error + } + } + + /// Explicit Setup button: always re-register. + public func setupLaunchAgentForDebugBuild() async throws { + try await registerDebugLaunchAgentIfNeeded() + Self.didRunInThisProcess = true + } + + private func registerDebugLaunchAgentIfNeeded() async throws { + let plist = try DebugLaunchAgentPlist.xmlData( + serviceIdentifier: serviceIdentifier, + programPath: executableURL.path, + bundleIdentifier: bundleIdentifier + ) + let existing = try? Data(contentsOf: URL(fileURLWithPath: launchAgentPath)) + let plistChanged = existing.map { !DebugLaunchAgentPlist.propertyListsEqual($0, plist) } ?? true + let isLoaded = await isDebugLaunchAgentLoaded() + let bridgeModificationDate = try? FileManager.default + .attributesOfItem(atPath: executableURL.path)[.modificationDate] as? Date + let defaults = UserDefaults.standard + let lastBridgeModificationDate = defaults + .object(forKey: Self.bridgeModificationDateKey) as? Date + + if plistChanged { + if isLoaded { + try await debugLaunchctl("bootout", serviceTarget) + await waitUntilDebugLaunchAgentUnloaded() + } + try writeDebugPlist(plist) + try await debugLaunchctl("bootstrap", domainTarget, launchAgentPath) + Self.logger.info( + "Registered \(serviceIdentifier, privacy: .public) -> \(executableURL.path, privacy: .public)" + ) + } else if !isLoaded { + try await debugLaunchctl("bootstrap", domainTarget, launchAgentPath) + Self.logger.info("Bootstrapped \(serviceIdentifier, privacy: .public)") + } else if let bridgeModificationDate, bridgeModificationDate != lastBridgeModificationDate { + try await debugLaunchctl("kickstart", "-k", serviceTarget) + Self.logger.info("Restarted the bridge because its executable was rebuilt") + } + + defaults.set(bridgeModificationDate, forKey: Self.bridgeModificationDateKey) + } +} + +private extension LaunchAgentManager { + var domainTarget: String { "gui/\(getuid())" } + var serviceTarget: String { "\(domainTarget)/\(serviceIdentifier)" } + + func writeDebugPlist(_ data: Data) throws { + try FileManager.default.createDirectory( + at: launchAgentDirURL, + withIntermediateDirectories: true + ) + try data.write(to: URL(fileURLWithPath: launchAgentPath), options: .atomic) + } + + func isDebugLaunchAgentLoaded() async -> Bool { + (try? await debugLaunchctl("print", serviceTarget)) != nil + } + + func waitUntilDebugLaunchAgentUnloaded() async { + for _ in 0..<20 { + if await !isDebugLaunchAgentLoaded() { return } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + @discardableResult + func debugLaunchctl(_ arguments: String...) async throws -> String { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = arguments + task.environment = ["PATH": "/usr/bin:/bin"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + return try await withCheckedThrowingContinuation { continuation in + task.terminationHandler = { process in + let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data() + let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if process.terminationStatus == 0 { + continuation.resume(returning: output) + } else { + continuation.resume(throwing: DebugLaunchAgentError( + command: "launchctl " + arguments.joined(separator: " "), + status: process.terminationStatus, + output: output + )) + } + } + do { + try task.run() + } catch { + continuation.resume(throwing: error) + } + } + } +} + +private struct DebugLaunchAgentError: Error, LocalizedError { + let command: String + let status: Int32 + let output: String + + var errorDescription: String? { + var message = "\(command) failed (\(status))" + if !output.isEmpty { message += ": \(output)" } + if output.contains("Input/output error") || output.contains("disabled") { + message += ". If the login item was disabled, enable it in System Settings › General › Login Items & Extensions." + } + return message + } +} +#endif diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index 3caf179b..7e713a8e 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -23,6 +23,9 @@ public struct LaunchAgentManager { } public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { + #if DEBUG + try await setupLaunchAgentForDebugBuildIfNeeded() + #else if #available(macOS 13, *) { try await setupLaunchAgent() } else { @@ -33,9 +36,13 @@ public struct LaunchAgentManager { guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } try await setupLaunchAgent() } + #endif } public func setupLaunchAgent() async throws { + #if DEBUG + try await setupLaunchAgentForDebugBuild() + #else if #available(macOS 13, *) { if executableURL.path.hasPrefix("/Applications") { try setupLaunchAgentWithPredefinedPlist() @@ -49,9 +56,13 @@ public struct LaunchAgentManager { let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) .flatMap(Int.init) UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey) + #endif } public func removeLaunchAgent() async throws { + #if DEBUG + Self.didRunInThisProcess = false + #endif if #available(macOS 13, *) { let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") try? await bridgeLaunchAgent.unregister() diff --git a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift index ce45eaf8..a0eadc4b 100644 --- a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift +++ b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift @@ -23,6 +23,8 @@ public func withFeatureEnabled( ) rethrows { #if canImport(LicenseManagement) try LicenseManagement.withFeatureEnabled(flag, then: then) + #else + try then() #endif } @@ -32,6 +34,8 @@ public func withFeatureEnabled( ) async rethrows { #if canImport(LicenseManagement) try await LicenseManagement.withFeatureEnabled(flag, then: then) + #else + try await then() #endif } @@ -39,7 +43,10 @@ public func isFeatureAvailable(_ flag: KeyPath AsyncThrowingStream<[SuggestionBasic.CodeSuggestion], Error> { + guard let streamingProvider = suggestionProvider as? StreamingSuggestionServiceProvider + else { + return SuggestionStreams.single { + try await self.getSuggestions(request, workspaceInfo: workspaceInfo) + } + } + + let configuration = await configuration + let middlewares = middlewares + let upstream = await streamingProvider.streamSuggestions( + request, + workspaceInfo: workspaceInfo + ) + return SuggestionStreams.forward(upstream, mapError: Self.wrapAsSilentIfNeeded) { partial in + try await Self.applyMiddlewares( + middlewares, + to: partial, + request: request, + configuration: configuration + ) + } + } + + /// Same rule as the blocking path: unknown errors are shown nowhere but the log. + static func wrapAsSilentIfNeeded(_ error: Error) -> Error { + if error is CancellationError { return error } + if error is SuggestionServiceError { return error } + return SuggestionServiceError.silent(error) + } + + /// Runs `suggestions` through the middleware chain as if the provider had returned them. + static func applyMiddlewares( + _ middlewares: [Middleware], + to suggestions: [SuggestionBasic.CodeSuggestion], + request: SuggestionRequest, + configuration: SuggestionProvider.SuggestionServiceConfiguration + ) async throws -> [SuggestionBasic.CodeSuggestion] { + var next: (SuggestionRequest) async throws -> [SuggestionBasic.CodeSuggestion] = { _ in + suggestions + } + for middleware in middlewares.reversed() { + let inner = next + next = { request in + try await middleware.getSuggestion( + request, + configuration: configuration, + next: inner + ) + } + } + return try await next(request) + } +} diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 335f0c83..c898f792 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,5 +1,6 @@ import BuiltinExtension import CodeiumService +import CustomSuggestionService import enum CopilotForXcodeKit.SuggestionServiceError import struct CopilotForXcodeKit.WorkspaceInfo import Foundation @@ -54,6 +55,11 @@ public actor SuggestionService: SuggestionServiceType { extension: CodeiumExtension.self ) return SuggestionService(provider: provider) + case .builtIn(.customModel): + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: CustomModelExtension.self + ) + return SuggestionService(provider: provider) case .builtIn(.gitHubCopilot), .extension: let provider = BuiltinExtensionSuggestionServiceProvider( extension: GitHubCopilotExtension.self diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index ef3b560c..4a9849ec 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -1082,7 +1082,7 @@ extension PromptToCodePanelView { #Preview("Multiple Snippets") { PromptToCodePanelView(store: .init(initialState: .init( - promptToCodeState: Shared(ModificationState( + promptToCodeState: Shared(value: ModificationState( source: .init( language: CodeLanguage.builtIn(.swift), documentURL: URL( @@ -1164,7 +1164,7 @@ extension PromptToCodePanelView { #Preview("Detached With Long File Name") { PromptToCodePanelView(store: .init(initialState: .init( - promptToCodeState: Shared(ModificationState( + promptToCodeState: Shared(value: ModificationState( source: .init( language: CodeLanguage.builtIn(.swift), documentURL: URL( @@ -1219,7 +1219,7 @@ extension PromptToCodePanelView { #Preview("Generating") { PromptToCodePanelView(store: .init(initialState: .init( - promptToCodeState: Shared(ModificationState( + promptToCodeState: Shared(value: ModificationState( source: .init( language: CodeLanguage.builtIn(.swift), documentURL: URL( diff --git a/Core/Tests/HostAppTests/CompletionModelEditTests.swift b/Core/Tests/HostAppTests/CompletionModelEditTests.swift new file mode 100644 index 00000000..ca329044 --- /dev/null +++ b/Core/Tests/HostAppTests/CompletionModelEditTests.swift @@ -0,0 +1,97 @@ +import AIModel +import ComposableArchitecture +import Foundation +import Preferences +import Toast +import XCTest + +@testable import HostApp + +@MainActor +final class CompletionModelEditTests: XCTestCase { + func test_saveButtonClicked_emptyModelName_doesNotWrite() async { + let (defaults, suite) = isolatedDefaults() + defer { defaults.removePersistentDomain(forName: suite) } + + let store = TestStore( + initialState: CompletionModelEdit.State( + format: .openAI, + modelName: " ", + baseURLSelection: .init(baseURL: "https://api.openai.com") + ) + ) { + CompletionModelEdit() + } withDependencies: { + $0.userDefaults = defaults + $0.toastController = ToastController(messages: []) + } + + await store.send(.saveButtonClicked) + XCTAssertNil( + defaults.value(forKey: UserDefaultPreferenceKeys().customSuggestionCompletionModel.key) + ) + XCTAssertFalse(store.state.didSave) + } + + func test_saveButtonClicked_emptyBaseURL_doesNotWrite() async { + let (defaults, suite) = isolatedDefaults() + defer { defaults.removePersistentDomain(forName: suite) } + + let store = TestStore( + initialState: CompletionModelEdit.State( + format: .openAI, + modelName: "gpt-3.5-turbo-instruct", + baseURLSelection: .init(baseURL: "") + ) + ) { + CompletionModelEdit() + } withDependencies: { + $0.userDefaults = defaults + $0.toastController = ToastController(messages: []) + } + + await store.send(.saveButtonClicked) + XCTAssertNil( + defaults.value(forKey: UserDefaultPreferenceKeys().customSuggestionCompletionModel.key) + ) + XCTAssertFalse(store.state.didSave) + } + + func test_saveButtonClicked_writesViaUserDefaults() async { + let (defaults, suite) = isolatedDefaults() + defer { defaults.removePersistentDomain(forName: suite) } + + let store = TestStore( + initialState: CompletionModelEdit.State( + id: "custom-id", + format: .openAI, + maxTokens: 2048, + modelName: "gpt-3.5-turbo-instruct", + baseURLSelection: .init(baseURL: "https://api.openai.com") + ) + ) { + CompletionModelEdit() + } withDependencies: { + $0.userDefaults = defaults + $0.toastController = ToastController(messages: []) + } + + await store.send(.saveButtonClicked) { + $0.didSave = true + } + + let saved = defaults.value(for: \.customSuggestionCompletionModel) + XCTAssertEqual(saved.id, "custom-id") + XCTAssertEqual(saved.info.modelName, "gpt-3.5-turbo-instruct") + XCTAssertEqual(saved.info.baseURL, "https://api.openai.com") + XCTAssertEqual(saved.info.maxTokens, 2048) + XCTAssertEqual(saved.format, .openAI) + } +} + +private func isolatedDefaults() -> (UserDefaults, String) { + let suite = "CompletionModelEditTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return (defaults, suite) +} diff --git a/Core/Tests/HostAppTests/SuggestionFeatureProviderTests.swift b/Core/Tests/HostAppTests/SuggestionFeatureProviderTests.swift new file mode 100644 index 00000000..1f9ae2dd --- /dev/null +++ b/Core/Tests/HostAppTests/SuggestionFeatureProviderTests.swift @@ -0,0 +1,22 @@ +import Preferences +import XCTest + +final class SuggestionFeatureProviderTests: XCTestCase { + func test_customModel_rawValue_is_1000() { + XCTAssertEqual(BuiltInSuggestionFeatureProvider.customModel.rawValue, 1000) + XCTAssertEqual(BuiltInSuggestionFeatureProvider(rawValue: 1000), .customModel) + XCTAssertNil(BuiltInSuggestionFeatureProvider(rawValue: 2)) + } + + func test_suggestionFeatureProvider_roundTrip_builtInCustomModel() throws { + let provider = SuggestionFeatureProvider.builtIn(.customModel) + XCTAssertEqual(SuggestionFeatureProvider(rawValue: provider.rawValue), provider) + + let encoded = try JSONEncoder().encode(BuiltInSuggestionFeatureProvider.customModel) + XCTAssertEqual(String(data: encoded, encoding: .utf8), "1000") + + XCTAssertThrowsError( + try JSONDecoder().decode(BuiltInSuggestionFeatureProvider.self, from: Data("2".utf8)) + ) + } +} diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift index b84e8ac0..c2aaf4e9 100644 --- a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift +++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift @@ -5,149 +5,33 @@ import XCTest @testable import KeyBindingManager class TabToAcceptSuggestionTests: XCTestCase { - func test_should_accept_if_line_invalid() { + /// Cursor-style: Tab accepts whatever is presented, even when typing a tab would not + /// invalidate the suggestion (upstream used to pass those Tabs through to Xcode). + func test_accepts_when_the_suggestion_begins_with_indentation() { XCTAssertTrue( TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - var name: String - var age: Int - } - """.breakLines(), - cursorPosition: .init(line: 4, character: 4), - codeMetadata: .init(), - presentingSuggestionText: "Hello" - ) - ) - - XCTAssertTrue( - TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - var name: String - var age: Int - } - """.breakLines(), - cursorPosition: .init(line: -1, character: 4), - codeMetadata: .init(), - presentingSuggestionText: "Hello" - ) - ) - } - - func test_should_not_accept_if_tab_does_not_invalidate_the_suggestion() { - XCTAssertFalse( - TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - - var age: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 0), - codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), presentingSuggestionText: " var name: String" ) ) - - XCTAssertFalse( - TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct 🐱 { - - var 🎇: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 0), - codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), - presentingSuggestionText: " var 🎇: String" - ) - ) - - XCTAssertFalse( - TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - - var age: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 0), - codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false), - presentingSuggestionText: " var name: String" - ) - ) - - XCTAssertFalse( + XCTAssertTrue( TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - - \tvar age: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 0), - codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true), presentingSuggestionText: "\tvar name: String" ) ) } - - func test_should_accept_if_tab_invalidates_the_suggestion() { - XCTAssertTrue( - TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - \(" ") - var age: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 1), - codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), - presentingSuggestionText: " var name: String" - ) - ) - - XCTAssertTrue( - TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct 🐱 { - \(" ") - var 🎇: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 1), - codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false), - presentingSuggestionText: " var 🎇: String" - ) - ) - - XCTAssertTrue( - TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - \(" ") - var age: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 1), - codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false), - presentingSuggestionText: " var name: String" - ) - ) - + + /// The reported case: the completion after `/// ` started with eight spaces, so upstream's + /// rule needed three Tabs before accepting. + func test_accepts_when_the_completion_after_the_line_prefix_starts_with_whitespace() { XCTAssertTrue( TabToAcceptSuggestion.checkIfAcceptSuggestion( - lines: """ - struct Cat { - \t - \tvar age: Int - } - """.breakLines(), - cursorPosition: .init(line: 1, character: 1), - codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true), - presentingSuggestionText: "\tvar name: String" + presentingSuggestionText: "/// " + String(repeating: " ", count: 8) + + "))\nlet shareItem = UIBarButtonItem(" ) ) } + + func test_does_not_accept_an_empty_suggestion() { + XCTAssertFalse(TabToAcceptSuggestion.checkIfAcceptSuggestion(presentingSuggestionText: "")) + } } diff --git a/Core/Tests/LaunchAgentManagerTests/DebugLaunchAgentPlistTests.swift b/Core/Tests/LaunchAgentManagerTests/DebugLaunchAgentPlistTests.swift new file mode 100644 index 00000000..7120f6d3 --- /dev/null +++ b/Core/Tests/LaunchAgentManagerTests/DebugLaunchAgentPlistTests.swift @@ -0,0 +1,48 @@ +import Foundation +import XCTest + +@testable import LaunchAgentManager + +final class DebugLaunchAgentPlistTests: XCTestCase { + func test_xmlData_roundTripsProgramPathContainingAmpersand() throws { + let programPath = "/tmp/foo & bar/CommunicationBridge" + let data = try DebugLaunchAgentPlist.xmlData( + serviceIdentifier: "dev.com.intii.CopilotForXcode.CommunicationBridge", + programPath: programPath, + bundleIdentifier: "dev.com.intii.CopilotForXcode" + ) + + let xml = String(decoding: data, as: UTF8.self) + XCTAssertTrue(xml.contains("&"), "ampersand in the program path must be XML-escaped") + XCTAssertFalse(xml.contains("foo & bar")) + + let parsed = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + let dict = try XCTUnwrap(parsed as? [String: Any]) + XCTAssertEqual(dict["Program"] as? String, programPath) + XCTAssertEqual( + dict["Label"] as? String, + "dev.com.intii.CopilotForXcode.CommunicationBridge" + ) + } + + func test_propertyListsEqual_comparesParsedDictionaries() throws { + let first = try DebugLaunchAgentPlist.xmlData( + serviceIdentifier: "label", + programPath: "/tmp/foo & bar", + bundleIdentifier: "bundle" + ) + let second = try DebugLaunchAgentPlist.xmlData( + serviceIdentifier: "label", + programPath: "/tmp/foo & bar", + bundleIdentifier: "bundle" + ) + XCTAssertTrue(DebugLaunchAgentPlist.propertyListsEqual(first, second)) + + let different = try DebugLaunchAgentPlist.xmlData( + serviceIdentifier: "label", + programPath: "/tmp/other", + bundleIdentifier: "bundle" + ) + XCTAssertFalse(DebugLaunchAgentPlist.propertyListsEqual(first, different)) + } +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5b663fbe..2c572d0f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,14 +27,23 @@ Most of the logics are implemented inside the package `Core` and `Tool`. ## Building and Archiving the App -1. Update the xcconfig files, bridgeLaunchAgent.plist, and Tool/Configs/Configurations.swift. -2. Build or archive the Copilot for Xcode target. +1. Copy `Local.xcconfig.example` to `Local.xcconfig` and set `DEVELOPMENT_TEAM` to your Apple Developer Team ID. `Local.xcconfig` is gitignored; `Config.xcconfig` and `Config.debug.xcconfig` include it with `#include?`. +2. Update the xcconfig files, `bridgeLaunchAgent.plist`, and `Tool/Configs/Configurations.swift` if you are changing identifiers. +3. Build or archive the Copilot for Xcode target. ## Testing Source Editor Extension and Service -Just run both the `ExtensionService`, `CommunicationBridge` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details. +On this fork, run the `Copilot for Xcode Debug` scheme. That is enough for the host, CommunicationBridge, and ExtensionService: the host registers a launch agent at `~/Library/LaunchAgents/dev.com.intii.CopilotForXcode.CommunicationBridge.plist` and loads it with `launchctl bootstrap`. CommunicationBridge is owned by that launch agent — do **not** Run the `CommunicationBridge` or `ExtensionService` schemes. A second ExtensionService from another bundle path is not reused. Keep only one Dev instance running at a time. -If you are not testing the source editor extension, it's recommended to archive and install a debug version of the Copilot for Xcode and test with the bundled source editor extension. +The `.appex` still follows Apple's editor-extension workflow. Run the `EditorExtension` scheme when you need to debug the source editor extension itself. See [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension). + +To uninstall the agent, use **General › Remove Launch Agent** in the host app, or: + +```bash +launchctl bootout gui/$(id -u)/dev.com.intii.CopilotForXcode.CommunicationBridge +``` + +To debug ExtensionService, use **Debug › Attach to Process…** on the running `CopilotForXcodeExtensionService` process. ## SwiftUI Previews diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index f102f9d4..c260068f 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -52,6 +52,8 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { } var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { + // Xcode can read this before `extensionDidFinishLaunching`, so pin the language here too. + AppLanguage.applyPreferredLanguage() return builtin + optional + custom + internalUse } @@ -89,7 +91,8 @@ extension CommandType { func makeCommandDefinition() -> [XCSourceEditorCommandDefinitionKey: Any] { [.classNameKey: commandClassName, .identifierKey: identifierPrefix + identifier, - .nameKey: name] + // Command names show up in Xcode's Editor menu, so they follow the app's language. + .nameKey: NSLocalizedString(name, comment: "Xcode editor command name")] } } diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 9107c97a..90728752 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -44,31 +44,31 @@ extension AppDelegate { ) let checkForUpdate = NSMenuItem( - title: "Check for Updates", + title: NSLocalizedString("Check for Updates", comment: ""), action: #selector(checkForUpdate), keyEquivalent: "" ) let openExtensionManager = NSMenuItem( - title: "Open Extension Manager", + title: NSLocalizedString("Open Extension Manager", comment: ""), action: #selector(openExtensionManager), keyEquivalent: "" ) let openCopilotForXcode = NSMenuItem( - title: "Open \(hostAppName)", + title: String(format: NSLocalizedString("Open %@", comment: ""), hostAppName), action: #selector(openCopilotForXcode), keyEquivalent: "" ) let openGlobalChat = NSMenuItem( - title: "Open Chat", + title: NSLocalizedString("Open Chat", comment: ""), action: #selector(openGlobalChat), keyEquivalent: "" ) let xcodeInspectorDebug = NSMenuItem( - title: "Xcode Inspector Debug", + title: NSLocalizedString("Xcode Inspector Debug", comment: ""), action: nil, keyEquivalent: "" ) @@ -79,27 +79,27 @@ extension AppDelegate { xcodeInspectorDebug.isHidden = false let accessibilityAPIPermission = NSMenuItem( - title: "Accessibility API Permission: N/A", + title: NSLocalizedString("Accessibility API Permission: N/A", comment: ""), action: nil, keyEquivalent: "" ) accessibilityAPIPermission.identifier = accessibilityAPIPermissionMenuItemIdentifier let quitItem = NSMenuItem( - title: "Quit", + title: NSLocalizedString("Quit", comment: ""), action: #selector(quit), keyEquivalent: "" ) quitItem.target = self let reactivateObservationsItem = NSMenuItem( - title: "Reactivate Observations to Xcode", + title: NSLocalizedString("Reactivate Observations to Xcode", comment: ""), action: #selector(reactivateObservationsToXcode), keyEquivalent: "" ) let resetWorkspacesItem = NSMenuItem( - title: "Reset workspaces", + title: NSLocalizedString("Reset workspaces", comment: ""), action: #selector(destroyWorkspacePool), keyEquivalent: "" ) @@ -139,8 +139,13 @@ extension AppDelegate: NSMenuDelegate { item.identifier == accessibilityAPIPermissionMenuItemIdentifier }) { AXIsProcessTrusted() - accessibilityAPIPermission.title = - "Accessibility API Permission: \(AXIsProcessTrusted() ? "Granted" : "Not Granted")" + accessibilityAPIPermission.title = String( + format: NSLocalizedString("Accessibility API Permission: %@", comment: ""), + NSLocalizedString( + AXIsProcessTrusted() ? "Granted" : "Not Granted", + comment: "" + ) + ) } case xcodeInspectorDebugMenuIdentifier: @@ -222,14 +227,14 @@ extension AppDelegate: NSMenuDelegate { menu.items.append(.separator()) menu.items.append(NSMenuItem( - title: "Restart Xcode Inspector", + title: NSLocalizedString("Restart Xcode Inspector", comment: ""), action: #selector(restartXcodeInspector), keyEquivalent: "" )) let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel) let debugOverlayItem = NSMenuItem( - title: "Debug Window Overlays", + title: NSLocalizedString("Debug Window Overlays", comment: ""), action: #selector(toggleDebugOverlayPanel), keyEquivalent: "" ) diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 801ce37f..50cea6ef 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -15,6 +15,7 @@ import XPCShared let bundleIdentifierBase = Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +let teamIDPrefix = Bundle.main.object(forInfoDictionaryKey: "TEAM_ID_PREFIX") as? String let serviceIdentifier = bundleIdentifierBase + ".ExtensionService" @main @@ -29,6 +30,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { shouldAutomaticallyCheckForUpdate: true ) + func applicationWillFinishLaunching(_: Notification) { + AppLanguage.applyPreferredLanguage() + } + func applicationDidFinishLaunching(_: Notification) { // isPerceptionCheckingEnabled = false if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } @@ -81,8 +86,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func setupQuitOnUpdate() { Task { + #if DEBUG + let fingerprint = ExecutableFingerprint.current() + #else guard let url = Bundle.main.executableURL else { return } let checker = await FileChangeChecker(fileURL: url) + #endif // If Xcode or Copilot for Xcode is made active, check if the executable of this program // is changed. If changed, quit this program. @@ -95,15 +104,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, app.isUserOfService else { continue } - guard await checker.checkIfChanged() else { + #if DEBUG + let changed = ExecutableFingerprint.current() != fingerprint + #else + let changed = await checker.checkIfChanged() + #endif + guard changed else { Logger.service.info("Extension Service is not updated, no need to quit.") continue } Logger.service.info("Extension Service will quit.") - #if DEBUG - #else quit() - #endif } } } diff --git a/ExtensionService/ExecutableFingerprint.swift b/ExtensionService/ExecutableFingerprint.swift new file mode 100644 index 00000000..1d2f8a8d --- /dev/null +++ b/ExtensionService/ExecutableFingerprint.swift @@ -0,0 +1,19 @@ +#if DEBUG +import Foundation + +/// Debug 包主可执行文件是 stub,代码在 `.debug.dylib`;指纹取 dylib 的 (mtime, size)。 +enum ExecutableFingerprint { + static func current() -> String { + guard let executableURL = Bundle.main.executableURL else { return "" } + let dylibPath = executableURL.path + ".debug.dylib" + let path = FileManager.default.fileExists(atPath: dylibPath) ? dylibPath : executableURL.path + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path) else { + return path + } + let mtime = attributes[.modificationDate] as? Date + let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0 + let mtimeBits = mtime.map { String($0.timeIntervalSince1970.bitPattern) } ?? "0" + return "\(mtimeBits)-\(size)" + } +} +#endif diff --git a/ExtensionService/ServiceDelegate.swift b/ExtensionService/ServiceDelegate.swift index 6280582f..bfe82dca 100644 --- a/ExtensionService/ServiceDelegate.swift +++ b/ExtensionService/ServiceDelegate.swift @@ -1,4 +1,5 @@ import Foundation +import Logger import Service import XPCShared @@ -7,6 +8,22 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate { _: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection ) -> Bool { + guard let teamID = XPCPeerRequirement.teamID(fromPrefix: teamIDPrefix) else { + Logger.service.error( + "Rejected XPC connection from pid \(newConnection.processIdentifier): Team ID is unavailable." + ) + return false + } + let requirement = XPCPeerRequirement.codeSigningRequirement(teamID: teamID) + do { + try XPCPeerRequirement.setCodeSigningRequirement(on: newConnection, teamID: teamID) + } catch { + Logger.service.error( + "Rejected XPC connection from pid \(newConnection.processIdentifier): invalid code signing requirement (\(requirement))." + ) + return false + } + newConnection.exportedInterface = NSXPCInterface( with: XPCServiceProtocol.self ) diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift index 5fdd4445..71aa16a6 100644 --- a/ExtensionService/XPCController.swift +++ b/ExtensionService/XPCController.swift @@ -28,7 +28,11 @@ final class XPCController: XPCServiceDelegate { func quit() async { bridge.setDelegate(nil) pingTask?.cancel() + #if DEBUG + // Kickstart restarts the rebuilt bridge; quit() would exit(0) and hit launchd's 10s throttle. + #else try? await bridge.quit() + #endif } deinit { @@ -43,7 +47,11 @@ final class XPCController: XPCServiceDelegate { guard let self else { return } do { try await self.bridge.updateServiceEndpoint(self.xpcListener.endpoint) + #if DEBUG + try await Task.sleep(nanoseconds: 5_000_000_000) + #else try await Task.sleep(nanoseconds: 60_000_000_000) + #endif } catch { try await Task.sleep(nanoseconds: 1_000_000_000) #if DEBUG diff --git a/Local.xcconfig.example b/Local.xcconfig.example new file mode 100644 index 00000000..f7b09bc9 --- /dev/null +++ b/Local.xcconfig.example @@ -0,0 +1 @@ +DEVELOPMENT_TEAM = YOUR_TEAM_ID diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings new file mode 100644 index 00000000..d8fc8021 --- /dev/null +++ b/Localization/Localizable.xcstrings @@ -0,0 +1,10356 @@ +{ + "sourceLanguage": "en", + "strings": { + " For more details, please visit [https://anthropic.com](https://anthropic.com).": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " For more details, please visit [https://anthropic.com](https://anthropic.com)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 更多详情请访问 [https://anthropic.com](https://anthropic.com)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 更多詳情請前往 [https://anthropic.com](https://anthropic.com)。" + } + } + } + }, + " For more details, please visit [https://ollama.com](https://ollama.com)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " For more details, please visit [https://ollama.com](https://ollama.com)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 更多详情请访问 [https://ollama.com](https://ollama.com)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 更多詳情請前往 [https://ollama.com](https://ollama.com)" + } + } + } + }, + " For more details, please visit [https://ollama.com](https://ollama.com).": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " For more details, please visit [https://ollama.com](https://ollama.com)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 更多详情请访问 [https://ollama.com](https://ollama.com)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 更多詳情請前往 [https://ollama.com](https://ollama.com)。" + } + } + } + }, + " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 如果你无法访问 GPT-4,可能需要前往 [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) 购买额度。仅订阅 ChatGPT Plus 并不能通过 API 使用 GPT-4。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 如果你無法存取 GPT-4,可能需要前往 [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) 購買額度。僅訂閱 ChatGPT Plus 並不能透過 API 使用 GPT-4。" + } + } + } + }, + " New Command": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " New Command" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 新建命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 新增命令" + } + } + } + }, + " New Command (%lld/10)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " New Command (%lld/10)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 新建命令(%lld/10)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 新增命令(%lld/10)" + } + } + } + }, + " Please login in the GitHub Copilot settings to use the model.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Please login in the GitHub Copilot settings to use the model." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 请先在 GitHub Copilot 设置中登录后再使用该模型。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 請先在 GitHub Copilot 設定中登入後再使用該模型。" + } + } + } + }, + " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 这会直接调用相关 API,GitHub 未必允许这种用法,但 Zed 等常见应用也是这么做的。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 這會直接呼叫相關 API,GitHub 未必允許這種用法,但 Zed 等常見應用程式也是這麼做的。" + } + } + } + }, + " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 获取 API 密钥请访问 [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 取得 API 金鑰請前往 [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)" + } + } + } + }, + " Use with any API that has the same format as Ollama. For more details, please visit [https://ollama.com](https://ollama.com)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Use with any API that has the same format as Ollama. For more details, please visit [https://ollama.com](https://ollama.com)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 可用于任何与 Ollama 格式相同的 API。更多详情请访问 [https://ollama.com](https://ollama.com)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 可用於任何與 Ollama 格式相同的 API。更多詳情請前往 [https://ollama.com](https://ollama.com)" + } + } + } + }, + "%@ (Not found)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ (Not found)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@(未找到)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@(找不到)" + } + } + } + }, + "%lld tokens": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld tokens" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 个 Token" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 個 Token" + } + } + } + }, + "11 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "11 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "11 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "11 則訊息" + } + } + } + }, + "111 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "111 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "111 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "111 則訊息" + } + } + } + }, + "151 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "151 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "151 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "151 則訊息" + } + } + } + }, + "201 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "201 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "201 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "201 則訊息" + } + } + } + }, + "21 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "21 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "21 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "21 則訊息" + } + } + } + }, + "3 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "3 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "3 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "3 則訊息" + } + } + } + }, + "31 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "31 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "31 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "31 則訊息" + } + } + } + }, + "41 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "41 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "41 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "41 則訊息" + } + } + } + }, + "5 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "5 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "5 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "5 則訊息" + } + } + } + }, + "51 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "51 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "51 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "51 則訊息" + } + } + } + }, + "7 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "7 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "7 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "7 則訊息" + } + } + } + }, + "71 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "71 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "71 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "71 則訊息" + } + } + } + }, + "9 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "9 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "9 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "9 則訊息" + } + } + } + }, + "91 Messages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "91 Messages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "91 条消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "91 則訊息" + } + } + } + }, + "API Keys": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API Keys" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "API 密钥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "API 金鑰" + } + } + } + }, + "API Version": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API Version" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "API 版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "API 版本" + } + } + } + }, + "Accept": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受" + } + } + } + }, + "Accept Line": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept Line" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受本行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受本行" + } + } + } + }, + "Accept Modification": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept Modification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受修改" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受修改" + } + } + } + }, + "Accept Suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept Suggestion" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受建議" + } + } + } + }, + "Accept Suggestion Line": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept Suggestion Line" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受建议的一行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受建議的一行" + } + } + } + }, + "Accept and Continue(⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept and Continue(⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受并继续(⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受並繼續(⌘ + ⏎)" + } + } + } + }, + "Accept and Continue(⌥ + ⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept and Continue(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受并继续(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受並繼續(⌥ + ⌘ + ⏎)" + } + } + } + }, + "Accept suggestion first line with Control": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept suggestion first line with Control" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 Control 键接受建议首行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 Control 鍵接受建議首行" + } + } + } + }, + "Accept suggestion with Tab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept suggestion with Tab" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 Tab 键接受建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 Tab 鍵接受建議" + } + } + } + }, + "Accept suggestion with modifier": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept suggestion with modifier" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用修饰键接受建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用修飾鍵接受建議" + } + } + } + }, + "Accept(⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept(⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受(⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受(⌘ + ⏎)" + } + } + } + }, + "Accept(⌥ + ⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accept(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "接受(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "接受(⌥ + ⌘ + ⏎)" + } + } + } + }, + "Accessibility API Permission: N/A": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accessibility API Permission: N/A" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助功能 API 权限:不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輔助使用 API 權限:不可用" + } + } + } + }, + "Accessibility API permission is not granted. Please enable in System Settings.app.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accessibility API permission is not granted. Please enable in System Settings.app." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未授予辅助功能 API 权限,请在「系统设置」中开启。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未授予輔助使用 API 權限,請在「系統設定」中開啟。" + } + } + } + }, + "Accessibility Permission: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accessibility Permission: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助功能权限:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輔助使用權限:%@" + } + } + } + }, + "Accessibility Settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accessibility Settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助功能设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輔助使用設定" + } + } + } + }, + "Add": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增" + } + } + } + }, + "Add Model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增模型" + } + } + } + }, + "Add context": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add context" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加上下文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增上下文" + } + } + } + }, + "Add to Suggestion-Enabled Project List": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to Suggestion-Enabled Project List" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加到已启用建议的项目列表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入已啟用建議的專案清單" + } + } + } + }, + "Additional system prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Additional system prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "附加系统提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "附加系統提示詞" + } + } + } + }, + "Advanced": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高级" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "進階" + } + } + } + }, + "After renaming or adding a custom command, please restart Xcode to refresh the menu.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After renaming or adding a custom command, please restart Xcode to refresh the menu." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名或新增自定义命令后,请重启 Xcode 以刷新菜单。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名或新增自訂命令後,請重新啟動 Xcode 以更新選單。" + } + } + } + }, + "Always": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一律" + } + } + } + }, + "Always accept and continue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always accept and continue" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终接受并继续" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一律接受並繼續" + } + } + } + }, + "Always accept suggestion with Accessibility API": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always accept suggestion with Accessibility API" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终通过辅助功能 API 接受建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一律透過輔助使用 API 接受建議" + } + } + } + }, + "Always-on-top behavior": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always-on-top behavior" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "置顶行为" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "置頂行為" + } + } + } + }, + "Anthropic Optimized": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Anthropic Optimized" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Anthropic 优化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Anthropic 最佳化" + } + } + } + }, + "Append to default system prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Append to default system prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "追加到默认系统提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "附加到預設系統提示詞" + } + } + } + }, + "Applies to the selected chat model. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages. Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to the selected chat model. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages. Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "作用于所选聊天模型。设置推理强度后,请求会切换为推理模型的形态:发送 reasoning_effort,用 max_completion_tokens 取代 max_tokens,并省略 temperature 与 stop。Anthropic 模型始终使用 /v1/messages。发往网关的请求头只对网关本身有意义,不会转发到上游。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "作用於所選聊天模型。設定推理強度後,請求會切換為推理模型的形態:傳送 reasoning_effort,以 max_completion_tokens 取代 max_tokens,並省略 temperature 與 stop。Anthropic 模型一律使用 /v1/messages。發往閘道的標頭只對閘道本身有意義,不會轉發至上游。" + } + } + } + }, + "Attach File Info": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach File Info" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "附带文件信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "附帶檔案資訊" + } + } + } + }, + "Auth provider URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auth provider URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "认证服务地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "驗證服務網址" + } + } + } + }, + "Authentication Mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication Mode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "认证方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "驗證方式" + } + } + } + }, + "Auto-detected by LLM": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-detected by LLM" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "由大模型自动判断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由大型語言模型自動判斷" + } + } + } + }, + "Automatically Check for Update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically Check for Update" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动检查更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動檢查更新" + } + } + } + }, + "Base URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Base URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基础地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "基礎網址" + } + } + } + }, + "Base URL cannot be empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Base URL cannot be empty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基础地址不能为空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "基礎網址不能為空" + } + } + } + }, + "Basic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Basic" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Basic" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Basic" + } + } + } + }, + "Bearer Token": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bearer Token" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Bearer Token" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Bearer Token" + } + } + } + }, + "Buy Me A Coffee": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Buy Me A Coffee" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请我喝杯咖啡" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請我喝杯咖啡" + } + } + } + }, + "Cache editor information on file open": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cache editor information on file open" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开文件时缓存编辑器信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟檔案時快取編輯器資訊" + } + } + } + }, + "Can't find workspace.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't find workspace." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到工作区。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到工作區。" + } + } + } + }, + "Cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "Chat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天" + } + } + } + }, + "Chat Model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天模型" + } + } + } + }, + "Chat Model API": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Model API" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天模型 API" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天模型 API" + } + } + } + }, + "Chat Model Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Model Name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天模型名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天模型名稱" + } + } + } + }, + "Chat Models": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Models" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天模型" + } + } + } + }, + "Chat about your code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat about your code" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "围绕你的代码进行对话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圍繞你的程式碼進行對話" + } + } + } + }, + "Chat model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天模型" + } + } + } + }, + "Chat web page URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat web page URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天网页地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天網頁網址" + } + } + } + }, + "Chat, Modification": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat, Modification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天、修改" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天、修改" + } + } + } + }, + "Check for Updates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查更新" + } + } + } + }, + "Close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + } + } + }, + "Close Idle Tabs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Idle Tabs" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭闲置标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉閒置標籤頁" + } + } + } + }, + "Codeium API URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codeium API URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codeium API 地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codeium API 網址" + } + } + } + }, + "Codeium Enterprise Mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codeium Enterprise Mode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codeium 企业版模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codeium 企業版模式" + } + } + } + }, + "Codeium Language Server": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codeium Language Server" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codeium 语言服务器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codeium 語言伺服器" + } + } + } + }, + "Codeium Portal URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codeium Portal URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codeium 门户地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codeium 入口網址" + } + } + } + }, + "Codeium not signed in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codeium not signed in." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codeium 未登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codeium 未登入。" + } + } + } + }, + "Command": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "命令" + } + } + } + }, + "Command Type": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Type" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令类型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "命令類型" + } + } + } + }, + "Command not found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command not found" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到命令" + } + } + } + }, + "Confirm Sign-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm Sign-in" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認登入" + } + } + } + }, + "Context Window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Context Window" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上下文窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上下文視窗" + } + } + } + }, + "Contexts": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Contexts" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上下文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上下文" + } + } + } + }, + "Continue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繼續" + } + } + } + }, + "Continuous Mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continuous Mode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连续模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連續模式" + } + } + } + }, + "Control": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Control" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Control" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Control" + } + } + } + }, + "Conversation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Conversation" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "对话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "對話" + } + } + } + }, + "Copilot.Vim Version: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim Version: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:%@" + } + } + } + }, + "Copilot.Vim Version: %@ (Supported Version: %@)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim Version: %@ (Supported Version: %@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:%1$@(支持的版本:%2$@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:%1$@(支援的版本:%2$@)" + } + } + } + }, + "Copilot.Vim Version: %@ (Update Available: %@)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim Version: %@ (Update Available: %@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:%1$@(可更新至 %2$@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:%1$@(可更新至 %2$@)" + } + } + } + }, + "Copilot.Vim Version: Loading..": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim Version: Loading.." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:加载中……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:載入中……" + } + } + } + }, + "Copilot.Vim Version: Not Installed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim Version: Not Installed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:未安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copilot.Vim 版本:未安裝" + } + } + } + }, + "Copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製" + } + } + } + }, + "Custom Body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Body" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义请求体" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂請求內容" + } + } + } + }, + "Custom Chat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Chat" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义聊天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂聊天" + } + } + } + }, + "Custom Command": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Command" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂命令" + } + } + } + }, + "Custom Commands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Commands" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂命令" + } + } + } + }, + "Custom Header Field": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Header Field" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义请求头字段" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂標頭欄位" + } + } + } + }, + "Custom Headers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Headers" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义请求头" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂標頭" + } + } + } + }, + "Custom Model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂模型" + } + } + } + }, + "Custom Model (Completion API)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Model (Completion API)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义模型(Completion API)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂模型(Completion API)" + } + } + } + }, + "Custom Model (FIM API)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Model (FIM API)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义模型(FIM API)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂模型(FIM API)" + } + } + } + }, + "Custom Value": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Value" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂值" + } + } + } + }, + "Dark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + } + } + }, + "Debug Window Overlays": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Debug Window Overlays" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调试窗口浮层" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "除錯視窗覆蓋層" + } + } + } + }, + "Decompressing..": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Decompressing.." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在解压……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在解壓縮……" + } + } + } + }, + "DeepSeek (OpenAI Compatible)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "DeepSeek (OpenAI Compatible)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "DeepSeek(OpenAI 兼容)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "DeepSeek(OpenAI 相容)" + } + } + } + }, + "Default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設" + } + } + } + }, + "Default (%@)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认(%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設(%@)" + } + } + } + }, + "Default (Selected Model Not Found)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default (Selected Model Not Found)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认(未找到所选模型)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設(找不到所選模型)" + } + } + } + }, + "Default Scopes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default Scopes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认权限范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設權限範圍" + } + } + } + }, + "Default Value": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default Value" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設值" + } + } + } + }, + "Delete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除" + } + } + } + }, + "Deployment Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deployment Name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部署名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "部署名稱" + } + } + } + }, + "Detach Chat Panel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detach Chat Panel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分离聊天面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "分離聊天面板" + } + } + } + }, + "Dimensions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dimensions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向量维度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向量維度" + } + } + } + }, + "Disable GitHub Copilot settings auto refresh status on appear": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable GitHub Copilot settings auto refresh status on appear" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 GitHub Copilot 设置时不自动刷新状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟 GitHub Copilot 設定時不自動重新整理狀態" + } + } + } + }, + "Disable Suggestion for \"%@\"": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable Suggestion for \"%@\"" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为“%@”禁用建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為「%@」停用建議" + } + } + } + }, + "Disable always-on-top when the chat panel is detached": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable always-on-top when the chat panel is detached" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天面板分离时不置顶" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天面板分離時不置頂" + } + } + } + }, + "Disable enhanced workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable enhanced workspace" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用增强工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用增強工作區" + } + } + } + }, + "Disable file content manipulation by cheatsheet": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable file content manipulation by cheatsheet" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用基于速查表的文件内容改写" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用基於速查表的檔案內容改寫" + } + } + } + }, + "Disable function calling for chat feature": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable function calling for chat feature" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为聊天功能禁用函数调用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為聊天功能停用函式呼叫" + } + } + } + }, + "Disable git ignore check": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable git ignore check" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用 git ignore 检查" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用 git ignore 檢查" + } + } + } + }, + "Disable suggestion feature globally": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable suggestion feature globally" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局禁用建议功能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域停用建議功能" + } + } + } + }, + "Disabled Languages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disabled Languages" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已禁用的语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已停用的語言" + } + } + } + }, + "Disabled language list": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disabled language list" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已禁用的语言列表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已停用的語言清單" + } + } + } + }, + "Dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "忽略" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "忽略" + } + } + } + }, + "Dismiss suggestion with ESC": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss suggestion with ESC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 ESC 键忽略建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 ESC 鍵忽略建議" + } + } + } + }, + "Don't install launch agent automatically": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don't install launch agent automatically" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不自动安装启动代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不自動安裝啟動代理程式" + } + } + } + }, + "Done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + } + } + }, + "Done!": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成!" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完成!" + } + } + } + }, + "Downloading..": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading.." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在下載……" + } + } + } + }, + "Duplicate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Duplicate" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建副本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立副本" + } + } + } + }, + "Edit Model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯模型" + } + } + } + }, + "Embedding Models": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Embedding Models" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "嵌入模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "嵌入模型" + } + } + } + }, + "Embedding model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Embedding model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "嵌入模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "嵌入模型" + } + } + } + }, + "Empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Empty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "空" + } + } + } + }, + "Empty (Default Value)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Empty (Default Value)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空(默认值)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "空(預設值)" + } + } + } + }, + "Enable Indexing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Indexing" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用索引" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用索引" + } + } + } + }, + "Enable Suggestion for \"%@\"": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Suggestion for \"%@\"" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为“%@”启用建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為「%@」啟用建議" + } + } + } + }, + "Enable Xcode inspector debug menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Xcode inspector debug menu" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用 Xcode Inspector 调试菜单" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用 Xcode Inspector 除錯選單" + } + } + } + }, + "Enable animation A": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable animation A" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用动画 A" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用動畫 A" + } + } + } + }, + "Enable animation B": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable animation B" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用动画 B" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用動畫 B" + } + } + } + }, + "Enable the Hotkey Globally": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable the Hotkey Globally" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局启用快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域啟用快速鍵" + } + } + } + }, + "Enable widget breathing animation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable widget breathing animation" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用组件呼吸动画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用元件呼吸動畫" + } + } + } + }, + "Enabled Projects": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enabled Projects" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已启用的项目" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已啟用的專案" + } + } + } + }, + "Enforce message order to be user/assistant alternated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enforce message order to be user/assistant alternated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "强制消息按用户/助手交替排列" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "強制訊息按使用者/助理交替排列" + } + } + } + }, + "Engine": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Engine" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "引擎" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "引擎" + } + } + } + }, + "Enter file path:": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter file path:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入文件路径:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入檔案路徑:" + } + } + } + }, + "Enter the root path of the project. Do not use `~` to replace /Users/yourUserName.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the root path of the project. Do not use `~` to replace /Users/yourUserName." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入项目的根路径。请勿用 `~` 代替 /Users/你的用户名。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入專案的根路徑。請勿用 `~` 代替 /Users/你的使用者名稱。" + } + } + } + }, + "Enter your requirements to generate code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your requirements to generate code." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入你的需求以生成代码。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入你的需求以產生程式碼。" + } + } + } + }, + "Enterprise": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enterprise" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "企业版" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "企業版" + } + } + } + }, + "Error (Completed in %.2fs)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error (Completed in %.2fs)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败(耗时 %.2f 秒)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "失敗(耗時 %.2f 秒)" + } + } + } + }, + "Error message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error message" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "错误消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "錯誤訊息" + } + } + } + }, + "Exception list": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Exception list" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "例外列表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "例外清單" + } + } + } + }, + "Export": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯出" + } + } + } + }, + "Extension Service Version: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extension Service Version: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扩展服务版本:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "擴充服務版本:%@" + } + } + } + }, + "Extensions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extensions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "擴充功能" + } + } + } + }, + "Extensions Settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extensions Settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扩展设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "擴充功能設定" + } + } + } + }, + "Extra Context": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extra Context" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "附加上下文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "附加上下文" + } + } + } + }, + "Extra Prompt:": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extra Prompt:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "附加提示词:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "附加提示詞:" + } + } + } + }, + "FIM Stop Token": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "FIM Stop Token" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "FIM 停止词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "FIM 停止詞" + } + } + } + }, + "FIM Template": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "FIM Template" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "FIM 模板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "FIM 範本" + } + } + } + }, + "Failed to construct chat URL.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to construct chat URL." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "构造聊天地址失败。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建構聊天網址失敗。" + } + } + } + }, + "Failed to find language server. Please open an issue on GitHub.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to find language server. Please open an issue on GitHub." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到语言服务器,请在 GitHub 上提交 issue。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到語言伺服器,請在 GitHub 上提交 issue。" + } + } + } + }, + "Failed to install language server. Please open an issue on GitHub.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to install language server. Please open an issue on GitHub." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装语言服务器失败,请在 GitHub 上提交 issue。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝語言伺服器失敗,請在 GitHub 上提交 issue。" + } + } + } + }, + "Failed to install start script.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to install start script." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装启动脚本失败。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝啟動指令碼失敗。" + } + } + } + }, + "Feature": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feature" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "功能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "功能" + } + } + } + }, + "Feature provider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feature provider" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "功能提供方" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "功能提供者" + } + } + } + }, + "File path": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File path" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件路径" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案路徑" + } + } + } + }, + "File...": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案……" + } + } + } + }, + "File:": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案:" + } + } + } + }, + "Fill-in-the-Middle (for models with FIM support, e.g. codellama:xb-code)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fill-in-the-Middle (for models with FIM support, e.g. codellama:xb-code)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中间填充 FIM(适用于支持 FIM 的模型,例如 codellama:xb-code)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中間填充 FIM(適用於支援 FIM 的模型,例如 codellama:xb-code)" + } + } + } + }, + "Fill-in-the-Middle with System Prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fill-in-the-Middle with System Prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "带系统提示词的中间填充 FIM" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帶系統提示詞的中間填充 FIM" + } + } + } + }, + "Fixed to Bottom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fixed to Bottom" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定在底部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "固定在底部" + } + } + } + }, + "Floating widget": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Floating widget" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬浮组件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "懸浮元件" + } + } + } + }, + "Follow System": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Follow System" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跟隨系統" + } + } + } + }, + "Follow Text Cursor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Follow Text Cursor" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随文本光标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跟隨文字游標" + } + } + } + }, + "Font": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Font" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "字体" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "字型" + } + } + } + }, + "Font of code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Font of code" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代码字体" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "程式碼字型" + } + } + } + }, + "Font size of message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Font size of message" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "消息字体大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "訊息字型大小" + } + } + } + }, + "Format": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Format" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "格式" + } + } + } + }, + "Found %lld results:": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Found %lld results:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 条结果:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 筆結果:" + } + } + } + }, + "Full URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完整地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完整網址" + } + } + } + }, + "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分功能依赖函数调用。如果该模型不支持函数调用,请关闭此项以免出现未定义行为。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "部分功能依賴函式呼叫。如果該模型不支援函式呼叫,請關閉此項以免出現未定義行為。" + } + } + } + }, + "General": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + } + } + }, + "Generate suggestions for your code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generate suggestions for your code" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为你的代码生成建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為你的程式碼產生建議" + } + } + } + }, + "Generate suggestions with locally run or self-hosted models. Select \"Custom Model\" as the suggestion provider in Feature - Suggestion to use it.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generate suggestions with locally run or self-hosted models. Select \"Custom Model\" as the suggestion provider in Feature - Suggestion to use it." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用本地运行或自建的模型生成建议。请在「功能 - 建议」中把建议提供方设为「自定义模型」后使用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用本機執行或自架的模型產生建議。請在「功能 - 建議」中把建議提供者設為「自訂模型」後使用。" + } + } + } + }, + "Get Suggestions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get Suggestions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "获取建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取得建議" + } + } + } + }, + "GitHub Copilot (pick another model)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub Copilot (pick another model)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GitHub Copilot(选择其他模型)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "GitHub Copilot(選擇其他模型)" + } + } + } + }, + "GitHub Copilot Language Server": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub Copilot Language Server" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GitHub Copilot 语言服务器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "GitHub Copilot 語言伺服器" + } + } + } + }, + "Granted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Granted" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已授權" + } + } + } + }, + "Grok (OpenAI Compatible)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grok (OpenAI Compatible)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Grok(OpenAI 兼容)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Grok(OpenAI 相容)" + } + } + } + }, + "Header Field": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Header Field" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求头字段" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標頭欄位" + } + } + } + }, + "Header Field Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Header Field Name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求头字段名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標頭欄位名稱" + } + } + } + }, + "Header Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Header Name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求头名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標頭名稱" + } + } + } + }, + "Headless Browser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Headless Browser" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无头浏览器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無頭瀏覽器" + } + } + } + }, + "Headless Browser Settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Headless Browser Settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无头浏览器设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無頭瀏覽器設定" + } + } + } + }, + "Hide buttons": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide buttons" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏按钮" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏按鈕" + } + } + } + }, + "Hide common preceding spaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide common preceding spaces" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏公共前导空格" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏共同前導空格" + } + } + } + }, + "Hide indicator widget": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide indicator widget" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏指示器组件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏指示器元件" + } + } + } + }, + "If you are not sure, run test to get the correct value.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you are not sure, run test to get the correct value." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果不确定,可以运行测试来获取正确的值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果不確定,可以執行測試來取得正確的值。" + } + } + } + }, + "Ignore existing contexts": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ignore existing contexts" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "忽略已有上下文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "忽略既有上下文" + } + } + } + }, + "Import": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入" + } + } + } + }, + "Indexing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indexing" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "索引" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "索引" + } + } + } + }, + "Info message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Info message" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提示消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "提示訊息" + } + } + } + }, + "Install": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝" + } + } + } + }, + "Install beta builds": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install beta builds" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装测试版" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝測試版" + } + } + } + }, + "Instruction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Instruction" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "指令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "指令" + } + } + } + }, + "Invalid JSON object": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid JSON object" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "JSON 对象不合法" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "JSON 物件不合法" + } + } + } + }, + "Invalid data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid data" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "数据无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料無效" + } + } + } + }, + "Invalid response": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid response" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "响应无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "回應無效" + } + } + } + }, + "Jump to File(⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to File(⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳转到文件(⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳至檔案(⌘ + ⏎)" + } + } + } + }, + "Keep Alive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Alive" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保持连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保持連線" + } + } + } + }, + "Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当聊天面板与 Xcode 重叠且 Xcode 处于活动状态时保持置顶" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當聊天面板與 Xcode 重疊且 Xcode 為使用中視窗時保持置頂" + } + } + } + }, + "Key": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鍵" + } + } + } + }, + "Key not found: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key not found: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到密钥:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到金鑰:%@" + } + } + } + }, + "Label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Label" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤" + } + } + } + }, + "Language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言" + } + } + } + }, + "Language Server Version: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language Server Version: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言服务器版本:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言伺服器版本:%@" + } + } + } + }, + "Language Server Version: %@ (Supported Version: %@)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language Server Version: %@ (Supported Version: %@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言服务器版本:%1$@(支持的版本:%2$@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言伺服器版本:%1$@(支援的版本:%2$@)" + } + } + } + }, + "Language Server Version: %@ (Update Available: %@)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language Server Version: %@ (Update Available: %@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言服务器版本:%1$@(可更新至 %2$@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言伺服器版本:%1$@(可更新至 %2$@)" + } + } + } + }, + "Language Server Version: Not Installed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language Server Version: Not Installed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言服务器版本:未安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言伺服器版本:未安裝" + } + } + } + }, + "Language server is installing.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language server is installing." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言服务器正在安装。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言伺服器正在安裝。" + } + } + } + }, + "Language server is not installed.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language server is not installed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未安装语言服务器。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未安裝語言伺服器。" + } + } + } + }, + "Language server is not installed. Please install it in the host app.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language server is not installed. Please install it in the host app." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未安装语言服务器,请在主程序中安装。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未安裝語言伺服器,請在主程式中安裝。" + } + } + } + }, + "Language server is outdated. Please update it in the host app or update the extension.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language server is outdated. Please update it in the host app or update the extension." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言服务器版本过旧,请在主程序中更新,或更新扩展本身。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言伺服器版本過舊,請在主程式中更新,或更新擴充功能本身。" + } + } + } + }, + "Language service is installing, please try again later.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language service is installing, please try again later." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言服务正在安装,请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言服務正在安裝,請稍後再試。" + } + } + } + }, + "Launching service app.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Launching service app." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动服务应用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在啟動服務應用程式。" + } + } + } + }, + "Leave it blank if non is available.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leave it blank if non is available." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果没有可用值,请留空。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果沒有可用值,請留空。" + } + } + } + }, + "Light": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色" + } + } + } + }, + "Load Active Workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load Active Workspace" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载当前工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入目前工作區" + } + } + } + }, + "Load Current Workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load Current Workspace" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载当前工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入目前工作區" + } + } + } + }, + "Load certificates in keychain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load certificates in keychain" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载钥匙串中的证书" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入鑰匙圈中的憑證" + } + } + } + }, + "Loading..": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading.." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载中……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入中……" + } + } + } + }, + "Max Input Tokens": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Max Input Tokens" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最大输入 Token 数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最大輸入 Token 數" + } + } + } + }, + "Max: %lld": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Max: %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上限:%lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上限:%lld" + } + } + } + }, + "Memory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Memory" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "记忆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "記憶" + } + } + } + }, + "Mistral (OpenAI Compatible)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mistral (OpenAI Compatible)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Mistral(OpenAI 兼容)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Mistral(OpenAI 相容)" + } + } + } + }, + "Mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模式" + } + } + } + }, + "Model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模型" + } + } + } + }, + "Model ID cannot be empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model ID cannot be empty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模型 ID 不能为空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模型 ID 不能為空" + } + } + } + }, + "Model Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model Name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模型名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模型名稱" + } + } + } + }, + "Model name cannot be empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model name cannot be empty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模型名称不能为空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模型名稱不能為空" + } + } + } + }, + "Modification": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Modification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修改" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修改" + } + } + } + }, + "More info": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More info" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更多信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更多資訊" + } + } + } + }, + "Naive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Naive" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "朴素" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "樸素" + } + } + } + }, + "Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "名稱" + } + } + } + }, + "Nearby text cursor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nearby text cursor" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "靠近文本光标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "靠近文字游標" + } + } + } + }, + "Never": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从不" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "永不" + } + } + } + }, + "New": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增" + } + } + } + }, + "New Command": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Command" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增命令" + } + } + } + }, + "New Key": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Key" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新鍵" + } + } + } + }, + "New Value": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Value" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新值" + } + } + } + }, + "Next Suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Suggestion" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一条建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一則建議" + } + } + } + }, + "No API Key": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No API Key" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无 API 密钥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無 API 金鑰" + } + } + } + }, + "No API key found, please add a new one →": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No API key found, please add a new one →" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到 API 密钥,请添加一个 →" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到 API 金鑰,請新增一個 →" + } + } + } + }, + "No Limit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Limit" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不限制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不限制" + } + } + } + }, + "No Model Available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Model Available" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可用的模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有可用的模型" + } + } + } + }, + "No context": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No context" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无上下文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無上下文" + } + } + } + }, + "No model found, please add a new one.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No model found, please add a new one." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到模型,请添加一个。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到模型,請新增一個。" + } + } + } + }, + "Node Settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Node Settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Node 设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Node 設定" + } + } + } + }, + "None": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無" + } + } + } + }, + "Not Found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Found" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到" + } + } + } + }, + "Not Granted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Granted" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未授權" + } + } + } + }, + "Not Set": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Set" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + } + } + }, + "Not logged in.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not logged in." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未登入。" + } + } + } + }, + "OK": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "好" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "好" + } + } + } + }, + "Observe to AXNotification with default mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Observe to AXNotification with default mode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以默认模式监听 AXNotification" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以預設模式監聽 AXNotification" + } + } + } + }, + "Ollama Compatible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ollama Compatible" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Ollama 兼容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Ollama 相容" + } + } + } + }, + "Only require modifiers for Swift": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only require modifiers for Swift" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅 Swift 文件需要修饰键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅 Swift 檔案需要修飾鍵" + } + } + } + }, + "Open %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟%@" + } + } + } + }, + "Open %@ tab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open %@ tab" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开「%@」标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟「%@」標籤頁" + } + } + } + }, + "Open Chat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Chat" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开聊天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟聊天" + } + } + } + }, + "Open Chat Mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Chat Mode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开聊天模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟聊天模式" + } + } + } + }, + "Open Extension Manager": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Extension Manager" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开扩展管理器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟擴充功能管理員" + } + } + } + }, + "Open chat panel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open chat panel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开聊天面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟聊天面板" + } + } + } + }, + "Open web page in browser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open web page in browser" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在浏览器中打开网页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在瀏覽器中開啟網頁" + } + } + } + }, + "Open web page in chat panel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open web page in chat panel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在聊天面板中打开网页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在聊天面板中開啟網頁" + } + } + } + }, + "OpenAI Compatible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenAI Compatible" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "OpenAI 兼容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "OpenAI 相容" + } + } + } + }, + "OpenAI Endpoint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenAI Endpoint" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "OpenAI 接口地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "OpenAI 端點網址" + } + } + } + }, + "OpenRouter (OpenAI Compatible)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenRouter (OpenAI Compatible)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "OpenRouter(OpenAI 兼容)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "OpenRouter(OpenAI 相容)" + } + } + } + }, + "Option": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Option" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Option" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Option" + } + } + } + }, + "Optional": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選填" + } + } + } + }, + "Organization ID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Organization ID" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "组织 ID" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "組織 ID" + } + } + } + }, + "Original": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原文" + } + } + } + }, + "Overwrite default system prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overwrite default system prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "覆盖默认系统提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "覆寫預設系統提示詞" + } + } + } + }, + "PATH inherited from $SHELL configurations.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PATH inherited from $SHELL configurations." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "PATH 继承自 $SHELL 的配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "PATH 繼承自 $SHELL 的設定。" + } + } + } + }, + "PATH inherited from bash configurations.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PATH inherited from bash configurations." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "PATH 继承自 bash 的配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "PATH 繼承自 bash 的設定。" + } + } + } + }, + "Password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "密碼" + } + } + } + }, + "Path to Node (v22.0+)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to Node (v22.0+)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Node 可执行文件路径(v22.0 及以上)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Node 執行檔路徑(v22.0 以上)" + } + } + } + }, + "Please login in the GitHub Copilot settings to use the model.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please login in the GitHub Copilot settings to use the model." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先在 GitHub Copilot 设置中登录后再使用该模型。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請先在 GitHub Copilot 設定中登入後再使用該模型。" + } + } + } + }, + "Plugin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugin" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "插件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外掛" + } + } + } + }, + "Prefer widget to be inside editor\nwhen width greater than": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefer widget to be inside editor\nwhen width greater than" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当宽度大于此值时,优先将组件放在编辑器内" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當寬度大於此值時,優先將元件放在編輯器內" + } + } + } + }, + "Prefetch Suggestions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefetch Suggestions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预取建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預先取得建議" + } + } + } + }, + "Prepare for Real-time Suggestions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prepare for Real-time Suggestions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为实时建议做准备" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為即時建議做準備" + } + } + } + }, + "Presentation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Presentation" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "呈现方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "呈現方式" + } + } + } + }, + "Pretend IDE to be VSCode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pretend IDE to be VSCode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将 IDE 伪装为 VSCode" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將 IDE 偽裝為 VSCode" + } + } + } + }, + "Previous Suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Suggestion" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一条建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一則建議" + } + } + } + }, + "Project ID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Project ID" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "项目 ID" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "專案 ID" + } + } + } + }, + "Prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "提示詞" + } + } + } + }, + "Provide the path to the executable if it can't be found by the app, shim executable is not supported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provide the path to the executable if it can't be found by the app, shim executable is not supported" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "若应用无法自动找到可执行文件,请提供其路径;不支持 shim 形式的可执行文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "若應用程式無法自動找到執行檔,請提供其路徑;不支援 shim 形式的執行檔" + } + } + } + }, + "Proxy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理" + } + } + } + }, + "Proxy host": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy host" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理主机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理主機" + } + } + } + }, + "Proxy password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy password" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理密码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理密碼" + } + } + } + }, + "Proxy port": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy port" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理端口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理連接埠" + } + } + } + }, + "Proxy strict SSL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy strict SSL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理严格校验 SSL" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理嚴格驗證 SSL" + } + } + } + }, + "Proxy username": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy username" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理用户名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理使用者名稱" + } + } + } + }, + "Quit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束" + } + } + } + }, + "Quit service when Xcode and host app are terminated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit service when Xcode and host app are terminated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当 Xcode 与主程序退出时结束服务" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當 Xcode 與主程式結束時停止服務" + } + } + } + }, + "Raw Prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Raw Prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始提示詞" + } + } + } + }, + "Re-activate Xcode Inspector when Accessibility API malfunctioning detected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-activate Xcode Inspector when Accessibility API malfunctioning detected" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测到辅助功能 API 异常时重新激活 Xcode Inspector" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偵測到輔助使用 API 異常時重新啟用 Xcode Inspector" + } + } + } + }, + "Reactivate Observations to Xcode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reactivate Observations to Xcode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新激活对 Xcode 的监听" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟用對 Xcode 的監聽" + } + } + } + }, + "Real-time suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Real-time suggestion" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時建議" + } + } + } + }, + "Real-time suggestion debounce": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Real-time suggestion debounce" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时建议防抖延迟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時建議防抖延遲" + } + } + } + }, + "Realtime Suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Realtime Suggestion" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時建議" + } + } + } + }, + "Reasoning Effort": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reasoning Effort" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推理强度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "推理強度" + } + } + } + }, + "Reasoning Token Budget": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reasoning Token Budget" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推理 Token 预算" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "推理 Token 預算" + } + } + } + }, + "Receive response in notification": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Receive response in notification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在通知中接收回复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在通知中接收回覆" + } + } + } + }, + "Refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理" + } + } + } + }, + "Refresh configurations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh configurations" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理設定" + } + } + } + }, + "Reject": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reject" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拒绝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拒絕" + } + } + } + }, + "Reject Suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reject Suggestion" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拒绝建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拒絕建議" + } + } + } + }, + "Reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入" + } + } + } + }, + "Reload Launch Agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload Launch Agent" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载启动代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入啟動代理程式" + } + } + } + }, + "Remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "Remove Launch Agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove Launch Agent" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除启动代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除啟動代理程式" + } + } + } + }, + "Remove from Suggestion-Enabled Project List": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from Suggestion-Enabled Project List" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从已启用建议的项目列表中移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從已啟用建議的專案清單中移除" + } + } + } + }, + "Replace and Continue(⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace and Continue(⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换并继续(⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取代並繼續(⌘ + ⏎)" + } + } + } + }, + "Replace and Continue(⌥ + ⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace and Continue(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换并继续(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取代並繼續(⌥ + ⌘ + ⏎)" + } + } + } + }, + "Replace(⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace(⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换(⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取代(⌘ + ⏎)" + } + } + } + }, + "Replace(⌥ + ⌘ + ⏎)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换(⌥ + ⌘ + ⏎)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取代(⌥ + ⌘ + ⏎)" + } + } + } + }, + "Reply in language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply in language" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "回复所用语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "回覆所用語言" + } + } + } + }, + "Request Strategy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request Strategy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求策略" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請求策略" + } + } + } + }, + "Requires the first message to be from the user": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requires the first message to be from the user" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要求首条消息来自用户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要求首則訊息來自使用者" + } + } + } + }, + "Reset 0.23.0 migration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset 0.23.0 migration" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置 0.23.0 迁移" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設 0.23.0 移轉" + } + } + } + }, + "Reset Default Scopes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset Default Scopes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置默认权限范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設預設權限範圍" + } + } + } + }, + "Reset System Prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset System Prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置系统提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設系統提示詞" + } + } + } + }, + "Reset migration version to 0": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset migration version to 0" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将迁移版本重置为 0" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將移轉版本重設為 0" + } + } + } + }, + "Reset update cycle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset update cycle" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置更新周期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設更新週期" + } + } + } + }, + "Reset workspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset workspaces" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設工作區" + } + } + } + }, + "Restart Xcode Inspector": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Xcode Inspector" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启 Xcode Inspector" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動 Xcode Inspector" + } + } + } + }, + "Restart the app and the extension service to switch language.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart the app and the extension service to switch language." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请重启本应用与扩展服务以切换语言。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請重新啟動本應用程式與擴充服務以切換語言。" + } + } + } + }, + "Reveal Extension Service in Finder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reveal Extension Service in Finder" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在访达中显示扩展服务" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Finder 中顯示擴充服務" + } + } + } + }, + "Root path": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Root path" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "根路径" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "根路徑" + } + } + } + }, + "Run Node with": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run Node with" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行 Node 的方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行 Node 的方式" + } + } + } + }, + "Same as chat feature": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Same as chat feature" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "与聊天功能相同" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "與聊天功能相同" + } + } + } + }, + "Save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存" + } + } + } + }, + "Saved!": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存!" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已儲存!" + } + } + } + }, + "Search Provider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Provider" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索服务商" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋服務商" + } + } + } + }, + "Search plugin max iterations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search plugin max iterations" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索插件最大迭代次数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋外掛最大迭代次數" + } + } + } + }, + "Send Again": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Again" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新发送" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新傳送" + } + } + } + }, + "Send Message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Message" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "傳送訊息" + } + } + } + }, + "Send immediately": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send immediately" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即发送" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即傳送" + } + } + } + }, + "Serp API Settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Serp API Settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Serp API 设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Serp API 設定" + } + } + } + }, + "Service": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Service" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "服务" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "服務" + } + } + } + }, + "Set as Extra System Prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set as Extra System Prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设为附加系统提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設為附加系統提示詞" + } + } + } + }, + "Setup Launch Agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Setup Launch Agent" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置启动代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定啟動代理程式" + } + } + } + }, + "Shift": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shift" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Shift" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Shift" + } + } + } + }, + "Sign In": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign In" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入" + } + } + } + }, + "Sign Out": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign Out" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登出" + } + } + } + }, + "Signing In..": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing In.." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在登录……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在登入……" + } + } + } + }, + "Single Round Dialog": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Single Round Dialog" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "单轮对话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "單輪對話" + } + } + } + }, + "Status: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "狀態:%@" + } + } + } + }, + "Status: Not Signed In": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status: Not Signed In" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态:未登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "狀態:未登入" + } + } + } + }, + "Status: Signed In": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status: Signed In" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态:已登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "狀態:已登入" + } + } + } + }, + "Stop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } + } + }, + "Stop Responding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop Responding" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止回复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止回覆" + } + } + } + }, + "Store API keys in UserDefaults": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Store API keys in UserDefaults" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将 API 密钥保存在 UserDefaults 中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將 API 金鑰儲存在 UserDefaults 中" + } + } + } + }, + "Success (Completed in %.2fs)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Success (Completed in %.2fs)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "成功(耗时 %.2f 秒)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "成功(耗時 %.2f 秒)" + } + } + } + }, + "Suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Suggestion" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建議" + } + } + } + }, + "Suggestion Line Limit (0 means stop words only)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Suggestion Line Limit (0 means stop words only)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "建议行数上限(0 表示仅使用停止词)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建議行數上限(0 表示僅使用停止詞)" + } + } + } + }, + "Suggestion Token Limit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Suggestion Token Limit" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "建议 Token 上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建議 Token 上限" + } + } + } + }, + "Support multi-part message content": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Support multi-part message content" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "支持多段式消息内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "支援多段式訊息內容" + } + } + } + }, + "Supports Function Calling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Supports Function Calling" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "支持函数调用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "支援函式呼叫" + } + } + } + }, + "Supports Images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Supports Images" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "支持图片" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "支援圖片" + } + } + } + }, + "Sync color scheme with Xcode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sync color scheme with Xcode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配色跟随 Xcode" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "配色跟隨 Xcode" + } + } + } + }, + "System": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跟隨系統" + } + } + } + }, + "System Prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System Prompt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统提示词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統提示詞" + } + } + } + }, + "System Prompt:": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System Prompt:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统提示词:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統提示詞:" + } + } + } + }, + "Tabs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tabs" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤頁" + } + } + } + }, + "Temperature": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "温度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "溫度" + } + } + } + }, + "Test": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測試" + } + } + } + }, + "Test Result": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test Result" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试结果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測試結果" + } + } + } + }, + "Test Search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test Search" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測試搜尋" + } + } + } + }, + "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义请求体会被合并进请求中,可用来添加表单里尚未提供的参数。内容必须是合法的 JSON 对象。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂請求內容會被合併進請求中,可用來加入表單裡尚未提供的參數。內容必須是合法的 JSON 物件。" + } + } + } + }, + "The user code is pasted into your clipboard, please paste it in the opened website to login.\nAfter that, click \"Confirm Sign-in\" to finish.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The user code is pasted into your clipboard, please paste it in the opened website to login.\nAfter that, click \"Confirm Sign-in\" to finish." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户代码已复制到剪贴板,请在打开的网页中粘贴以完成登录。\n完成后点击「确认登录」结束流程。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用者代碼已複製到剪貼簿,請在開啟的網頁中貼上以完成登入。\n完成後點按「確認登入」結束流程。" + } + } + } + }, + "Thinking...": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thinking..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "思考中……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "思考中……" + } + } + } + }, + "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该命令可以在不打开聊天面板的情况下,向一个临时会话发送消息,特别适合一次性的命令,例如用 `/shell` 执行终端命令。比如把提示词设为 `/shell open .`,即可在访达中打开该项目。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "該命令可以在不開啟聊天面板的情況下,向一個暫時對話傳送訊息,特別適合一次性的命令,例如用 `/shell` 執行終端機命令。例如把提示詞設為 `/shell open .`,即可在 Finder 中開啟該專案。" + } + } + } + }, + "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该命令会打开「提示词转代码」面板,并对选中的代码执行你给出的需求。你也可以通过「附加上下文」提供更多信息。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "該命令會開啟「提示詞轉程式碼」面板,並對選取的程式碼執行你給出的需求。你也可以透過「附加上下文」提供更多資訊。" + } + } + } + }, + "This command sends a message to the active chat tab. You can provide additional context as well. The additional context will be removed once a message is sent. If the message provided is empty, you can manually type the message in the chat.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This command sends a message to the active chat tab. You can provide additional context as well. The additional context will be removed once a message is sent. If the message provided is empty, you can manually type the message in the chat." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该命令会向当前聊天标签页发送一条消息,也可以附带额外的上下文;消息发出后附加上下文会被清除。如果没有填写消息内容,你可以在聊天中手动输入。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "該命令會向目前聊天標籤頁傳送一則訊息,也可以附帶額外的上下文;訊息送出後附加上下文會被清除。如果沒有填寫訊息內容,你可以在聊天中手動輸入。" + } + } + } + }, + "This command will overwrite the context of the chat. You can use it to switch to different contexts in the chat. If a message is provided, it will be sent to the chat as well.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This command will overwrite the context of the chat. You can use it to switch to different contexts in the chat. If a message is provided, it will be sent to the chat as well." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该命令会覆盖聊天的上下文,可用来在不同上下文之间切换。如果填写了消息内容,也会一并发送到聊天中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "該命令會覆寫聊天的上下文,可用來在不同上下文之間切換。如果填寫了訊息內容,也會一併傳送到聊天中。" + } + } + } + }, + "Title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標題" + } + } + } + }, + "To refresh the theme, you must activate the extension service app once.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To refresh the theme, you must activate the extension service app once." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要刷新主题,需要先激活一次扩展服务应用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要更新佈景主題,需要先啟用一次擴充服務應用程式。" + } + } + } + }, + "Toast for the reason of re-activation of Xcode Inspector": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toast for the reason of re-activation of Xcode Inspector" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以提示条显示重新激活 Xcode Inspector 的原因" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以提示訊息顯示重新啟用 Xcode Inspector 的原因" + } + } + } + }, + "Toggle Real-time Suggestions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Real-time Suggestions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换实时建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換即時建議" + } + } + } + }, + "Token": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + } + } + }, + "Topic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Topic" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "主題" + } + } + } + }, + "Trigger command with Accessibility API": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trigger command with Accessibility API" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过辅助功能 API 触发命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過輔助使用 API 觸發命令" + } + } + } + }, + "Trigger malfunctioning detection only with events": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trigger malfunctioning detection only with events" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅在收到事件时触发异常检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅在收到事件時觸發異常偵測" + } + } + } + }, + "UI": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "UI" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "界面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "介面" + } + } + } + }, + "URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網址" + } + } + } + }, + "URL is invalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "URL is invalid" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "地址无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網址無效" + } + } + } + }, + "Uninstall": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstall" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "解除安裝" + } + } + } + }, + "Uninstalling old version..": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstalling old version.." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在卸载旧版本……" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在解除安裝舊版本……" + } + } + } + }, + "Unknown Model (Use Custom Model Instead)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Model (Use Custom Model Instead)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知模型(改用自定义模型)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知模型(改用自訂模型)" + } + } + } + }, + "Unknown Strategy (Use Default Strategy Instead)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Strategy (Use Default Strategy Instead)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知策略(改用默认策略)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知策略(改用預設策略)" + } + } + } + }, + "Update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, + "Use Cloudflare domain name for license check": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Cloudflare domain name for license check" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 Cloudflare 域名进行许可证校验" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 Cloudflare 網域進行授權驗證" + } + } + } + }, + "Use custom scroll view workaround for smooth scrolling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use custom scroll view workaround for smooth scrolling" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用自定义滚动视图以获得更顺滑的滚动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用自訂捲動視圖以取得更順暢的捲動" + } + } + } + }, + "Use the default model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use the default model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用默认模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用預設模型" + } + } + } + }, + "Used %lld references": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used %lld references" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "引用了 %lld 处参考" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "引用了 %lld 處參考" + } + } + } + }, + "Usercode is empty.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usercode is empty." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户代码为空。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用者代碼為空。" + } + } + } + }, + "Username": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用者名稱" + } + } + } + }, + "Utility chat model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Utility chat model" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助聊天模型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輔助聊天模型" + } + } + } + }, + "Value": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Value" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "值" + } + } + } + }, + "Verbose Log": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verbose Log" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详细日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "詳細記錄" + } + } + } + }, + "Verbose Log (writes prompts including your source code to Console.app)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verbose Log (writes prompts including your source code to Console.app)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详细日志(会把包含你源代码的提示词写入“控制台”应用)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "詳細記錄(會把包含你原始碼的提示詞寫入「主控台」應用程式)" + } + } + } + }, + "Verbose log": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verbose log" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详细日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "詳細記錄" + } + } + } + }, + "Verification URI is incorrect.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification URI is incorrect." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "验证地址不正确。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "驗證網址不正確。" + } + } + } + }, + "Voyage (OpenAI Compatible)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Voyage (OpenAI Compatible)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Voyage(OpenAI 兼容)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Voyage(OpenAI 相容)" + } + } + } + }, + "Warning message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning message" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "警告消息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "警告訊息" + } + } + } + }, + "Web": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁" + } + } + } + }, + "Web Search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web Search" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网页搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁搜尋" + } + } + } + }, + "When Xcode is active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When Xcode is active" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当 Xcode 处于活动状态时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當 Xcode 為使用中視窗時" + } + } + } + }, + "Widget color scheme": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Widget color scheme" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬浮组件配色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "懸浮元件配色" + } + } + } + }, + "Widget position": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Widget position" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬浮组件位置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "懸浮元件位置" + } + } + } + }, + "Wrap code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Wrap code" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代码自动换行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "程式碼自動換行" + } + } + } + }, + "Wrap text in code block": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Wrap text in code block" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代码块内文本自动换行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "程式碼區塊內文字自動換行" + } + } + } + }, + "Write or Edit Code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write or Edit Code" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编写或修改代码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "撰寫或修改程式碼" + } + } + } + }, + "Write or modify code with natural language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write or modify code with natural language" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用自然语言编写或修改代码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用自然語言撰寫或修改程式碼" + } + } + } + }, + "Xcode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Xcode" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Xcode" + } + } + } + }, + "Xcode Inspector Debug": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode Inspector Debug" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Xcode Inspector 调试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Xcode Inspector 除錯" + } + } + } + }, + "Xcode related features": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode related features" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "与 Xcode 相关的功能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "與 Xcode 相關的功能" + } + } + } + }, + "You can enter either an absolute path or a path relative to the project root.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can enter either an absolute path or a path relative to the project root." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可以输入绝对路径,也可以输入相对于项目根目录的路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可以輸入絕對路徑,也可以輸入相對於專案根目錄的路徑。" + } + } + } + }, + "You will be prompted to grant the app permission to send notifications for the first time.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You will be prompted to grant the app permission to send notifications for the first time." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "首次使用时,系统会请求你授予本应用发送通知的权限。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "首次使用時,系統會請求你授予本應用程式傳送通知的權限。" + } + } + } + }, + "You will be redirected to codeium.com. Please paste the generated token below and click the \"Sign In\" button.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You will be redirected to codeium.com. Please paste the generated token below and click the \"Sign In\" button." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统会跳转到 codeium.com。请把生成的 token 粘贴到下方,然后点击「登录」按钮。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統會跳轉到 codeium.com。請把產生的 token 貼到下方,然後點按「登入」按鈕。" + } + } + } + }, + "current selection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "current selection" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前选中内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前選取內容" + } + } + } + }, + "xxx.xxx.xxx.xxx, leave it blank to disable proxy.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "xxx.xxx.xxx.xxx, leave it blank to disable proxy." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "xxx.xxx.xxx.xxx,留空则不使用代理。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "xxx.xxx.xxx.xxx,留空則不使用代理。" + } + } + } + } + }, + "version": "1.0" +} diff --git a/README.md b/README.md index c4066a45..6069ca7a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - Chat - Modification - Custom Commands to extend Chat and Modification. +- Custom Model (built-in; OpenAI / OpenAI compatible / Responses API / Claude / Gemini / Ollama / Completion / FIM / Tabby) +- Localized interface: English, 简体中文, 繁體中文. Pick one under General → Language, or leave it on "Follow System". The choice is shared by the host app, the extension service and the Xcode editor commands, and takes effect after a restart. ## Table of Contents @@ -32,6 +34,7 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Setting Up Suggestion Feature](#setting-up-suggestion-feature) - [Setting Up GitHub Copilot](#setting-up-github-copilot) - [Setting Up Codeium](#setting-up-codeium) + - [Setting Up Custom Model](#setting-up-custom-model) - [Setting Up Chat Feature](#setting-up-chat-feature) - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) - [Update](#update) @@ -54,6 +57,8 @@ For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wi ## Prerequisites +- A Mac with Apple Silicon. Intel Macs are not supported; the app ships an arm64-only binary. +- macOS 15.6. - Public network connection. For suggestion features: @@ -183,6 +188,16 @@ The installed language server is located at `~/Library/Application Support/com.i The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`. +#### Setting Up Custom Model + +1. In the host app, go to **Service › Chat Models** and create or update a model. +2. Go to **Service › Custom Model**, pick that model (or Completion / FIM / Tabby), and set API options for the selected chat model. +3. Go to **Feature › Suggestion** and set the suggestion provider to **Custom Model**. + +Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream. + +Custom Model provider merged from [intitni/CustomSuggestionServiceForCopilotForXcode](https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode) (MIT). + ### Setting Up Chat Feature 1. In the host app, navigate to "Service - Chat Model". diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index c9ebe525..bece20a5 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -50,6 +50,13 @@ "name" : "ServiceUpdateMigrationTests" } }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "LaunchAgentManagerTests", + "name" : "LaunchAgentManagerTests" + } + }, { "target" : { "containerPath" : "container:Tool", @@ -182,6 +189,20 @@ "identifier" : "TokenEncoderTests", "name" : "TokenEncoderTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "CustomSuggestionServiceTests", + "name" : "CustomSuggestionServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "HostAppTests", + "name" : "HostAppTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index f303e44c..1b5cb437 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "Tool", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), @@ -52,6 +52,7 @@ let package = Package( .library(name: "CommandHandler", targets: ["CommandHandler"]), .library(name: "CodeDiff", targets: ["CodeDiff"]), .library(name: "BuiltinExtension", targets: ["BuiltinExtension"]), + .library(name: "CustomSuggestionService", targets: ["CustomSuggestionService"]), .library(name: "WebSearchService", targets: ["WebSearchService"]), .library(name: "WebScrapper", targets: ["WebScrapper"]), .library( @@ -73,7 +74,7 @@ let package = Package( .package(url: "https://github.com/intitni/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - exact: "1.16.1" + exact: "1.26.2" ), .package(url: "https://github.com/apple/swift-syntax.git", from: "600.0.0"), .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), @@ -94,7 +95,10 @@ let package = Package( targets: [ // MARK: - Helpers - .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger"]), + .target( + name: "XPCShared", + dependencies: ["SuggestionBasic", "Logger", "ObjectiveCExceptionHandling"] + ), .target(name: "Configs"), @@ -460,6 +464,28 @@ let package = Package( ] ), + // MARK: - Custom Model Suggestion + + .target( + name: "CustomSuggestionService", + dependencies: [ + "AIModel", + "Preferences", + "Keychain", + "Logger", + "BuiltinExtension", + "OpenAIService", + "JoinJSON", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), + .product(name: "Parsing", package: "swift-parsing"), + ] + ), + .testTarget( + name: "CustomSuggestionServiceTests", + dependencies: ["CustomSuggestionService", "AIModel", "Preferences", "JoinJSON"] + ), + // MARK: - OpenAI .target( diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index 145d0298..5472d479 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -180,7 +180,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { case .azureOpenAI: let baseURL = info.baseURL let deployment = info.modelName - let version = "2024-02-15-preview" + let version = "2024-09-01-preview" if baseURL.isEmpty { return "" } return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" case .googleAI: diff --git a/Tool/Sources/AIModel/CompletionModel.swift b/Tool/Sources/AIModel/CompletionModel.swift new file mode 100644 index 00000000..3ba7ed95 --- /dev/null +++ b/Tool/Sources/AIModel/CompletionModel.swift @@ -0,0 +1,102 @@ +import CodableWrappers +import Foundation + +/// A completion model. +public struct CompletionModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case openAI + case azureOpenAI + case openAICompatible + case ollama + + case unknown + } + + public struct Info: Codable, Equatable { + public typealias OllamaInfo = ChatModel.Info.OllamaInfo + public typealias OpenAIInfo = ChatModel.Info.OpenAIInfo + + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var isFullURL: Bool + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var modelName: String + + @FallbackDecoding + public var openAIInfo: OpenAIInfo + @FallbackDecoding + public var ollamaInfo: OllamaInfo + + public init( + apiKeyName: String = "", + baseURL: String = "", + isFullURL: Bool = false, + maxTokens: Int = 4000, + modelName: String = "", + openAIInfo: OpenAIInfo = OpenAIInfo(), + ollamaInfo: OllamaInfo = OllamaInfo() + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.isFullURL = isFullURL + self.maxTokens = maxTokens + self.modelName = modelName + self.openAIInfo = openAIInfo + self.ollamaInfo = ollamaInfo + } + } + + public var endpoint: String { + switch format { + case .openAI: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/completions" } + return "\(baseURL)/v1/completions" + case .openAICompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/completions" } + if info.isFullURL { return baseURL } + return "\(baseURL)/v1/completions" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.modelName + let version = "2023-07-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/completions?api-version=\(version)" + case .ollama: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/generate" } + return "\(baseURL)/api/generate" + case .unknown: + return "" + } + } +} + +public struct EmptyCompletionModelInfo: FallbackValueProvider { + public static var defaultValue: CompletionModel.Info { .init() } +} + +public struct EmptyCompletionModelFormat: FallbackValueProvider { + public static var defaultValue: CompletionModel.Format { .unknown } +} + diff --git a/Tool/Sources/AIModel/CustomModelType.swift b/Tool/Sources/AIModel/CustomModelType.swift new file mode 100644 index 00000000..82091fef --- /dev/null +++ b/Tool/Sources/AIModel/CustomModelType.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum CustomModelType: String, CaseIterable { + case completionModel + case fimModel + case tabby + + public static var `default`: CustomModelType { + .completionModel + } +} diff --git a/Tool/Sources/AIModel/FIMModel.swift b/Tool/Sources/AIModel/FIMModel.swift new file mode 100644 index 00000000..7d1aaac4 --- /dev/null +++ b/Tool/Sources/AIModel/FIMModel.swift @@ -0,0 +1,105 @@ +import CodableWrappers +import Foundation + +/// A completion model. +public struct FIMModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case mistral + case ollama + case ollamaCompatible + + case unknown + } + + public struct Info: Codable, Equatable { + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var isFullURL: Bool + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var modelName: String + @FallbackDecoding + public var authenticationMode: AuthenticationMode + @FallbackDecoding + public var authenticationHeaderFieldName: String + + public enum AuthenticationMode: Codable, Equatable, CaseIterable { + case header + case bearerToken + } + + @FallbackDecoding + public var ollamaInfo: ChatModel.Info.OllamaInfo + + public init( + apiKeyName: String = "", + baseURL: String = "", + isFullURL: Bool = false, + maxTokens: Int = 4000, + modelName: String = "", + authenticationMode: AuthenticationMode = .bearerToken, + authenticationHeaderFieldName: String = "", + ollamaInfo: ChatModel.Info.OllamaInfo = ChatModel.Info.OllamaInfo() + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.isFullURL = isFullURL + self.maxTokens = maxTokens + self.modelName = modelName + self.ollamaInfo = ollamaInfo + self.authenticationMode = authenticationMode + self.authenticationHeaderFieldName = authenticationHeaderFieldName + } + } + + public var endpoint: String { + switch format { + case .mistral: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.mistral.ai/v1/fim/completions" } + if info.isFullURL { return baseURL } + return "\(baseURL)/v1/fim/completions" + case .ollama: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/generate" } + return "\(baseURL)/api/generate" + case .ollamaCompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/generate" } + if info.isFullURL { return baseURL } + return "\(baseURL)/api/generate" + case .unknown: + return "" + } + } +} + +public struct EmptyFIMModelInfo: FallbackValueProvider { + public static var defaultValue: FIMModel.Info { .init() } +} + +public struct EmptyFIMModelFormat: FallbackValueProvider { + public static var defaultValue: FIMModel.Format { .unknown } +} + +public struct EmptyFIMModelAuthenticationMode: FallbackValueProvider { + public static var defaultValue: FIMModel.Info.AuthenticationMode { .bearerToken } +} diff --git a/Tool/Sources/AIModel/KnownModelNames.swift b/Tool/Sources/AIModel/KnownModelNames.swift new file mode 100644 index 00000000..b8f93b3d --- /dev/null +++ b/Tool/Sources/AIModel/KnownModelNames.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Display names for custom-suggestion model dropdowns. Not a live provider catalog. +public enum KnownChatCompletionModels: String, CaseIterable { + case gpt55 = "gpt-5.5" + case gpt54 = "gpt-5.4" + case gpt41 = "gpt-4.1" + case gpt41Mini = "gpt-4.1-mini" + case gpt41Nano = "gpt-4.1-nano" + case gpt4o = "gpt-4o" + case gpt4oMini = "gpt-4o-mini" + case gpt4Turbo = "gpt-4-turbo" + case gpt4 = "gpt-4" + case o3 = "o3" + case o3Mini = "o3-mini" + case o4Mini = "o4-mini" + case o1 = "o1" + case o1Pro = "o1-pro" + case o1Preview = "o1-preview" + case gpt35Turbo = "gpt-3.5-turbo" + + public var maxToken: Int { + switch self { + case .gpt55, .gpt54, .gpt41, .gpt41Mini, .gpt41Nano: + return 1_047_576 + case .gpt4o, .gpt4oMini, .gpt4Turbo: + return 128_000 + case .gpt4: + return 8192 + case .o3, .o3Mini, .o4Mini, .o1, .o1Pro: + return 200_000 + case .o1Preview: + return 128_000 + case .gpt35Turbo: + return 16385 + } + } +} + +/// Completions-endpoint names (`/v1/completions`), not chat completions. +public enum KnownCompletionModels: String, CaseIterable { + case gpt35TurboInstruct = "gpt-3.5-turbo-instruct" + + public var maxToken: Int { + switch self { + case .gpt35TurboInstruct: + return 4096 + } + } +} + +public enum KnownGeminiModels: String, CaseIterable { + case gemini25FlashPreview = "gemini-2.5-flash-preview-04-17" + case gemini25ProPreview = "gemini-2.5-pro-preview-05-06" + case gemini20Flash = "gemini-2.0-flash" + case gemini20FlashLite = "gemini-2.0-flash-lite" + case gemini15Pro = "gemini-1.5-pro" + case gemini15Flash = "gemini-1.5-flash" + case geminiPro = "gemini-pro" + + public var maxToken: Int { + switch self { + case .geminiPro: + return 32768 + case .gemini15Pro: + return 2_097_152 + case .gemini25FlashPreview, .gemini25ProPreview, .gemini20Flash, .gemini20FlashLite, + .gemini15Flash: + return 1_048_576 + } + } +} diff --git a/Tool/Sources/AIModel/TabbyModel.swift b/Tool/Sources/AIModel/TabbyModel.swift new file mode 100644 index 00000000..75af101d --- /dev/null +++ b/Tool/Sources/AIModel/TabbyModel.swift @@ -0,0 +1,41 @@ +import CodableWrappers +import Foundation + +public struct TabbyModel: Codable, Equatable { + public enum AuthorizationMode: String, Codable, CaseIterable { + case none + case bearerToken + case basic + case customHeaderField + } + + @FallbackDecoding + public var url: String + @FallbackDecoding + public var authorizationMode: AuthorizationMode + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var authorizationHeaderName: String + @FallbackDecoding + public var username: String + + public init( + url: String, + authorizationMode: AuthorizationMode, + apiKeyName: String, + authorizationHeaderName: String, + username: String + ) { + self.url = url + self.authorizationMode = authorizationMode + self.apiKeyName = apiKeyName + self.authorizationHeaderName = authorizationHeaderName + self.username = username + } +} + +public struct EmptyAuthorizationMode: FallbackValueProvider { + public static var defaultValue: TabbyModel.AuthorizationMode { .none } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift index 86832df5..3ebadf59 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -50,6 +50,8 @@ extension BuiltinExtensionManager { ext.extensionIdentifier == "com.github.copilot" case .codeium: ext.extensionIdentifier == "com.codeium" + case .customModel: + ext.extensionIdentifier == "com.intii.CopilotForXcode.CustomModel" } case let .extension(_, bundleIdentifier): ext.extensionIdentifier == bundleIdentifier diff --git a/Tool/Sources/BuiltinExtension/StreamingSuggestionServiceType.swift b/Tool/Sources/BuiltinExtension/StreamingSuggestionServiceType.swift new file mode 100644 index 00000000..6d67fe5f --- /dev/null +++ b/Tool/Sources/BuiltinExtension/StreamingSuggestionServiceType.swift @@ -0,0 +1,28 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionBasic +import SuggestionProvider + +/// A builtin suggestion service that can report partial results while it is still generating. +/// Element semantics are the same as ``StreamingSuggestionServiceProvider``. +public protocol StreamingSuggestionServiceType: CopilotForXcodeKit.SuggestionServiceType { + func streamSuggestions( + _ request: CopilotForXcodeKit.SuggestionRequest, + workspace: WorkspaceInfo + ) async -> AsyncThrowingStream<[CopilotForXcodeKit.CodeSuggestion], Error> +} + +extension BuiltinExtensionSuggestionServiceProvider: StreamingSuggestionServiceProvider { + public func streamSuggestions( + _ request: SuggestionProvider.SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async -> AsyncThrowingStream<[SuggestionBasic.CodeSuggestion], Error> { + guard let streaming = service as? StreamingSuggestionServiceType else { + return SuggestionStreams.single { + try await self.getSuggestions(request, workspaceInfo: workspaceInfo) + } + } + let upstream = await streaming.streamSuggestions(request.converted, workspace: workspaceInfo) + return SuggestionStreams.forward(upstream) { $0.map(\.converted) } + } +} diff --git a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift new file mode 100644 index 00000000..55c687ba --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift @@ -0,0 +1,281 @@ +import CopilotForXcodeKit +import Foundation +import AIModel +import Logger +import Preferences + +public actor AnthropicService { + let url: URL + let modelName: String + let contextWindow: Int + let maxToken: Int + let temperature: Double + let apiKey: String + let stopWords: [String] + let extraHeaders: [(name: String, value: String)] + let reasoningEffort: ReasoningEffort? + let customJSONBody: String + + init( + url: String? = nil, + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + apiKey: String, + extraHeaders: [(name: String, value: String)] = [], + reasoningEffort: ReasoningEffort? = nil, + customJSONBody: String = "" + ) { + self.extraHeaders = extraHeaders + self.reasoningEffort = reasoningEffort + self.customJSONBody = customJSONBody + self.url = url.flatMap(URL.init(string:)) ?? + URL(string: "https://api.anthropic.com/v1/messages")! + self.modelName = modelName + self.maxToken = maxToken + self.temperature = temperature + self.apiKey = apiKey + self.stopWords = stopWords + self.contextWindow = contextWindow + } +} + +// MARK: - CodeCompletionServiceType Implementation + +extension AnthropicService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingCompactMapSequence< + ResponseStream, + String + > + + func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence { + let (messages, systemPrompt) = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.content, $0.role.rawValue) + }) + let result = try await sendMessages(messages, systemPrompt: systemPrompt) + return result.compactMap { $0.delta?.text } + } +} + +// MARK: - Message Structure and Request Handling + +extension AnthropicService { + public struct Message: Codable { + public enum Role: String, Codable { + case user + case assistant + } + + var role: Role + var content: String + } + + struct OutputConfig: Codable, Equatable { + var effort: String + } + + struct MessageRequestBody: Codable { + var model: String + var messages: [Message] + var system: String? + var max_tokens: Int + var temperature: Double + var stream: Bool = true + var stop_sequences: [String]? + var output_config: OutputConfig? + + enum CodingKeys: String, CodingKey { + case model + case messages + case system + case max_tokens + case temperature + case stream + case stop_sequences + case output_config + } + } + + nonisolated static func outputConfig(for effort: ReasoningEffort?) -> OutputConfig? { + guard let effort else { return nil } + let value: String + switch effort { + case .low, .medium, .high: + value = effort.rawValue + case .none, .minimal: + value = "low" + case .xhigh: + value = "high" + } + return .init(effort: value) + } + + nonisolated static func makeMessageRequestBody( + modelName: String, + messages: [Message], + systemPrompt: String?, + maxToken: Int, + temperature: Double, + stopSequences: [String]?, + reasoningEffort: ReasoningEffort? + ) -> MessageRequestBody { + MessageRequestBody( + model: modelName, + messages: messages, + system: systemPrompt, + max_tokens: maxToken, + temperature: temperature, + stop_sequences: stopSequences, + output_config: outputConfig(for: reasoningEffort) + ) + } + + func createMessages(from request: PromptStrategy) -> (messages: [Message], system: String?) { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + + let systemPrompt = request.systemPrompt + + let messages = prompts.map { prompt in + Message( + role: prompt.role == .user ? .user : .assistant, + content: prompt.content + ) + } + + return (messages: messages, system: systemPrompt) + } + + func sendMessages(_ messages: [Message], systemPrompt: String?) async throws -> ResponseStream { + let validStopSequences = stopWords.filter { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + let requestBody = Self.makeMessageRequestBody( + modelName: modelName, + messages: messages, + systemPrompt: systemPrompt, + maxToken: maxToken, + temperature: temperature, + stopSequences: validStopSequences, + reasoningEffort: reasoningEffort + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(apiKey)", forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setExtraHeaders(extraHeaders) + + let encoder = JSONEncoder() + request.httpBody = CompletionJSON.mergeCustomBody( + try encoder.encode(requestBody), + jsonBody: customJSONBody + ) + + let (result, response) = try await URLSession.shared.bytes(for: request) + return try await CompletionHTTP.makeStream( + result: result, + response: response, + mapError: { Error.otherError($0) }, + parseBody: { try Self.parseMessageBody($0, url: url) }, + parseLine: Self.parseStreamLine + ) + } + + static func parseMessageBody(_ text: String, url: URL) throws -> StreamResponse { + if let message = APIErrorPayload.message(in: text) { + throw Error.apiError(APIError(type: "error", message: message, code: nil)) + } + do { + let body = try JSONDecoder().decode(StreamResponse.self, from: Data(text.utf8)) + let joined = (body.content ?? []).map(\.text).joined() + return StreamResponse( + type: body.type ?? "message", + delta: .init(text: joined, type: "text"), + index: nil, + content: body.content + ) + } catch let error as Error { + throw error + } catch { + throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url)) + } + } + + static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent { + guard let payload = SSELine.payload(of: line) else { + return .init(chunk: nil, done: false) + } + struct ErrorEvent: Decodable { + var type: String? + var error: APIError? + } + if let event = try? JSONDecoder().decode(ErrorEvent.self, from: Data(payload.utf8)), + event.type == "error" + { + if let error = event.error { + throw Error.apiError(error) + } + throw Error.otherError("Unknown error.") + } + do { + let chunk = try JSONDecoder().decode(StreamResponse.self, from: Data(payload.utf8)) + return .init(chunk: chunk, done: chunk.type == "message_stop") + } catch { + Logger.service.error("Error decoding chunk: \(error)") + throw error + } + } +} + +// MARK: - API Response Structures + +extension AnthropicService { + struct StreamResponse: Decodable { + var type: String? + var delta: Delta? + var index: Int? + var content: [Content]? + + struct Delta: Decodable { + var text: String? + var type: String? + } + + struct Content: Decodable { + var text: String + var type: String + } + } + + struct APIError: Decodable { + var type: String + var message: String + var code: String? + } + + enum Error: Swift.Error, LocalizedError { + case decodeError(Swift.Error) + case apiError(APIError) + case otherError(String) + + var errorDescription: String? { + switch self { + case let .decodeError(error): + return error.localizedDescription + case let .apiError(error): + return error.message + case let .otherError(message): + return message + } + } + } +} diff --git a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift new file mode 100644 index 00000000..10792637 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift @@ -0,0 +1,128 @@ +import Foundation +import AIModel +import GoogleGenerativeAI + +public struct GoogleGeminiService { + let modelName: String + let maxToken: Int + let contextWindow: Int + let temperature: Double + let stopWords: [String] + let apiKey: String + let baseURL: String + let apiVersion: String + + init( + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + apiKey: String, + baseURL: String = "", + apiVersion: String = "" + ) { + self.modelName = modelName + self.maxToken = maxToken + self.contextWindow = contextWindow + self.temperature = temperature + self.stopWords = stopWords + self.apiKey = apiKey + self.baseURL = baseURL + self.apiVersion = apiVersion + } +} + +extension GoogleGeminiService: CodeCompletionServiceType { + func getCompletion(_ request: PromptStrategy) async throws -> AsyncThrowingStream { + let messages = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.parts.first?.text ?? "N/A", $0.role ?? "N/A") + }) + return AsyncThrowingStream { continuation in + let task = Task { + do { + let result = try await sendMessages(messages) + try Task.checkCancellation() + continuation.yield(result) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +extension GoogleGeminiService { + public enum Error: Swift.Error, LocalizedError { + case apiError(Swift.Error) + case otherError(Swift.Error) + + public var errorDescription: String? { + switch self { + case let .apiError(error): + return "API error: \(error.localizedDescription)" + case let .otherError(error): + return "Error: \(error.localizedDescription)" + } + } + } + + func createMessages(from request: PromptStrategy) -> [ModelContent] { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return [ + .init( + role: "user", + parts: ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") + ), + ] + } + + func sendMessages(_ messages: [ModelContent]) async throws -> String { + let aiModel = GenerativeModel( + name: modelName, + apiKey: apiKey, + generationConfig: .init(GenerationConfig( + temperature: Float(temperature), + maxOutputTokens: maxToken, + stopSequences: stopWords + )), + baseURL: baseURL, + requestOptions: apiVersion.isEmpty + ? .init() + : .init(apiVersion: apiVersion) + ) + + do { + let response = try await aiModel.generateContent(messages) + + return response.candidates.first.map { + $0.content.parts.first(where: { part in + if let text = part.text { + return !text.isEmpty + } else { + return false + } + })?.text ?? "" + } ?? "" + } catch let error as GenerateContentError { + switch error { + case let .internalError(underlying): + throw Error.apiError(underlying) + default: + throw Error.apiError(error) + } + } catch { + throw Error.otherError(error) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift b/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift new file mode 100644 index 00000000..f9fa16ab --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift @@ -0,0 +1,172 @@ +import CopilotForXcodeKit +import Foundation +import AIModel +import Logger + +public actor MistralFIMService { + let url: URL + let model: String + let temperature: Double + let stopWords: [String] + let apiKey: String + let contextWindow: Int + let maxToken: Int + + init( + url: URL? = nil, + model: String, + temperature: Double, + stopWords: [String] = [], + apiKey: String, + contextWindow: Int, + maxToken: Int + ) { + self.url = url ?? URL(string: "https://api.mistral.ai/v1/fim/completions")! + self.model = model + self.temperature = temperature + self.stopWords = stopWords + self.apiKey = apiKey + self.contextWindow = contextWindow + self.maxToken = maxToken + } +} + +extension MistralFIMService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingCompactMapSequence< + ResponseStream, + String + > + + func getCompletion(_ request: any PromptStrategy) async throws -> CompletionSequence { + let result = try await send(request) + return result.compactMap { $0.choices?.first?.delta?.content ?? $0.choices?.first?.text } + } +} + +extension MistralFIMService { + struct RequestBody: Codable { + let model: String + let prompt: String + let suffix: String + let stream: Bool + let temperature: Double + let max_tokens: Int + } + + enum Error: Swift.Error, LocalizedError { + case decodeError(Swift.Error) + case otherError(String) + + public var errorDescription: String? { + switch self { + case let .decodeError(error): + return error.localizedDescription + case let .otherError(message): + return message + } + } + } + + struct StreamDataChunk: Decodable { + struct Delta: Decodable { + var role: OpenAIService.Message.Role? + var content: String? + } + + struct Choice: Decodable { + var index: Int + var delta: Delta? + var text: String? + var finish_reason: String? + var message: OpenAIService.Message? + } + + var id: String? + var object: String? + var model: String? + var choices: [Choice]? + } + + func send(_ request: any PromptStrategy) async throws -> ResponseStream { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + + let prefix = prompts.first { $0.role == .prefix }?.content ?? "" + let suffix = prompts.last { $0.role == .suffix }?.content ?? "" + + CodeCompletionLogger.logger.logPrompt([ + (prefix, "prefix"), + (suffix, "suffix"), + ]) + + var request = URLRequest(url: url) + let requestBody = RequestBody( + model: model, + prompt: prefix, + suffix: suffix, + stream: true, + temperature: temperature, + max_tokens: maxToken + ) + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let (result, response) = try await URLSession.shared.bytes(for: request) + return try await CompletionHTTP.makeStream( + result: result, + response: response, + mapError: { Error.otherError($0) }, + parseBody: { try Self.parseBody($0, url: url) }, + parseLine: Self.parseStreamLine + ) + } + + static func parseBody(_ text: String, url: URL) throws -> StreamDataChunk { + if let message = APIErrorPayload.message(in: text) { + throw Error.otherError(message) + } + do { + var chunk = try JSONDecoder().decode(StreamDataChunk.self, from: Data(text.utf8)) + if let choice = chunk.choices?.first, + choice.delta?.content == nil, + choice.text == nil, + let content = choice.message?.content + { + chunk.choices = [ + .init( + index: choice.index, + delta: .init(role: choice.message?.role, content: content), + text: content, + finish_reason: choice.finish_reason ?? "stop", + message: choice.message + ), + ] + } + return chunk + } catch let error as Error { + throw error + } catch { + throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url)) + } + } + + static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent { + guard let payload = SSELine.payload(of: line) else { + return .init(chunk: nil, done: false) + } + if payload == "[DONE]" { return .init(chunk: nil, done: true) } + do { + let chunk = try JSONDecoder().decode(StreamDataChunk.self, from: Data(payload.utf8)) + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } catch { + Logger.service.error(error) + throw error + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift new file mode 100644 index 00000000..54ca3c2e --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift @@ -0,0 +1,312 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +public actor OllamaService { + let url: URL + let endpoint: Endpoint + let modelName: String + let maxToken: Int + let contextWindow: Int + let temperature: Double + let stopWords: [String] + let keepAlive: String + let format: ResponseFormat + let authenticationMode: AuthenticationMode? + let extraHeaders: [(name: String, value: String)] + + enum AuthenticationMode { + case bearerToken(String) + case header(name: String, value: String) + } + + public enum ResponseFormat: String { + case none = "" + case json + } + + public enum Endpoint { + case completion + case chatCompletion + case completionWithSuffix + } + + init( + url: String? = nil, + endpoint: Endpoint, + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + keepAlive: String = "", + format: ResponseFormat = .none, + authenticationMode: AuthenticationMode? = nil, + extraHeaders: [(name: String, value: String)] = [] + ) { + self.url = url.flatMap(URL.init(string:)) ?? { + switch endpoint { + case .chatCompletion: + URL(string: "http://127.0.0.1:11434/api/chat")! + case .completion, .completionWithSuffix: + URL(string: "http://127.0.0.1:11434/api/generate")! + } + }() + + self.endpoint = endpoint + self.modelName = modelName + self.maxToken = maxToken + self.temperature = temperature + self.stopWords = stopWords + self.keepAlive = keepAlive + self.format = format + self.contextWindow = contextWindow + self.authenticationMode = authenticationMode + self.extraHeaders = extraHeaders + } +} + +extension OllamaService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingCompactMapSequence< + ResponseStream, + String + > + + func getCompletion( + _ request: PromptStrategy + ) async throws -> CompletionSequence { + switch endpoint { + case .chatCompletion: + let messages = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.content, $0.role.rawValue) + }) + let stream = try await sendMessages(messages) + return stream.compactMap { $0.message?.content } + case .completion: + let prompt = createPrompt(from: request) + CodeCompletionLogger.logger.logPrompt([(prompt, "user")]) + let stream = try await sendPrompt(prompt, raw: request.promptIsRaw) + return stream.compactMap { $0.response } + case .completionWithSuffix: + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + + let prefix = prompts.first { $0.role == .prefix }?.content ?? "" + let suffix = prompts.last { $0.role == .suffix }?.content ?? "" + + CodeCompletionLogger.logger.logPrompt([ + (prefix, "prefix"), + (suffix, "suffix"), + ]) + + let stream = try await sendPrompt(prefix, suffix: suffix) + return stream.compactMap { $0.response } + } + } +} + +extension OllamaService { + struct Message: Codable, Equatable { + public enum Role: String, Codable { + case user + case assistant + case system + } + + /// The role of the message. + public var role: Role + /// The content of the message. + public var content: String + } + + enum Error: Swift.Error, LocalizedError { + case decodeError(Swift.Error) + case otherError(String) + + public var errorDescription: String? { + switch self { + case let .decodeError(error): + return error.localizedDescription + case let .otherError(message): + return message + } + } + } +} + +// MARK: - Chat Completion API + +/// https://github.com/ollama/ollama/blob/main/docs/api.md#chat-request-streaming +extension OllamaService { + struct ChatCompletionRequestBody: Codable { + struct Options: Codable { + var temperature: Double + var stop: [String] + var num_predict: Int + var top_k: Int? + var top_p: Double? + } + + var model: String + var messages: [Message] + var stream: Bool + var options: Options + var keep_alive: String? + var format: String? + } + + struct ChatCompletionResponseChunk: Decodable { + var model: String + var message: Message? + var response: String? + var done: Bool + var total_duration: Int64? + var load_duration: Int64? + var prompt_eval_count: Int? + var prompt_eval_duration: Int64? + var eval_count: Int? + var eval_duration: Int64? + } + + func createMessages(from request: PromptStrategy) -> [Message] { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return [ + .init(role: .system, content: request.systemPrompt), + ] + prompts.map { prompt in + switch prompt.role { + case .user: + return .init(role: .user, content: prompt.content) + case .assistant: + return .init(role: .assistant, content: prompt.content) + } + } + } + + func sendMessages(_ messages: [Message]) async throws + -> ResponseStream + { + let requestBody = ChatCompletionRequestBody( + model: modelName, + messages: messages, + stream: true, + options: .init( + temperature: temperature, + stop: stopWords, + num_predict: maxToken + ), + keep_alive: keepAlive.isEmpty ? nil : keepAlive, + format: format == .none ? nil : format.rawValue + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setExtraHeaders(extraHeaders) + let (result, response) = try await URLSession.shared.bytes(for: request) + return try await CompletionHTTP.makeStream( + result: result, + response: response, + mapError: { Error.otherError($0) }, + parseBody: { try Self.parseChunkBody($0, url: url) }, + parseLine: Self.parseStreamLine + ) + } + + static func parseChunkBody(_ text: String, url: URL) throws -> ChatCompletionResponseChunk { + do { + return try JSONDecoder().decode(ChatCompletionResponseChunk.self, from: Data(text.utf8)) + } catch { + throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url)) + } + } + + static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent { + let chunk = try JSONDecoder().decode( + ChatCompletionResponseChunk.self, + from: line.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.done) + } +} + +// MARK: - Completion API + +extension OllamaService { + struct CompletionRequestBody: Codable { + var model: String + var prompt: String + var stream: Bool + var options: ChatCompletionRequestBody.Options + var keep_alive: String? + var format: String? + var raw: Bool? + var suffix: String? + } + + func createPrompt(from request: PromptStrategy) -> String { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + func sendPrompt( + _ prompt: String, + raw: Bool? = nil, + suffix: String? = nil + ) async throws -> ResponseStream { + let requestBody = CompletionRequestBody( + model: modelName, + prompt: prompt, + stream: true, + options: .init( + temperature: temperature, + stop: stopWords, + num_predict: maxToken + ), + keep_alive: keepAlive.isEmpty ? nil : keepAlive, + format: format == .none ? nil : format.rawValue, + raw: raw, + suffix: suffix + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + switch authenticationMode{ + case .none: + break + case let .bearerToken(key): + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + case let .header(name, value): + request.setValue(value, forHTTPHeaderField: name) + } + request.setExtraHeaders(extraHeaders) + + let (result, response) = try await URLSession.shared.bytes(for: request) + return try await CompletionHTTP.makeStream( + result: result, + response: response, + mapError: { Error.otherError($0) }, + parseBody: { try Self.parseChunkBody($0, url: url) }, + parseLine: Self.parseStreamLine + ) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift new file mode 100644 index 00000000..4802a00b --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift @@ -0,0 +1,344 @@ +import AIModel +import CopilotForXcodeKit +import Foundation +import Logger +import Preferences + +/// `POST /v1/responses` +/// +/// https://platform.openai.com/docs/api-reference/responses/create +public actor OpenAIResponsesService { + let url: URL + let modelName: String + let contextWindow: Int + let maxToken: Int + let apiKey: String + let stopWords: [String] + let reasoningEffort: ReasoningEffort? + let reasoningTokenBudget: Int + let extraHeaders: [(name: String, value: String)] + let organizationID: String + let projectID: String + let customJSONBody: String + let requiresBeginWithUserMessage: Bool + let enforceMessageOrder: Bool + + init( + url: String? = nil, + modelName: String, + contextWindow: Int, + maxToken: Int, + stopWords: [String] = [], + apiKey: String, + reasoningEffort: ReasoningEffort? = nil, + reasoningTokenBudget: Int = 1500, + extraHeaders: [(name: String, value: String)] = [], + organizationID: String = "", + projectID: String = "", + customJSONBody: String = "", + requiresBeginWithUserMessage: Bool = false, + enforceMessageOrder: Bool = false + ) { + self.url = url.flatMap(URL.init(string:)) + ?? URL(string: "https://api.openai.com/v1/responses")! + self.modelName = modelName + self.contextWindow = contextWindow + self.maxToken = maxToken + self.apiKey = apiKey + self.stopWords = stopWords + self.reasoningEffort = reasoningEffort + self.reasoningTokenBudget = reasoningTokenBudget + self.extraHeaders = extraHeaders + self.organizationID = organizationID + self.projectID = projectID + self.customJSONBody = customJSONBody + self.requiresBeginWithUserMessage = requiresBeginWithUserMessage + self.enforceMessageOrder = enforceMessageOrder + } +} + +extension OpenAIResponsesService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingCompactMapSequence< + ResponseStream, + String + > + + func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence { + let (instructions, input) = createInput(from: request) + CodeCompletionLogger.logger.logPrompt( + [(instructions, "instructions")] + input.map { + ($0.content.map(\.text).joined(), $0.role) + } + ) + let result = try await send(instructions: instructions, input: input) + return result.compactMap { $0.text } + } +} + +// MARK: - Request + +extension OpenAIResponsesService { + struct ContentPart: Encodable { + var type: String + var text: String + } + + struct InputItem: Encodable { + var role: String + var content: [ContentPart] + } + + struct RequestBody: Encodable { + struct Reasoning: Encodable { + var effort: String + } + + var model: String + var input: [InputItem] + var instructions: String? + var stream: Bool + var max_output_tokens: Int + var store: Bool + var temperature: Double? + var reasoning: Reasoning? + } + + nonisolated static func makeRequestBody( + modelName: String, + input: [InputItem], + instructions: String?, + maxToken: Int, + reasoningEffort: ReasoningEffort?, + reasoningTokenBudget: Int + ) -> RequestBody { + RequestBody( + model: modelName, + input: input, + instructions: instructions, + stream: true, + max_output_tokens: ReasoningTokenLimit.maxOutputTokens( + maxToken: maxToken, + effort: reasoningEffort, + budget: reasoningTokenBudget + ), + store: false, + temperature: reasoningEffort == nil ? 0.2 : nil, + reasoning: reasoningEffort.map { .init(effort: $0.rawValue) } + ) + } + + func createInput(from request: PromptStrategy) -> (instructions: String, input: [InputItem]) { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + var ordered: [OrderedChatMessage] = [ + .init(role: .system, content: request.systemPrompt), + ] + for prompt in prompts { + switch prompt.role { + case .user: + ordered.append(.init(role: .user, content: prompt.content)) + case .assistant: + ordered.append(.init(role: .assistant, content: prompt.content)) + } + } + ordered = OrderedChatMessages.apply( + ordered, + requiresBeginWithUserMessage: requiresBeginWithUserMessage, + enforceMessageOrder: enforceMessageOrder + ) + let instructions = ordered.filter { $0.role == .system }.map(\.content) + .joined(separator: "\n\n") + let input = ordered.compactMap { message -> InputItem? in + switch message.role { + case .system: + return nil + case .user: + return .init( + role: "user", + content: [.init(type: "input_text", text: message.content)] + ) + case .assistant: + return .init( + role: "assistant", + content: [.init(type: "output_text", text: message.content)] + ) + } + } + return (instructions, input) + } + + func send(instructions: String, input: [InputItem]) async throws -> ResponseStream { + let requestBody = Self.makeRequestBody( + modelName: modelName, + input: input, + instructions: instructions.isEmpty ? nil : instructions, + maxToken: maxToken, + reasoningEffort: reasoningEffort, + reasoningTokenBudget: reasoningTokenBudget + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = CompletionJSON.mergeCustomBody( + try JSONEncoder().encode(requestBody), + jsonBody: customJSONBody + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID) + request.setExtraHeaders(extraHeaders) + let (result, response) = try await URLSession.shared.bytes(for: request) + return try await CompletionHTTP.makeStream( + result: result, + response: response, + mapError: { Error.otherError($0) }, + parseBody: { try Self.parseNonStreamingBody($0, url: url) }, + parseLine: Self.parseStreamLine + ) + } + + static func parseNonStreamingBody(_ text: String, url: URL) throws -> StreamChunk { + if let message = APIErrorPayload.message(in: text) { + throw Error.apiError(message) + } + do { + let object = try JSONDecoder().decode(ResponseObject.self, from: Data(text.utf8)) + if let message = object.error?.message { + throw Error.apiError(message) + } + return StreamChunk(text: object.outputText) + } catch let error as Error { + throw error + } catch { + throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url)) + } + } +} + +// MARK: - Response + +extension OpenAIResponsesService { + /// A text fragment extracted from one stream line. + struct StreamChunk { + var text: String? + } + + struct APIError: Decodable { + var message: String? + var code: String? + } + + struct OutputContent: Decodable { + var type: String? + var text: String? + } + + struct OutputItem: Decodable { + var type: String? + var content: [OutputContent]? + } + + struct IncompleteDetails: Decodable { + var reason: String? + } + + struct Usage: Decodable { + struct OutputTokensDetails: Decodable { + var reasoning_tokens: Int? + } + + var output_tokens: Int? + var output_tokens_details: OutputTokensDetails? + } + + struct ResponseObject: Decodable { + var id: String? + var object: String? + var status: String? + var output: [OutputItem]? + var error: APIError? + var incomplete_details: IncompleteDetails? + var usage: Usage? = nil + + var outputText: String { + (output ?? []) + .filter { $0.type == "message" } + .flatMap { $0.content ?? [] } + .filter { $0.type == "output_text" } + .compactMap(\.text) + .joined() + } + } + + /// Decodes both a stream event and a bare (non-streaming) response object. + struct StreamEvent: Decodable { + var type: String? + var delta: String? + var message: String? + var error: APIError? + var response: ResponseObject? + // Fields of a bare response object. + var object: String? + var output: [OutputItem]? + } + + enum Error: Swift.Error, LocalizedError { + case apiError(String) + case otherError(String) + + var errorDescription: String? { + switch self { + case let .apiError(message), let .otherError(message): + return message + } + } + } + + /// Parses one line of the event stream. + static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent { + guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) } + if payload == "[DONE]" { return .init(chunk: nil, done: true) } + if let message = APIErrorPayload.message(in: payload) { + throw Error.apiError(message) + } + let event = try JSONDecoder().decode(StreamEvent.self, from: Data(payload.utf8)) + switch event.type { + case "response.output_text.delta": + return .init(chunk: .init(text: event.delta), done: false) + case "response.completed": + if let usage = event.response?.usage { + CodeCompletionLogger.logger.logUsage( + outputTokens: usage.output_tokens, + reasoningTokens: usage.output_tokens_details?.reasoning_tokens + ) + } + return .init(chunk: nil, done: true) + case "response.incomplete": + if event.response?.incomplete_details?.reason == "max_output_tokens" { + Logger.service.error( + "Completion truncated: incomplete_details.reason=max_output_tokens request_id=\(event.response?.id ?? "unknown")" + ) + } + return .init(chunk: nil, done: true) + case "response.failed": + throw Error.apiError(event.response?.error?.message ?? "The response failed.") + case "error": + throw Error.apiError(event.error?.message ?? event.message ?? "Unknown error.") + case .none where event.object == "response": + let body = ResponseObject( + id: nil, + object: event.object, + status: nil, + output: event.output, + error: nil, + incomplete_details: nil + ) + return .init(chunk: .init(text: body.outputText), done: true) + default: + return .init(chunk: nil, done: false) + } + } +} diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift new file mode 100644 index 00000000..55b63a71 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift @@ -0,0 +1,629 @@ +import CopilotForXcodeKit +import Foundation +import AIModel +import Logger +import Preferences + +public actor OpenAIService { + let url: URL + let endpoint: Endpoint + let modelName: String + let contextWindow: Int + let maxToken: Int + let temperature: Double + let apiKey: String + let stopWords: [String] + /// When set, the request takes the reasoning model shape: `reasoning_effort` is sent, + /// `max_completion_tokens` replaces `max_tokens`, `temperature` and `stop` are omitted. + let reasoningEffort: ReasoningEffort? + let reasoningTokenBudget: Int + let extraHeaders: [(name: String, value: String)] + let organizationID: String + let projectID: String + let customJSONBody: String + let requiresBeginWithUserMessage: Bool + let enforceMessageOrder: Bool + let authentication: Authentication + + public enum Endpoint { + case completion + case chatCompletion + } + + public enum Authentication { + case bearer + case apiKeyHeader + } + + init( + url: String? = nil, + endpoint: Endpoint, + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + apiKey: String, + authentication: Authentication = .bearer, + reasoningEffort: ReasoningEffort? = nil, + reasoningTokenBudget: Int = 1500, + extraHeaders: [(name: String, value: String)] = [], + organizationID: String = "", + projectID: String = "", + customJSONBody: String = "", + requiresBeginWithUserMessage: Bool = false, + enforceMessageOrder: Bool = false + ) throws { + self.authentication = authentication + self.reasoningEffort = reasoningEffort + self.reasoningTokenBudget = reasoningTokenBudget + self.extraHeaders = extraHeaders + self.organizationID = organizationID + self.projectID = projectID + self.customJSONBody = customJSONBody + self.requiresBeginWithUserMessage = requiresBeginWithUserMessage + self.enforceMessageOrder = enforceMessageOrder + self.url = try Self.resolveURL( + url: url, + authentication: authentication, + endpoint: endpoint + ) + + self.endpoint = endpoint + self.modelName = modelName + self.maxToken = maxToken + self.temperature = temperature + self.apiKey = apiKey + self.stopWords = stopWords + self.contextWindow = contextWindow + } + + nonisolated static let missingAzureEndpointMessage = + "Azure OpenAI endpoint is empty. Set the resource base URL." + + /// Matches `ChatModel.endpoint` / `CompletionModel.endpoint` Azure branches. + nonisolated static func azureEndpoint( + baseURL: String, + deployment: String, + endpoint: Endpoint + ) -> String { + switch endpoint { + case .chatCompletion: + let version = "2024-09-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" + case .completion: + let version = "2023-07-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/completions?api-version=\(version)" + } + } + + /// Azure (`api-key`) must not fall back to api.openai.com. + nonisolated static func resolveURL( + url: String?, + authentication: Authentication, + endpoint: Endpoint + ) throws -> URL { + if let url, !url.isEmpty, let parsed = URL(string: url) { + if authentication == .apiKeyHeader, isOpenAIAPIHost(parsed) { + throw Error.otherError(missingAzureEndpointMessage) + } + return parsed + } + if authentication == .apiKeyHeader { + throw Error.otherError(missingAzureEndpointMessage) + } + switch endpoint { + case .chatCompletion: + return URL(string: "https://api.openai.com/v1/chat/completions")! + case .completion: + return URL(string: "https://api.openai.com/v1/completions")! + } + } + + nonisolated static func isOpenAIAPIHost(_ url: URL) -> Bool { + url.host == "api.openai.com" + } +} + +extension OpenAIService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingCompactMapSequence< + ResponseStream, + String + > + + func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence { + switch endpoint { + case .chatCompletion: + let messages = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.content, $0.role.rawValue) + }) + let result = try await sendMessages(messages) + return result.compactMap { $0.choices?.first?.delta?.content } + case .completion: + let prompt = createPrompt(from: request) + CodeCompletionLogger.logger.logPrompt([(prompt, "user")]) + let result = try await sendPrompt(prompt) + return result.compactMap { $0.choices?.first?.delta?.content } + } + } +} + +public extension OpenAIService { + enum Error: Swift.Error, LocalizedError { + case apiError(String) + case otherError(String) + + public var errorDescription: String? { + switch self { + case let .apiError(message), let .otherError(message): + return message + } + } + } +} + +// MARK: - Chat Completion API + +extension OpenAIService { + public struct Message: Codable, Equatable { + public enum Role: String, Codable { + case user + case assistant + case system + } + + /// The role of the message. + public var role: Role + /// The content of the message. + public var content: String + } + + func createMessages(from request: PromptStrategy) -> [Message] { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + var ordered: [OrderedChatMessage] = [ + .init(role: .system, content: request.systemPrompt), + ] + for prompt in prompts { + switch prompt.role { + case .user: + ordered.append(.init(role: .user, content: prompt.content)) + case .assistant: + ordered.append(.init(role: .assistant, content: prompt.content)) + } + } + ordered = OrderedChatMessages.apply( + ordered, + requiresBeginWithUserMessage: requiresBeginWithUserMessage, + enforceMessageOrder: enforceMessageOrder + ) + return ordered.map { message in + let role: Message.Role = switch message.role { + case .system: .system + case .user: .user + case .assistant: .assistant + } + return .init(role: role, content: message.content) + } + } + + nonisolated static func makeChatCompletionRequestBody( + modelName: String, + messages: [Message], + temperature: Double, + stopWords: [String], + maxToken: Int, + reasoningEffort: ReasoningEffort?, + reasoningTokenBudget: Int + ) -> ChatCompletionRequestBody { + let isReasoningRequest = reasoningEffort != nil + return ChatCompletionRequestBody( + model: modelName, + messages: messages, + temperature: isReasoningRequest ? nil : temperature, + stream: true, + stop: isReasoningRequest ? nil : stopWords, + max_tokens: isReasoningRequest ? nil : maxToken, + max_completion_tokens: isReasoningRequest + ? ReasoningTokenLimit.maxOutputTokens( + maxToken: maxToken, + effort: reasoningEffort, + budget: reasoningTokenBudget + ) + : nil, + reasoning_effort: reasoningEffort?.rawValue + ) + } + + func sendMessages( + _ messages: [Message] + ) async throws -> ResponseStream { + let requestBody = Self.makeChatCompletionRequestBody( + modelName: modelName, + messages: messages, + temperature: temperature, + stopWords: stopWords, + maxToken: maxToken, + reasoningEffort: reasoningEffort, + reasoningTokenBudget: reasoningTokenBudget + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = CompletionJSON.mergeCustomBody( + try encoder.encode(requestBody), + jsonBody: customJSONBody + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + applyAuthentication(to: &request) + request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID) + request.setExtraHeaders(extraHeaders) + let (result, response) = try await URLSession.shared.bytes(for: request) + return try await CompletionHTTP.makeStream( + result: result, + response: response, + mapError: { Error.otherError($0) }, + parseBody: { try Self.parseChatCompletionsBody($0, url: url) }, + parseLine: Self.parseChatCompletionsStreamLine + ) + } + + func applyAuthentication(to request: inout URLRequest) { + switch authentication { + case .bearer: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .apiKeyHeader: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + } + } + + /// Parses a non-streaming `application/json` chat completions body. + static func parseChatCompletionsBody( + _ text: String, + url: URL + ) throws -> ChatCompletionsStreamDataChunk { + if let message = APIErrorPayload.message(in: text) { + throw Error.apiError(message) + } + do { + let body = try JSONDecoder().decode( + ChatCompletionResponseBody.self, + from: Data(text.utf8) + ) + let chunk = chatChunk(from: body) + logIfTruncatedByLength(chunk) + return chunk + } catch let error as Error { + throw error + } catch { + throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url)) + } + } + + static func logIfTruncatedByLength(_ chunk: ChatCompletionsStreamDataChunk) { + if chunk.choices?.first?.finish_reason == "length" { + Logger.service.error( + "Completion truncated: finish_reason=length request_id=\(chunk.id ?? "unknown")" + ) + } + } + + static func chatChunk(from body: ChatCompletionResponseBody) -> ChatCompletionsStreamDataChunk { + let choice = body.choices.first + return ChatCompletionsStreamDataChunk( + id: body.id, + object: body.object, + model: body.model, + choices: [ + .init( + delta: .init(role: choice?.message.role, content: choice?.message.content), + index: choice?.index, + finish_reason: choice?.finish_reason ?? "stop", + message: choice?.message + ), + ] + ) + } + + static func asChatChunk(_ chunk: CompletionsStreamDataChunk) -> ChatCompletionsStreamDataChunk { + ChatCompletionsStreamDataChunk( + id: chunk.id, + object: chunk.object, + model: chunk.model, + choices: chunk.choices?.map { choice in + .init( + delta: .init(role: nil, content: choice.text), + index: choice.index, + finish_reason: choice.finish_reason, + message: nil + ) + } + ) + } + + /// Parses one line of the event stream. + static func parseChatCompletionsStreamLine( + _ line: String + ) throws -> ResponseStream.LineContent { + guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) } + if payload == "[DONE]" { return .init(chunk: nil, done: true) } + if let message = APIErrorPayload.message(in: payload) { + throw Error.apiError(message) + } + let chunk = try JSONDecoder().decode( + ChatCompletionsStreamDataChunk.self, + from: Data(payload.utf8) + ) + logIfTruncatedByLength(chunk) + // A non-streaming body carries the whole message instead of a delta. + if let choice = chunk.choices?.first, choice.delta == nil, let message = choice.message { + return .init( + chunk: .init(choices: [.init( + delta: .init(role: message.role, content: message.content), + index: choice.index, + finish_reason: choice.finish_reason ?? "stop", + message: nil + )]), + done: true + ) + } + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } + + /// https://platform.openai.com/docs/api-reference/chat/create + struct ChatCompletionRequestBody: Codable, Equatable { + var model: String + var messages: [Message] + var temperature: Double? + var top_p: Double? + var n: Double? + var stream: Bool? + var stop: [String]? + var max_tokens: Int? + var presence_penalty: Double? + var frequency_penalty: Double? + var logit_bias: [String: Double]? + /// Reasoning models reject `max_tokens` and expect this field instead. + var max_completion_tokens: Int? + var reasoning_effort: String? + + init( + model: String, + messages: [Message], + temperature: Double? = nil, + top_p: Double? = nil, + n: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil, + max_tokens: Int? = nil, + presence_penalty: Double? = nil, + frequency_penalty: Double? = nil, + logit_bias: [String: Double]? = nil, + max_completion_tokens: Int? = nil, + reasoning_effort: String? = nil + ) { + self.max_completion_tokens = max_completion_tokens + self.reasoning_effort = reasoning_effort + self.model = model + self.messages = messages + self.temperature = temperature + self.top_p = top_p + self.n = n + self.stream = stream + self.stop = stop + self.max_tokens = max_tokens + self.presence_penalty = presence_penalty + self.frequency_penalty = frequency_penalty + self.logit_bias = logit_bias + } + } + + struct ChatCompletionResponseBody: Codable, Equatable { + struct Choice: Codable, Equatable { + var message: Message + var index: Int + var finish_reason: String? + } + + struct Usage: Codable, Equatable { + var prompt_tokens: Int + var completion_tokens: Int + var total_tokens: Int + } + + var id: String? + var object: String? + var model: String? + var usage: Usage? + var choices: [Choice] + } + + struct ChatCompletionsStreamDataChunk: Decodable { + var id: String? + var object: String? + var model: String? + var choices: [Choice]? + + struct Choice: Decodable { + var delta: Delta? + var index: Int? + var finish_reason: String? + /// Present in a non-streaming response body. + var message: Message? + + struct Delta: Decodable { + var role: Message.Role? + var content: String? + } + } + } +} + +// MARK: - Completion API + +extension OpenAIService { + func createPrompt(from request: PromptStrategy) -> String { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + // if request.systemPrompt empty not append + if request.systemPrompt.isEmpty { + return prompts.map(\.content).joined(separator: "\n\n") + } + return ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") + } + + func sendPrompt(_ prompt: String) async throws -> ResponseStream { + let requestBody = CompletionRequestBody( + model: modelName, + prompt: prompt, + temperature: temperature, + stream: true, + stop: stopWords, + max_tokens: maxToken + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = CompletionJSON.mergeCustomBody( + try encoder.encode(requestBody), + jsonBody: customJSONBody + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + applyAuthentication(to: &request) + request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID) + request.setExtraHeaders(extraHeaders) + let (result, response) = try await URLSession.shared.bytes(for: request) + return try await CompletionHTTP.makeStream( + result: result, + response: response, + mapError: { Error.otherError($0) }, + parseBody: { try Self.asChatChunk(Self.parseCompletionsBody($0, url: url)) }, + parseLine: { line in + let content = try Self.parseCompletionsStreamLine(line) + return .init(chunk: content.chunk.map(Self.asChatChunk), done: content.done) + } + ) + } + + static func parseCompletionsBody( + _ text: String, + url: URL + ) throws -> CompletionsStreamDataChunk { + if let message = APIErrorPayload.message(in: text) { + throw Error.apiError(message) + } + do { + let body = try JSONDecoder().decode(CompletionResponseBody.self, from: Data(text.utf8)) + return CompletionsStreamDataChunk( + id: body.id, + object: body.object, + model: body.model, + choices: body.choices.map { + .init(text: $0.text, index: $0.index, finish_reason: $0.finish_reason) + } + ) + } catch let error as Error { + throw error + } catch { + throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url)) + } + } + + /// Parses one line of the event stream. + static func parseCompletionsStreamLine( + _ line: String + ) throws -> ResponseStream.LineContent { + guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) } + if payload == "[DONE]" { return .init(chunk: nil, done: true) } + if let message = APIErrorPayload.message(in: payload) { + throw Error.apiError(message) + } + let chunk = try JSONDecoder().decode(CompletionsStreamDataChunk.self, from: Data(payload.utf8)) + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } + + /// https://platform.openai.com/docs/api-reference/chat/create + struct CompletionRequestBody: Codable, Equatable { + var model: String + var prompt: String + var temperature: Double? + var top_p: Double? + var n: Double? + var stream: Bool? + var stop: [String]? + /// Default to be 16. + var max_tokens: Int? + var presence_penalty: Double? + var frequency_penalty: Double? + var logit_bias: [String: Double]? + + init( + model: String, + prompt: String, + temperature: Double? = nil, + top_p: Double? = nil, + n: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil, + max_tokens: Int? = nil, + presence_penalty: Double? = nil, + frequency_penalty: Double? = nil, + logit_bias: [String: Double]? = nil + ) { + self.model = model + self.prompt = prompt + self.temperature = temperature + self.top_p = top_p + self.n = n + self.stream = stream + self.stop = stop + self.max_tokens = max_tokens + self.presence_penalty = presence_penalty + self.frequency_penalty = frequency_penalty + self.logit_bias = logit_bias + } + } + + struct CompletionResponseBody: Codable, Equatable { + struct Choice: Codable, Equatable { + var text: String + var index: Int + var finish_reason: String? + } + + struct Usage: Codable, Equatable { + var prompt_tokens: Int + var total_tokens: Int + } + + var id: String? + var object: String? + var model: String? + var usage: Usage? + var choices: [Choice] + } + + struct CompletionsStreamDataChunk: Decodable { + struct Choice: Decodable { + var text: String? + var index: Int + var finish_reason: String? + } + + var id: String? + var object: String? + var model: String? + var choices: [Choice]? + } +} diff --git a/Tool/Sources/CustomSuggestionService/API/TabbyService.swift b/Tool/Sources/CustomSuggestionService/API/TabbyService.swift new file mode 100644 index 00000000..87765e46 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/TabbyService.swift @@ -0,0 +1,144 @@ +import CopilotForXcodeKit +import Foundation +import AIModel +import Logger + +actor TabbyService { + enum AuthorizationMode { + case none + case bearerToken(String) + case basic(username: String, password: String) + case customHeaderField(name: String, value: String) + } + + let url: URL + let temperature: Double + let authorizationMode: AuthorizationMode + + init( + url: String? = nil, + temperature: Double = 0.2, + authorizationMode: AuthorizationMode + ) { + self.url = url + .flatMap(URL.init(string:)) ?? URL(string: "http://127.0.0.1:8080/v1/completions")! + self.temperature = temperature + self.authorizationMode = authorizationMode + } +} + +extension TabbyService: CodeCompletionServiceType { + func getCompletion(_ request: PromptStrategy) async throws -> AsyncThrowingStream { + let prefix = request.prefix.joined() + let suffix = request.suffix.joined() + let requestBody = RequestBody( + language: request.language?.rawValue, + segments: .init( + prefix: prefix, + suffix: suffix, + clipboard: "" + ), + temperature: temperature, + seed: nil + ) + CodeCompletionLogger.logger.logPrompt([ + (prefix, "prefix"), + (suffix, "suffix"), + ]) + return AsyncThrowingStream { continuation in + let task = Task { + do { + let result = try await send(requestBody) + try Task.checkCancellation() + continuation.yield(result) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +extension TabbyService { + enum Error: Swift.Error, LocalizedError { + case serverError(String) + case decodeError(Swift.Error) + + var errorDescription: String? { + switch self { + case let .serverError(message): + return message + case let .decodeError(error): + return error.localizedDescription + } + } + } + + struct RequestBody: Codable { + struct Segments: Codable { + var prefix: String + var suffix: String + var clipboard: String + } + + var language: String? + var segments: Segments + var temperature: Double + var seed: Int? + } + + struct ResponseBody: Codable { + struct Choice: Codable { + var index: Int + var text: String + } + + var id: String + var choices: [Choice] + } + + func send(_ requestBody: RequestBody) async throws -> String { + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + switch authorizationMode { + case let .basic(username, password): + let data = "\(username):\(password)".data(using: .utf8)! + let base64 = data.base64EncodedString() + request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization") + case let .bearerToken(token): + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + case let .customHeaderField(name, value): + request.setValue(value, forHTTPHeaderField: name) + case .none: + break + } + + let (result, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw Error.serverError(CompletionHTTP.invalidResponseMessage()) + } + + guard response.statusCode == 200 else { + let text = String(data: result, encoding: .utf8) ?? "" + throw Error.serverError(CompletionHTTP.statusMessage(code: response.statusCode, body: text)) + } + + do { + let body = try JSONDecoder().decode(ResponseBody.self, from: result) + return body.choices.first?.text ?? "" + } catch { + Logger.service.error(error) + throw Error.serverError(CompletionHTTP.parseFailureMessage(url: url)) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift new file mode 100644 index 00000000..63de295c --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift @@ -0,0 +1,290 @@ +import AIModel +import Foundation +import JoinJSON +import Logger +import OpenAIService +import Preferences + +/// Per-request options for chat models, resolved from preferences and the model's custom headers. +struct ChatAPIOptions { + var api: OpenAIChatAPI + var reasoningEffort: ReasoningEffort? + var reasoningTokenBudget: Int + var extraHeaders: [(name: String, value: String)] + + static func current( + for model: ChatModel, + apiKey: String, + defaults: UserDefaultsType = UserDefaults.shared + ) async -> ChatAPIOptions { + let stored = ChatModelAPIOptions.stored(for: model.id, defaults: defaults) + let parser = HeaderValueParser() + var headers: [(name: String, value: String)] = [] + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + let value = await parser.parse( + field.value, + context: .init( + modelName: model.info.modelName, + apiKey: apiKey, + gitHubCopilotToken: { nil } + ) + ) + headers.append((name: field.key, value: value)) + } + return .init( + api: stored.api, + reasoningEffort: stored.reasoningEffort, + reasoningTokenBudget: stored.reasoningTokenBudget, + extraHeaders: CompletionHeader.validated(headers) + ) + } +} + +extension URLRequest { + mutating func setExtraHeaders(_ headers: [(name: String, value: String)]) { + for header in headers { + setValue(header.value, forHTTPHeaderField: header.name) + } + } + + mutating func setOpenAIIdentityHeaders(organizationID: String, projectID: String) { + if !organizationID.isEmpty { + setValue(organizationID, forHTTPHeaderField: "OpenAI-Organization") + } + if !projectID.isEmpty { + setValue(projectID, forHTTPHeaderField: "OpenAI-Project") + } + } +} + +enum CompletionHeader { + static func validated( + _ headers: [(name: String, value: String)] + ) -> [(name: String, value: String)] { + headers.filter { header in + if !isValidName(header.name) { + Logger.service.error("Dropping invalid completion header name: \(header.name)") + return false + } + if header.value.contains(where: \.isNewline) { + Logger.service.error( + "Dropping completion header \(header.name) because the value contains a newline" + ) + return false + } + return true + } + } + + /// Reject whitespace and control characters in header names. + static func isValidName(_ name: String) -> Bool { + !name.isEmpty && name.unicodeScalars.allSatisfy { scalar in + scalar.value > 32 && scalar.value < 127 + } + } +} + +enum CompletionJSON { + static func mergeCustomBody(_ data: Data, jsonBody: String) -> Data { + let jsonBody = jsonBody.trimmingCharacters(in: .whitespacesAndNewlines) + guard !jsonBody.isEmpty else { return data } + return JoinJSON().join(data, with: jsonBody) + } +} + +enum ReasoningTokenLimit { + static func maxOutputTokens(maxToken: Int, effort: ReasoningEffort?, budget: Int) -> Int { + if let effort, effort.addsReasoningTokenBudget { + return maxToken + budget + } + return maxToken + } +} + +struct OrderedChatMessage { + enum Role { + case system + case user + case assistant + } + + var role: Role + var content: String +} + +enum OrderedChatMessages { + static func apply( + _ messages: [OrderedChatMessage], + requiresBeginWithUserMessage: Bool, + enforceMessageOrder: Bool + ) -> [OrderedChatMessage] { + guard requiresBeginWithUserMessage || enforceMessageOrder else { return messages } + var result = messages + + if requiresBeginWithUserMessage { + if let firstUser = result.firstIndex(where: { $0.role == .user }) { + let leading = result[.. String? { + var earliest: String.Index? + for word in stopWords where !word.isEmpty { + if let range = text.range(of: word), + earliest == nil || range.lowerBound < earliest! + { + earliest = range.lowerBound + } + } + guard let earliest else { return nil } + return String(text[..`. + static func displayablePrefix(of text: String, stopWords: [String]) -> String { + var cut = 0 + for word in stopWords where word.count > 1 { + for length in stride(from: word.count - 1, through: 1, by: -1) where length > cut { + if text.hasSuffix(word.prefix(length)) { + cut = length + break + } + } + } + return cut == 0 ? text : String(text.dropLast(cut)) + } +} + +/// Helpers for `text/event-stream` lines. +enum SSELine { + /// The payload of a stream line, or nil for lines that carry no data + /// (blank lines, `event:` / `id:` / `retry:` lines and comments). + static func payload(of line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + for prefix in [":", "event:", "id:", "retry:"] where trimmed.hasPrefix(prefix) { + return nil + } + if trimmed.hasPrefix("data:") { + return String(trimmed.dropFirst(5)).trimmingCharacters(in: .whitespaces) + } + return trimmed + } +} + +/// Shared HTTP handling for completion `URLSession.bytes` calls. +enum CompletionHTTP { + static func collectBody(_ bytes: URLSession.AsyncBytes) async throws -> String { + try await bytes.lines.reduce(into: "") { partial, line in + if !partial.isEmpty { partial += "\n" } + partial += line + } + } + + static func isEventStream(_ response: HTTPURLResponse) -> Bool { + contentType(response).localizedCaseInsensitiveContains("text/event-stream") + } + + static func isNDJSON(_ response: HTTPURLResponse) -> Bool { + contentType(response).localizedCaseInsensitiveContains("ndjson") + } + + /// Pretty-printed JSON is multiple lines and must not go through `ResponseStream`. + /// Ollama streams NDJSON, which is also line-based. + static func shouldParseAsSingleBody(_ response: HTTPURLResponse) -> Bool { + !isEventStream(response) && !isNDJSON(response) + } + + static func statusMessage(code: Int, body: String) -> String { + "HTTP \(code): \(String(body.prefix(200)))" + } + + static func parseFailureMessage(url: URL) -> String { + "Cannot parse response from \(url.absoluteString)" + } + + static func invalidResponseMessage() -> String { + "The server returned an invalid response." + } + + static func makeStream( + result: URLSession.AsyncBytes, + response: URLResponse, + mapError: (String) -> Swift.Error, + parseBody: (String) throws -> Chunk, + parseLine: @escaping (String) throws -> ResponseStream.LineContent + ) async throws -> ResponseStream { + guard let http = response as? HTTPURLResponse else { + throw mapError(invalidResponseMessage()) + } + if http.statusCode != 200 { + let body = try await collectBody(result) + throw mapError(statusMessage(code: http.statusCode, body: body)) + } + if shouldParseAsSingleBody(http) { + let body = try await collectBody(result) + return ResponseStream(single: try parseBody(body)) + } + return ResponseStream(result: result, lineExtractor: parseLine) + } + + private static func contentType(_ response: HTTPURLResponse) -> String { + response.value(forHTTPHeaderField: "Content-Type") ?? "" + } +} + +enum APIErrorPayload { + struct Envelope: Decodable { + struct Body: Decodable { + var message: String + var type: String? + var code: String? + } + + var error: Body + } + + static func message(in payload: String) -> String? { + guard let envelope = try? JSONDecoder().decode(Envelope.self, from: Data(payload.utf8)) + else { return nil } + return envelope.error.message + } +} diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift new file mode 100644 index 00000000..b4f09c2b --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift @@ -0,0 +1,173 @@ +import CopilotForXcodeKit +import Foundation +import Preferences +import Logger +import AIModel + +public final class CodeCompletionLogger { + struct Model { + var type: String + var format: String + var modelName: String + var baseURL: String + } + + @TaskLocal public static var logger: CodeCompletionLogger = .init(request: SuggestionRequest( + fileURL: .init(filePath: "/"), + relativePath: "", + language: .plaintext, + content: "", + originalContent: "", + cursorPosition: .zero, + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + )) + + let request: SuggestionRequest + var model = Model(type: "", format: "", modelName: "", baseURL: "") + var prompt: [(message: String, role: String)] = [] + var responses: [String] = [] + var endpoint: String? + var firstTokenTime: Date? + var partialCount = 0 + var outputTokens: Int? + var reasoningTokens: Int? + let startTime = Date() + let id = UUID() + + var shouldLogToConsole: Bool { + #if DEBUG + return true + #else + return UserDefaults.shared.value(for: \.customSuggestionVerboseLog) + #endif + } + + public init(request: SuggestionRequest) { + self.request = request + } + + /// The URL the request actually goes to, once the chat API choice is resolved. + public func logEndpoint(_ url: String) { + endpoint = url + } + + public func logModel(_ chatModel: ChatModel) { + model = .init( + type: "Chat Completion", + format: chatModel.format.rawValue, + modelName: chatModel.info.modelName, + baseURL: chatModel.info.baseURL + ) + } + + public func logModel(_ completionModel: CompletionModel) { + model = .init( + type: "Completion", + format: completionModel.format.rawValue, + modelName: completionModel.info.modelName, + baseURL: completionModel.info.baseURL + ) + } + + public func logModel(_ tabbyModel: TabbyModel) { + model = .init( + type: "Tabby", + format: "N/A", + modelName: "N/A", + baseURL: tabbyModel.url + ) + } + + public func logModel(_ fimModel: FIMModel) { + model = .init( + type: "FIM", + format: fimModel.format.rawValue, + modelName: fimModel.info.modelName, + baseURL: fimModel.info.baseURL + ) + } + + public func logPrompt(_ prompt: [(message: String, role: String)]) { + self.prompt = prompt + } + + public func logResponse(_ response: String) { + responses.append(response) + } + + /// Called when the first non-empty token arrives; later calls are ignored. + public func logFirstToken() { + if firstTokenTime == nil { firstTokenTime = Date() } + } + + /// Called every time a partial suggestion is handed to the UI while streaming. + public func logPartial() { + partialCount += 1 + } + + /// Token usage reported by the API, when the protocol carries it. + public func logUsage(outputTokens: Int?, reasoningTokens: Int?) { + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens + } + + public func error(_ error: Error) { + if error is CancellationError { return } + if let urlError = error as? URLError, urlError.code == .cancelled { return } + guard shouldLogToConsole else { return } + + let now = Date() + let duration = now.timeIntervalSince(startTime) + let formattedDuration = String(format: "%.2f", duration) + + Logger.service.info(""" + [Request] \(id) + + Duration: \(formattedDuration) + Error: \(error.localizedDescription). + """) + } + + public func finish() { + guard shouldLogToConsole else { return } + + let now = Date() + let duration = now.timeIntervalSince(startTime) + let formattedDuration = String(format: "%.2f", duration) + let formattedFirstToken = firstTokenTime + .map { String(format: "%.2f", $0.timeIntervalSince(startTime)) } ?? "n/a" + + Logger.service.info(""" + [Request] \(id) + + Format: \(model.format) + Model Name: \(model.modelName) + Base URL: \(model.baseURL) + Endpoint: \(endpoint ?? "n/a") + Duration: \(formattedDuration) + First Token: \(formattedFirstToken) + Partials: \(partialCount) + Output Tokens: \(outputTokens.map(String.init) ?? "n/a") (reasoning: \(reasoningTokens.map(String.init) ?? "n/a")) + --- + File URL: \(request.fileURL) + Code Snippets: \(request.relevantCodeSnippets.count) snippets + CursorPosition: \(request.cursorPosition) + """) + + Logger.service.info(""" + [Prompt] \(id) + + \(prompt.map { "\($0.role): \($0.message)" }.joined(separator: "\n\n")) + """) + + Logger.service.info(""" + [Response] \(id) + + \(responses.enumerated().map { "\($0 + 1): \($1)" }.joined(separator: "\n\n")) + """) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift new file mode 100644 index 00000000..cadd462f --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift @@ -0,0 +1,549 @@ +import Foundation +import AIModel +import Keychain +import Preferences + +protocol CodeCompletionServiceType { + associatedtype CompletionSequence: AsyncSequence where CompletionSequence.Element == String + + func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence +} + +extension CodeCompletionServiceType { + func getCompletions( + _ request: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + count: Int + ) async throws -> [String] { + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0.. AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let limiter = StreamLineLimiter(strategy: streamStopStrategy) + let stream = try await getCompletion(request) + let result = try await feedCompletion( + from: stream, + stopWords: request.stopWords, + limiter: limiter + ) { partial in + continuation.yield(partial) + } + try Task.checkCancellation() + continuation.yield(result) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +func collectCompletion( + from stream: S, + stopWords: [String], + limiter: StreamLineLimiter +) async throws -> String where S.Element == String { + try await feedCompletion(from: stream, stopWords: stopWords, limiter: limiter) { _ in } +} + +/// Feeds `stream` into `limiter`, reporting the displayable text after every token, and returns +/// the final text: cut at the first stop word, or where the limiter decided to stop. +func feedCompletion( + from stream: S, + stopWords: [String], + limiter: StreamLineLimiter, + onPartial: (String) -> Void +) async throws -> String where S.Element == String { + for try await response in stream { + if !response.isEmpty { CodeCompletionLogger.logger.logFirstToken() } + let push = limiter.push(response) + if let truncated = CompletionStopWords.truncatedPrefix( + of: limiter.result, + stopWords: stopWords + ) { + return truncated + } + if case let .finish(result) = push { + return result + } + onPartial(CompletionStopWords.displayablePrefix(of: limiter.result, stopWords: stopWords)) + } + return limiter.result +} + +/// Type-erased ``CodeCompletionServiceType`` so the per-model dispatch can serve both the blocking +/// and the streaming entry points. Errors and cancellation pass through unchanged. +struct ErasedCompletionService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingStream + + let makeCompletion: (PromptStrategy) async throws -> CompletionSequence + + init(_ service: Service) { + makeCompletion = { request in + let sequence = try await service.getCompletion(request) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await element in sequence { + try Task.checkCancellation() + continuation.yield(element) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + } + + func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence { + try await makeCompletion(request) + } +} + +public struct CodeCompletionService { + public init() {} + + public enum Error: Swift.Error, LocalizedError { + case unknownFormat + case gitHubCopilotNotSupported + + public var errorDescription: String? { + switch self { + case .unknownFormat: + return "Unknown model format." + case .gitHubCopilotNotSupported: + return "GitHub Copilot chat models can't be used for completion" + } + } + } + + // MARK: Blocking + + func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: CustomSuggestionCoordinator.Model, + count: Int + ) async throws -> [String] { + let result = try await completionService(for: model, prompt: prompt).getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + } + + public func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: TabbyModel, + count: Int + ) async throws -> [String] { + try await getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + model: .tabbyModel(model), + count: count + ) + } + + public func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: ChatModel, + count: Int + ) async throws -> [String] { + try await getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + model: .chatModel(model), + count: count + ) + } + + public func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: CompletionModel, + count: Int + ) async throws -> [String] { + try await getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + model: .completionModel(model), + count: count + ) + } + + public func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: FIMModel, + count: Int + ) async throws -> [String] { + try await getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + model: .fimModel(model), + count: count + ) + } + + // MARK: Streaming + + /// See ``CodeCompletionServiceType/streamCompletion(_:streamStopStrategy:)``. + func streamCompletion( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: CustomSuggestionCoordinator.Model + ) async throws -> AsyncThrowingStream { + try await completionService(for: model, prompt: prompt) + .streamCompletion(prompt, streamStopStrategy: streamStopStrategy) + } + + // MARK: Per-model dispatch + + func completionService( + for model: CustomSuggestionCoordinator.Model, + prompt: PromptStrategy + ) async throws -> ErasedCompletionService { + switch model { + case let .chatModel(model): + return try await completionService(for: model, prompt: prompt) + case let .completionModel(model): + return try completionService(for: model, prompt: prompt) + case let .tabbyModel(model): + return completionService(for: model, prompt: prompt) + case let .fimModel(model): + return try completionService(for: model, prompt: prompt) + } + } + + func completionService(for model: TabbyModel, prompt: PromptStrategy) -> ErasedCompletionService { + let apiKey = apiKey(from: model) + + return .init(TabbyService(url: model.url, authorizationMode: { + switch model.authorizationMode { + case .none: + return .none + case .bearerToken: + return .bearerToken(apiKey) + case .basic: + return .basic(username: model.username, password: apiKey) + case .customHeaderField: + return .customHeaderField(name: model.authorizationHeaderName, value: apiKey) + } + }())) + } + + func completionService( + for model: ChatModel, + prompt: PromptStrategy + ) async throws -> ErasedCompletionService { + let apiKey = apiKey(from: model) + + let options = await ChatAPIOptions.current(for: model, apiKey: apiKey) + CodeCompletionLogger.logger.logEndpoint(model.endpoint) + + switch model.format { + case .openAI, .openAICompatible: + switch options.api { + case .responses: + let responsesURL = Self.responsesEndpoint(for: model) + CodeCompletionLogger.logger.logEndpoint(responsesURL) + return .init(OpenAIResponsesService( + url: responsesURL, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey, + reasoningEffort: options.reasoningEffort, + reasoningTokenBudget: options.reasoningTokenBudget, + extraHeaders: options.extraHeaders, + organizationID: model.info.openAIInfo.organizationID, + projectID: model.info.openAIInfo.projectID, + customJSONBody: model.info.customBodyInfo.jsonBody, + requiresBeginWithUserMessage: model.info.openAICompatibleInfo + .requiresBeginWithUserMessage, + enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder + )) + case .chatCompletions: + return .init(try OpenAIService( + url: model.endpoint, + endpoint: .chatCompletion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey, + reasoningEffort: options.reasoningEffort, + reasoningTokenBudget: options.reasoningTokenBudget, + extraHeaders: options.extraHeaders, + organizationID: model.info.openAIInfo.organizationID, + projectID: model.info.openAIInfo.projectID, + customJSONBody: model.info.customBodyInfo.jsonBody, + requiresBeginWithUserMessage: model.info.openAICompatibleInfo + .requiresBeginWithUserMessage, + enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder + )) + } + case .azureOpenAI: + return .init(try OpenAIService( + url: model.endpoint, + endpoint: .chatCompletion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey, + authentication: .apiKeyHeader, + reasoningEffort: options.reasoningEffort, + reasoningTokenBudget: options.reasoningTokenBudget, + extraHeaders: options.extraHeaders + )) + case .googleAI: + return .init(GoogleGeminiService( + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + apiKey: apiKey, + baseURL: model.endpoint, + apiVersion: model.info.googleGenerativeAIInfo.apiVersion + )) + case .ollama: + return .init(OllamaService( + url: model.endpoint, + endpoint: .chatCompletion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none, + extraHeaders: options.extraHeaders + )) + case .claude: + return .init(AnthropicService( + url: model.endpoint, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey, + extraHeaders: options.extraHeaders, + reasoningEffort: options.reasoningEffort, + customJSONBody: model.info.customBodyInfo.jsonBody + )) + case .gitHubCopilot: + throw Error.gitHubCopilotNotSupported + } + } + + func completionService( + for model: CompletionModel, + prompt: PromptStrategy + ) throws -> ErasedCompletionService { + let apiKey = apiKey(from: model) + + switch model.format { + case .openAI, .openAICompatible: + return .init(try OpenAIService( + url: model.endpoint, + endpoint: .completion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey + )) + case .azureOpenAI: + return .init(try OpenAIService( + url: model.endpoint, + endpoint: .completion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey, + authentication: .apiKeyHeader + )) + case .ollama: + return .init(OllamaService( + url: model.endpoint, + endpoint: .completion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none + )) + case .unknown: + throw Error.unknownFormat + } + } + + func completionService( + for model: FIMModel, + prompt: PromptStrategy + ) throws -> ErasedCompletionService { + let apiKey = apiKey(from: model) + + switch model.format { + case .mistral: + return .init(MistralFIMService( + url: URL(string: model.endpoint), + model: model.info.modelName, + temperature: 0, + stopWords: prompt.stopWords, + apiKey: apiKey, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken) + )) + case .ollama: + return .init(OllamaService( + url: model.endpoint, + endpoint: .completionWithSuffix, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none + )) + case .ollamaCompatible: + return .init(OllamaService( + url: model.endpoint, + endpoint: .completionWithSuffix, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none, + authenticationMode: { + switch model.info.authenticationMode { + case .header: + return .header( + name: model.info.authenticationHeaderFieldName, + value: apiKey + ) + case .bearerToken: + return .bearerToken(apiKey) + } + }() + )) + case .unknown: + throw Error.unknownFormat + } + } + + /// `ChatModel.endpoint` always points at `/v1/chat/completions`; derive the Responses API URL. + static func responsesEndpoint(for model: ChatModel) -> String { + let baseURL = model.info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/responses" } + if model.format == .openAICompatible, model.info.isFullURL { + return rewriteChatCompletionsFullURL(baseURL) + } + let trimmed = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL + return "\(trimmed)/v1/responses" + } + + /// Full URLs may have a trailing slash or query; match `/chat/completions` on the path only. + static func rewriteChatCompletionsFullURL(_ baseURL: String) -> String { + if let url = URL(string: baseURL), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + { + var path = components.path + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + if path.hasSuffix("/chat/completions") { + components.path = String(path.dropLast("/chat/completions".count)) + "/responses" + if let rewritten = components.string { + return rewritten + } + } + return baseURL + } + + let parts = baseURL.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + var head = String(parts[0]) + while head.hasSuffix("/") { + head.removeLast() + } + guard head.hasSuffix("/chat/completions") else { return baseURL } + head = String(head.dropLast("/chat/completions".count)) + "/responses" + if parts.count > 1 { + return head + "?" + parts[1] + } + return head + } + + func apiKey(from model: ChatModel) -> String { + let name = model.info.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } + + func apiKey(from model: CompletionModel) -> String { + let name = model.info.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } + + func apiKey(from model: TabbyModel) -> String { + let name = model.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } + + func apiKey(from model: FIMModel) -> String { + let name = model.info.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } +} + diff --git a/Tool/Sources/CustomSuggestionService/CustomModelExtension.swift b/Tool/Sources/CustomSuggestionService/CustomModelExtension.swift new file mode 100644 index 00000000..205e8118 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CustomModelExtension.swift @@ -0,0 +1,22 @@ +import BuiltinExtension +import CopilotForXcodeKit +import Foundation + +/// A built-in extension that generates suggestions with user configured models +/// (OpenAI compatible APIs, Ollama, FIM endpoints, Tabby, etc.). +/// +/// It was the standalone "Custom Suggestion Service for Copilot for Xcode" app, now merged into +/// the host as a built-in suggestion provider. +public final class CustomModelExtension: BuiltinExtension { + public static let identifier = "com.intii.CopilotForXcode.CustomModel" + + public var extensionIdentifier: String { Self.identifier } + + public let suggestionService: CustomModelSuggestionService + + public init() { + suggestionService = .init() + } + + public func terminate() {} +} diff --git a/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift new file mode 100644 index 00000000..12f6fb17 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift @@ -0,0 +1,70 @@ +import BuiltinExtension +import CopilotForXcodeKit +import enum CopilotForXcodeKit.SuggestionServiceError +import Foundation +import AIModel + +public final class CustomModelSuggestionService: SuggestionServiceType { + let service = CustomSuggestionCoordinator() + + public init() {} + + public var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: false, + acceptsRelevantSnippetsFromOpenedFiles: true + ) + } + + public func notifyAccepted(_ suggestion: CodeSuggestion, workspace: WorkspaceInfo) async {} + + public func notifyRejected(_ suggestions: [CodeSuggestion], workspace: WorkspaceInfo) async {} + + public func cancelRequest(workspace: WorkspaceInfo) async { + await service.cancelRequest() + } + + public func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CodeSuggestion] { + do { + return try await service.getSuggestions(request, workspace: workspace) + } catch { + if let mapped = Self.userFacingError(error) { throw mapped } + return [] + } + } + + /// Cancellation ends the round quietly; everything else reaches the user the same way on + /// both entry points. + static func userFacingError(_ error: Swift.Error) -> Swift.Error? { + if error is CancellationError { return nil } + if let urlError = error as? URLError, urlError.code == .cancelled { return nil } + if let error = error as? SuggestionServiceError { return error } + return SuggestionServiceError.notice(error) + } +} + +extension CustomModelSuggestionService: StreamingSuggestionServiceType { + public func streamSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async -> AsyncThrowingStream<[CodeSuggestion], Swift.Error> { + let upstream = await service.streamSuggestions(request, workspace: workspace) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await partial in upstream { + continuation.yield(partial) + } + continuation.finish() + } catch { + continuation.finish(throwing: Self.userFacingError(error)) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} diff --git a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift new file mode 100644 index 00000000..457c46c7 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift @@ -0,0 +1,340 @@ +import CopilotForXcodeKit +import enum CopilotForXcodeKit.SuggestionServiceError +import Foundation +import Preferences +import AIModel + +actor CustomSuggestionCoordinator { + enum Model { + case chatModel(ChatModel) + case completionModel(CompletionModel) + case tabbyModel(TabbyModel) + case fimModel(FIMModel) + } + + enum SelectionError: Swift.Error, LocalizedError { + case modelMissing(String) + + var errorDescription: String? { + switch self { + case let .modelMissing(id): + if id.isEmpty { + return "No suggestion model is selected. Pick one in Service › Custom Model." + } + return "Selected suggestion model \"\(id)\" no longer exists. Pick one in Service › Custom Model." + } + } + } + + /// Everything a completion round derives from the request and the preferences. + struct Round { + let request: SuggestionRequest + let prompt: PromptStrategy + let postProcessor: RawSuggestionPostProcessingStrategy + let stopStream: StreamStopStrategy + let model: Model + let maxLines: Int + + /// Turns raw model output into the suggestion shown to the user; nil when it is blank. + func makeSuggestion(from rawSuggestion: String, id: String) -> CodeSuggestion? { + if rawSuggestion.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return nil } + let suggestionText = postProcessor + .postProcess( + rawSuggestion: rawSuggestion, + infillPrefix: prompt.suggestionPrefix.prependingValue, + suffix: prompt.suffix + ) + .keepLines(count: maxLines) + .removeTrailingNewlinesAndWhitespace() + + return CodeSuggestion( + id: id, + text: suggestionText, + position: request.cursorPosition, + range: .init( + start: .init( + line: request.cursorPosition.line, + character: 0 + ), + end: request.cursorPosition + ) + ) + } + } + + /// Minimum interval between two partial emissions while streaming. + static let partialEmitInterval: TimeInterval = 0.05 + + /// Cancels the in-flight round, whichever entry point started it. + private var cancelOnGoingRound: (() -> Void)? + var lastNoticeDate: Date? + + func cancelRequest() { + cancelOnGoingRound?() + cancelOnGoingRound = nil + } + + func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CodeSuggestion] { + cancelRequest() + let snapshot = Self.currentSnapshot() + let task = Task { + try await CodeCompletionLogger.$logger.withValue(.init(request: request)) { + do { + guard let round = try prepareRound(request, snapshot: snapshot) else { + return [CodeSuggestion]() + } + + let suggestedCodeSnippets = try await CodeCompletionService().getCompletions( + round.prompt, + streamStopStrategy: round.stopStream, + model: round.model, + count: 1 + ) + + CodeCompletionLogger.logger.finish() + + return suggestedCodeSnippets.compactMap { + round.makeSuggestion(from: $0, id: UUID().uuidString) + } + } catch { + CodeCompletionLogger.logger.error(error) + throw mapUserFacingError(error) + } + } + } + cancelOnGoingRound = { task.cancel() } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + /// Streams the round: partial suggestions (same id, growing text) at most every + /// ``partialEmitInterval``, then the final one. Blank partials are skipped. + func streamSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) -> AsyncThrowingStream<[CodeSuggestion], Swift.Error> { + cancelRequest() + let snapshot = Self.currentSnapshot() + return AsyncThrowingStream { continuation in + let task = Task { + await CodeCompletionLogger.$logger.withValue(.init(request: request)) { + do { + guard let round = try prepareRound(request, snapshot: snapshot) else { + continuation.finish() + return + } + let id = UUID().uuidString + let completion = try await CodeCompletionService().streamCompletion( + round.prompt, + streamStopStrategy: round.stopStream, + model: round.model + ) + + var latest: String? + var lastEmit = Date.distantPast + for try await partial in completion { + try Task.checkCancellation() + latest = partial + let now = Date() + guard now.timeIntervalSince(lastEmit) >= Self.partialEmitInterval, + let suggestion = round.makeSuggestion(from: partial, id: id) + else { continue } + lastEmit = now + continuation.yield([suggestion]) + CodeCompletionLogger.logger.logPartial() + } + try Task.checkCancellation() + + if let latest { CodeCompletionLogger.logger.logResponse(latest) } + CodeCompletionLogger.logger.finish() + + let final = latest.flatMap { round.makeSuggestion(from: $0, id: id) } + continuation.yield(final.map { [$0] } ?? []) + continuation.finish() + } catch { + CodeCompletionLogger.logger.error(error) + continuation.finish(throwing: mapUserFacingError(error)) + } + } + } + cancelOnGoingRound = { task.cancel() } + continuation.onTermination = { _ in task.cancel() } + } + } + + struct PreferenceSnapshot { + var modelId: String + var chatModels: [ChatModel] + var strategyId: String + } + + static func currentSnapshot() -> PreferenceSnapshot { + PreferenceSnapshot( + modelId: UserDefaults.shared.value(for: \.customSuggestionModelId), + chatModels: UserDefaults.shared.value(for: \.chatModels), + strategyId: UserDefaults.shared.value(for: \.customSuggestionRequestStrategyId) + ) + } + + /// Nil when the strategy decides there is nothing to complete here. + func prepareRound(_ request: SuggestionRequest, snapshot: PreferenceSnapshot) throws -> Round? { + let lines = request.content.breakLines() + let (previousLines, nextLines) = Self.split( + code: request.content, + lines: lines, + at: request.cursorPosition + ) + let strategy = getStrategy( + sourceRequest: request, + prefix: previousLines, + suffix: nextLines, + snapshot: snapshot + ) + + if strategy.shouldSkip { return nil } + + let model = try getModel(snapshot) + logModel(model) + + return Round( + request: request, + prompt: strategy.createPrompt(), + postProcessor: strategy.createRawSuggestionPostProcessor(), + stopStream: strategy.createStreamStopStrategy(model: model), + model: model, + maxLines: UserDefaults.shared.value(for: \.customSuggestionMaxLines) + ) + } + + func logModel(_ model: Model) { + switch model { + case let .chatModel(model): + CodeCompletionLogger.logger.logModel(model) + case let .completionModel(model): + CodeCompletionLogger.logger.logModel(model) + case let .tabbyModel(model): + CodeCompletionLogger.logger.logModel(model) + case let .fimModel(model): + CodeCompletionLogger.logger.logModel(model) + } + } + + func getModel(_ snapshot: PreferenceSnapshot) throws -> Model { + let id = snapshot.modelId + let models = snapshot.chatModels + if let existedModel = models.first(where: { $0.id == id }) { + return .chatModel(existedModel) + } + guard let type = CustomModelType(rawValue: id) else { + throw SelectionError.modelMissing(id) + } + switch type { + case .completionModel: + return .completionModel(UserDefaults.shared.value(for: \.customSuggestionCompletionModel)) + case .tabby: + return .tabbyModel(UserDefaults.shared.value(for: \.customSuggestionTabbyModel)) + case .fimModel: + return .fimModel(UserDefaults.shared.value(for: \.customSuggestionFIMModel)) + } + } + + func mapUserFacingError(_ error: Swift.Error) -> Swift.Error { + if error is CancellationError { return error } + if let urlError = error as? URLError, urlError.code == .cancelled { return error } + if error is SuggestionServiceError { return error } + let now = Date() + if let lastNoticeDate, now.timeIntervalSince(lastNoticeDate) < 30 { + return SuggestionServiceError.silent(error) + } + lastNoticeDate = now + return SuggestionServiceError.notice(error) + } + + func getStrategy( + sourceRequest: SuggestionRequest, + prefix: [String], + suffix: [String], + snapshot: PreferenceSnapshot + ) -> any RequestStrategy { + let id = snapshot.strategyId + let type = CustomModelType(rawValue: snapshot.modelId) + if let type, type == .tabby { + return TabbyRequestStrategy( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + if let type, type == .fimModel { + return FIMEndpointRequestStrategy( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + let strategyOption = RequestStrategyOption(rawValue: id) ?? .default + return strategyOption.strategy.init( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + static func split( + code: String, + lines: [String], + at cursorPosition: CursorPosition + ) -> (head: [String], tail: [String]) { + if code.isEmpty { return ([], []) } + if lines.isEmpty { return ([], []) } + if cursorPosition.line < 0 { return ([], lines) } + if cursorPosition.line >= lines.endIndex { return (lines, []) } + + let (previousLines, nextLines): ([String], [String]) = { + let previousLines = Array(lines[0..= lines.endIndex + ? [] + : Array(lines[(cursorPosition.line + 1)...]) + let splitLine = lines[cursorPosition.line] + if cursorPosition.character < 0 { + return (previousLines, [splitLine] + nextLines) + } + if cursorPosition.character >= splitLine.count { + return (previousLines + [splitLine], nextLines) + } + let firstHalf = String(splitLine[.. String { + if count <= 0 { return self } + let lines = breakLines() + return lines.prefix(count).joined() + } + + func removeTrailingNewlinesAndWhitespace() -> String { + var text = self[...] + while let last = text.last, last.isNewline || last.isWhitespace { + text = text.dropLast(1) + } + return String(text) + } +} diff --git a/Tool/Sources/CustomSuggestionService/PromptStrategy.swift b/Tool/Sources/CustomSuggestionService/PromptStrategy.swift new file mode 100644 index 00000000..86d44796 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/PromptStrategy.swift @@ -0,0 +1,100 @@ +import CopilotForXcodeKit +import Foundation + +public protocol PromptStrategy { + /// An instruction to the AI model to generate a completion. + var systemPrompt: String { get } + /// The source code before the text cursor. Represented as an array of lines. + var prefix: [String] { get } + /// The source code after the text cursor. Represented as an array of lines. + var suffix: [String] { get } + /// The prefix that should be prepended to the response. By default the last element of + /// `prefix`. + var suggestionPrefix: SuggestionPrefix { get } + /// The relevant code snippets that the AI model should consider when generating a completion. + var relevantCodeSnippets: [RelevantCodeSnippet] { get } + /// The words at which the AI model should stop generating the completion. + var stopWords: [String] { get } + /// The language of the source code. + var language: CodeLanguage? { get } + /// If the prompt generated is raw. + var promptIsRaw: Bool { get } + + /// Creates a prompt about the source code and relevant code snippets to be sent to the AI + /// model. + /// + /// - Parameters: + /// - truncatedPrefix: The truncated source code before the text cursor. + /// - truncatedSuffix: The truncated source code after the text cursor. + /// - includedSnippets: The relevant code snippets to be included in the prompt. + /// + /// - Warning: Please make sure that the prompt won't cause the whole prompt to + /// exceed the token limit. + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] +} + +/// A meesage in prompt. +public struct PromptMessage { + public enum PromptRole { + case user + case assistant + public static var prefix: PromptRole { .user } + public static var suffix: PromptRole { .assistant } + } + + public var role: PromptRole + public var content: String + + public init(role: PromptRole, content: String) { + self.role = role + self.content = content + } +} + +/// The last line of the prefix. +public struct SuggestionPrefix { + /// The original value. + public var original: String + /// The value to be in the prompt. This value can be different than the ``original`` value. Use + /// it to tweak the prompt to make the AI model generate a better completion. + /// + /// For example, it the last character is `{`, we may want to start the generation from the + /// next line. + public var infillValue: String + /// The value to be prepended to the response that is generated from the ``infillValue``. + /// + /// For example, if we appended `// write some code` in the ``infillValue`` to make the model + /// generate code instead of comments, we may not want to include this line in the final + /// suggestion. + public var prependingValue: String + + public static var empty: SuggestionPrefix { + .init(original: "", infillValue: "", prependingValue: "") + } + + public static func unchanged(_ string: String) -> SuggestionPrefix { + .init(original: string, infillValue: string, prependingValue: string) + } + + public init(original: String, infillValue: String, prependingValue: String) { + self.original = original + self.infillValue = infillValue + self.prependingValue = prependingValue + } +} + +// MARK: - Default Implementations + +public extension PromptStrategy { + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix) + } + + var promptIsRaw: Bool { false } +} + diff --git a/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift new file mode 100644 index 00000000..c612e683 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift @@ -0,0 +1,171 @@ +import Foundation +import Parsing + +protocol RawSuggestionPostProcessingStrategy { + func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String +} + +struct DefaultRawSuggestionPostProcessingStrategy: RawSuggestionPostProcessingStrategy { + let codeWrappingTags: (opening: String, closing: String)? + + func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String { + var suggestion = extractSuggestion(from: rawSuggestion) + removePrefix(from: &suggestion, infillPrefix: infillPrefix) + removeDuplicatedIndentation(from: &suggestion, infillPrefix: infillPrefix) + removeSuffix(from: &suggestion, suffix: suffix) + return infillPrefix + suggestion + } + + /// Models like to restate the indentation of the line they continue. When the prefix already + /// ends with whitespace, leading spaces or tabs in the completion would only double the gap, + /// and they make Tab-to-accept look like indentation typing. + func removeDuplicatedIndentation(from suggestion: inout String, infillPrefix: String) { + guard let last = infillPrefix.last, last == " " || last == "\t" else { return } + while let first = suggestion.first, first == " " || first == "\t" { + suggestion.removeFirst() + } + } + + func extractSuggestion(from response: String) -> String { + let escapedMarkdownCodeBlock = removeLeadingAndTrailingMarkdownCodeBlockMark(from: response) + if let tags = codeWrappingTags { + let escapedTags = extractEnclosingSuggestion( + from: escapedMarkdownCodeBlock, + openingTag: tags.opening, + closingTag: tags.closing + ) + return escapedTags + } else { + return escapedMarkdownCodeBlock + } + } + + func removePrefix(from suggestion: inout String, infillPrefix: String) { + if suggestion.hasPrefix(infillPrefix) { + suggestion.removeFirst(infillPrefix.count) + } + } + + /// Window-mapping the lines in suggestion and the suffix to remove the common suffix. + func removeSuffix(from suggestion: inout String, suffix: [String]) { + let suggestionLines = suggestion.breakLines(appendLineBreakToLastLine: true) + if let last = suggestionLines.last, let lastIndex = suffix.firstIndex(of: last) { + var i = lastIndex - 1 + var j = suggestionLines.endIndex - 2 + while i >= 0, j >= 0, suffix[i] == suggestionLines[j] { + i -= 1 + j -= 1 + } + if i < 0 { + let endIndex = max(j, 0) + suggestion = suggestionLines[...endIndex].joined() + } + } + } + + /// Extract suggestions that is enclosed in tags. + fileprivate func extractEnclosingSuggestion( + from response: String, + openingTag: String, + closingTag: String + ) -> String { + guard !openingTag.isEmpty, !closingTag.isEmpty else { + return response + } + + let case_openingTagAtTheStart_parseEverythingInsideTheTag = Parse(input: Substring.self) { + openingTag + + OneOf { // parse until tags or the end + Parse { + OneOf { + PrefixUpTo(openingTag) + PrefixUpTo(closingTag) + } + Skip { + Rest() + } + } + + Rest() + } + } + + let case_noTagAtTheStart_parseEverythingBeforeTheTag = Parse(input: Substring.self) { + OneOf { + PrefixUpTo(openingTag) + PrefixUpTo(closingTag) + } + + Skip { + Rest() + } + } + + let parser = Parse(input: Substring.self) { + OneOf { + case_openingTagAtTheStart_parseEverythingInsideTheTag + case_noTagAtTheStart_parseEverythingBeforeTheTag + Rest() + } + } + + var text = response[...] + do { + let suggestion = try parser.parse(&text) + return String(suggestion) + } catch { + return response + } + } + + /// If the response starts with markdown code block, we should remove it. + fileprivate func removeLeadingAndTrailingMarkdownCodeBlockMark(from response: String) + -> String + { + let leadingMarkdownCodeBlockMarkParser = Parse(input: Substring.self) { + Skip { + Many { + OneOf { + " " + "\n" + } + } + "```" + } + } + + let messagePrefixingMarkdownCodeBlockMarkParser = Parse(input: Substring.self) { + Skip { + PrefixThrough(":") + "\n```" + } + } + + let removePrefixMarkdownCodeBlockMark = Parse(input: Substring.self) { + Skip { + OneOf { + leadingMarkdownCodeBlockMarkParser + messagePrefixingMarkdownCodeBlockMarkParser + } + PrefixThrough("\n") + } + OneOf { + Parse { + PrefixUpTo("```") + Skip { Rest() } + } + Rest() + } + } + + do { + var response = response[...] + let suggestion = try removePrefixMarkdownCodeBlockMark.parse(&response) + return String(suggestion) + } catch { + return response + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift new file mode 100644 index 00000000..89b7eac7 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift @@ -0,0 +1,8 @@ +import Foundation + +struct NoOpRawSuggestionPostProcessingStrategy: RawSuggestionPostProcessingStrategy { + func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String { + infillPrefix + rawSuggestion + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/AnthropicRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/AnthropicRequestStrategy.swift new file mode 100644 index 00000000..5cc53ec6 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/AnthropicRequestStrategy.swift @@ -0,0 +1,161 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// Request strategy optimized for Anthropic's Claude API. +/// +/// This strategy is specifically designed to work with Claude's message API format, +/// providing clear and strict instructions for code completion tasks. +struct AnthropicRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createStreamStopStrategy(model _: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + OpeningTagBasedStreamStopStrategy( + openingTag: Tag.openingCode, + toleranceIfNoOpeningTagFound: 0 // Claude is more precise, we don't need tolerance + ) + } + + func createRawSuggestionPostProcessor() -> DefaultRawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: ( + Tag.openingCode, + Tag.closingCode + )) + } + + enum Tag { + public static let openingCode = "" + public static let closingCode = "" + public static let openingSnippet = "" + public static let closingSnippet = "" + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = """ + You are a code completion AI with the following STRICT rules: + 1. You MUST ONLY output code within \(Tag.openingCode) and \(Tag.closingCode) tags + 2. You MUST NEVER add explanations, comments, or any text outside the tags + 3. You MUST continue the code exactly where it was left off + 4. You MUST maintain the same style, patterns, and conventions present in the surrounding code + 5. You MUST NOT include any markdown formatting or code block symbols + + Example - if given: + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode) + print("Hello + \(Tag.closingCode) + + You MUST respond EXACTLY: + \(Tag.openingCode) World")\(Tag.closingCode) + + CRITICAL REQUIREMENTS: + - Start IMMEDIATELY with \(Tag.openingCode) + - End IMMEDIATELY with \(Tag.closingCode) + - NO text before or after the tags + - NO explanations + - NO markdown + - NO commentary + """.trimmingCharacters(in: .whitespacesAndNewlines) + + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [Tag.closingCode] } // Removed "\n\n" as Claude does not accept whitespace-only sequences + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + return [.init(role: .user, content: [ + Self.createSnippetsPrompt(includedSnippets: includedSnippets), + createSourcePrompt( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix + ), + ].filter { !$0.isEmpty }.joined(separator: "\n\n"))] + } + + func createSourcePrompt(truncatedPrefix: [String], truncatedSuffix: [String]) -> String { + guard let (summary, infillBlock) = Self.createCodeSummary( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + suggestionPrefix: suggestionPrefix.infillValue + ) else { return "" } + + return """ + Below is the code from file \(filePath) that needs completion. + You MUST: + 1. Analyze the code's style, patterns, and conventions + 2. Complete the code maintaining exact formatting + 3. Only output code within \(Tag.openingCode) tags + 4. Never duplicate existing implementations + + File: \(filePath) + Indentation: \(sourceRequest.indentSize) \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + + Code to complete: + \(summary) + + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode)\(infillBlock) + """.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createSnippetsPrompt(includedSnippets: [RelevantCodeSnippet]) -> String { + guard !includedSnippets.isEmpty else { return "" } + return """ + Reference code (analyze for patterns and conventions): + + \(includedSnippets.map { snippet in + "\(Tag.openingSnippet)\n\(snippet.content)\n\(Tag.closingSnippet)" + }.joined(separator: "\n\n")) + """.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createCodeSummary( + truncatedPrefix: [String], + truncatedSuffix: [String], + suggestionPrefix: String + ) -> (summary: String, infillBlock: String)? { + guard !(truncatedPrefix.isEmpty && truncatedSuffix.isEmpty) else { return nil } + let promptLinesCount = min(10, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix( + max(0, truncatedPrefix.count - promptLinesCount) + ) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix] + }() + + return ( + summary: "\(prefixLines.joined())\(Tag.openingCode)\(Tag.closingCode)\(truncatedSuffix.joined())", + infillBlock: promptLines.joined() + ) + } + } +} diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/ContinueRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/ContinueRequestStrategy.swift new file mode 100644 index 00000000..e79ae596 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/ContinueRequestStrategy.swift @@ -0,0 +1,176 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// This strategy tries to fool the AI model that it has generated a part of the response but fail +/// to complete because of token limit. The strategy will append a user message "Continue" to let +/// the model continue it's (mock) response, so that the format may be more stable. +struct ContinueRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> DefaultRawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: ( + Tag.openingCode, + Tag.closingCode + )) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + OpeningTagBasedStreamStopStrategy( + openingTag: Tag.openingCode, + toleranceIfNoOpeningTagFound: { if case .chatModel = model { 4 } else { 0 } }() + ) + } + + enum Tag { + public static let openingCode = "" + public static let closingCode = "" + public static let openingSnippet = "" + public static let closingSnippet = "" + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = """ + You are a senior programer who take the surrounding code and \ + references from the codebase into account in order to write high-quality code to \ + complete the code enclosed in \(Tag.openingCode) tags. \ + You only respond with code that works and fits seamlessly with surrounding code. \ + Do not include anything else beyond the code. + + When you are asked to continue generating, you should continue generating the response. + For example, if your previous response is: + ``` + print(Hell + ``` + + You should continue with: + ``` + o World) + ``` + """.trimmingCharacters(in: .whitespacesAndNewlines) + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [Tag.closingCode, "\n\n"] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + return createSourcePrompt( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + includedSnippets: includedSnippets + ) + } + + func createSourcePrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + guard let (summary, infillBlock) = Self.createCodeSummary( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + suggestionPrefix: suggestionPrefix.infillValue + ) else { return [] } + + let snippets = Self.createSnippetsPrompt(includedSnippets: includedSnippets) + + let initialPrompt = PromptMessage(role: .user, content: """ + \(snippets) + + Below is the code from file \(filePath) that you are trying to complete. + Review the code carefully, detect the functionality, formats, style, patterns, \ + and logics in use and use them to predict the completion. \ + Make sure your completion has the correct syntax and formatting. + + File Path: \(filePath) + Indentation: \ + \(sourceRequest.indentSize) \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + + --- + + Here is the code: + ``` + \(summary) + ``` + + Complete code inside \(Tag.openingCode) + """.trimmingCharacters(in: .whitespacesAndNewlines)) + + let mockResponse = PromptMessage(role: .assistant, content: """ + \(Tag.openingCode)\(infillBlock) + """.trimmingCharacters(in: .whitespacesAndNewlines)) + + let continuePrompt = PromptMessage(role: .user, content: """ + Continue generating. \ + Don't duplicate existing implementations. \ + Don't try to fix what was written. \ + Don't worry about typos. + """.trimmingCharacters(in: .whitespacesAndNewlines)) + + return [ + initialPrompt, + mockResponse, + continuePrompt, + ] + } + + static func createSnippetsPrompt(includedSnippets: [RelevantCodeSnippet]) -> String { + guard !includedSnippets.isEmpty else { return "" } + var content = "References from codebase: \n\n" + for snippet in includedSnippets { + content += """ + \(Tag.openingSnippet) + \(snippet.content) + \(Tag.closingSnippet) + """ + "\n\n" + } + return content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createCodeSummary( + truncatedPrefix: [String], + truncatedSuffix: [String], + suggestionPrefix: String + ) -> (summary: String, infillBlock: String)? { + guard !(truncatedPrefix.isEmpty && truncatedSuffix.isEmpty) else { return nil } + let promptLinesCount = min(4, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix(truncatedPrefix.count - promptLinesCount) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix] + }() + + return ( + summary: "\(prefixLines.joined())\(Tag.openingCode)\(Tag.closingCode)\(truncatedSuffix.joined())", + infillBlock: promptLines.joined() + ) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/DefaultRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/DefaultRequestStrategy.swift new file mode 100644 index 00000000..5f444713 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/DefaultRequestStrategy.swift @@ -0,0 +1,162 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// The default strategy to generate prompts. +/// +/// This strategy tries to believe that the model is smart. It will explain carefully what is what +/// and tell the model to complete the code. +struct DefaultRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + OpeningTagBasedStreamStopStrategy( + openingTag: Tag.openingCode, + toleranceIfNoOpeningTagFound: { if case .chatModel = model { 4 } else { 0 } }() + ) + } + + func createRawSuggestionPostProcessor() -> DefaultRawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: ( + Tag.openingCode, + Tag.closingCode + )) + } + + enum Tag { + public static let openingCode = "" + public static let closingCode = "" + public static let openingSnippet = "" + public static let closingSnippet = "" + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = """ + You are a senior programer who take the surrounding code and \ + references from the codebase into account in order to write high-quality code to \ + complete the code enclosed in \(Tag.openingCode) tags. \ + You only respond with code that works and fits seamlessly with surrounding code. \ + Don't include anything else beyond the code. + + Code completion means to keep writing the code. For example, if I tell you to + ### + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode) + print("Hello + ### + + You should respond with: + ### + World")\(Tag.closingCode) + ### + """.trimmingCharacters(in: .whitespacesAndNewlines) + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [Tag.closingCode, "\n\n"] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + return [.init(role: .user, content: [ + Self.createSnippetsPrompt(includedSnippets: includedSnippets), + createSourcePrompt( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix + ), + ].filter { !$0.isEmpty }.joined(separator: "\n\n"))] + } + + func createSourcePrompt(truncatedPrefix: [String], truncatedSuffix: [String]) -> String { + guard let (summary, infillBlock) = Self.createCodeSummary( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + suggestionPrefix: suggestionPrefix.infillValue + ) else { return "" } + + return """ + Below is the code from file \(filePath) that you are trying to complete. + Review the code carefully, detect the functionality, formats, style, patterns, \ + and logics in use and use them to predict the completion. \ + Make sure your completion has the correct syntax and formatting. \ + Enclose the completion the XML tag \(Tag.openingCode). \ + Don't duplicate existing implementations. \ + + File Path: \(filePath) + Indentation: \ + \(sourceRequest.indentSize) \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + + --- + + Here is the code: + ``` + \(summary) + ``` + + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode)\(infillBlock) + """.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createSnippetsPrompt(includedSnippets: [RelevantCodeSnippet]) -> String { + guard !includedSnippets.isEmpty else { return "" } + var content = "References from codebase: \n\n" + for snippet in includedSnippets { + content += """ + \(Tag.openingSnippet) + \(snippet.content) + \(Tag.closingSnippet) + """ + "\n\n" + } + return content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createCodeSummary( + truncatedPrefix: [String], + truncatedSuffix: [String], + suggestionPrefix: String + ) -> (summary: String, infillBlock: String)? { + guard !(truncatedPrefix.isEmpty && truncatedSuffix.isEmpty) else { return nil } + let promptLinesCount = min(10, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix( + max(0, truncatedPrefix.count - promptLinesCount) + ) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix] + }() + + return ( + summary: "\(prefixLines.joined())\(Tag.openingCode)\(Tag.closingCode)\(truncatedSuffix.joined())", + infillBlock: promptLines.joined() + ) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift new file mode 100644 index 00000000..f8c8c678 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift @@ -0,0 +1,80 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// A special strategy for FIM endpoints. +struct FIMEndpointRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: nil) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + FIMStreamStopStrategy(prefix: prefix) + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix) + } + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) { + self.sourceRequest = sourceRequest + + let prefix = sourceRequest.relevantCodeSnippets.map { $0.content + "\n\n" } + + prefix + + self.prefix = prefix + self.suffix = suffix + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + let suffix = truncatedSuffix.joined() + let prefixContent = """ + // File Path: \(filePath) + // Indentation: \ + \(sourceRequest.indentSize) \ + \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + \(includedSnippets.map(\.content).joined(separator: "\n\n")) + \(truncatedPrefix.joined()) + """ + + let suffixContent = suffix.isEmpty ? "\n// End of file" : suffix + + return [ + .init(role: .prefix, content: prefixContent), + .init(role: .suffix, content: suffixContent) + ] + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift new file mode 100644 index 00000000..841e6deb --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift @@ -0,0 +1,150 @@ +import CopilotForXcodeKit +import Foundation +import Preferences +import AIModel + +/// https://ollama.com/library/codellama +struct FillInTheMiddleRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + FIMStreamStopStrategy(prefix: prefix) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: nil) + } + + enum Tag { + public static var stop: String { UserDefaults.shared.value(for: \.customSuggestionFIMStopToken) } + } + + struct Prompt: PromptStrategy { + fileprivate(set) var systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + // Stop words should support multiple comma-separated + var stopWords: [String] { + return ["\n\n"] + Tag.stop.split(separator: ",").map { String($0) }.filter { !$0.isEmpty } + } + var language: CodeLanguage? { sourceRequest.language } + var promptIsRaw: Bool { UserDefaults.shared.value(for: \.customSuggestionFIMPromptIsRaw) } + var attachFileInfo: Bool { UserDefaults.shared.value(for: \.customSuggestionFIMAttachFileInfo) } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + let suffix = truncatedSuffix.joined() + var template = UserDefaults.shared.value(for: \.customSuggestionFIMTemplate) + if template.isEmpty { template = UserDefaultPreferenceKeys().customSuggestionFIMTemplate.defaultValue } + // Determine whether to append file information according to attachFileInfo + let fileInfo = attachFileInfo ? """ + // File Path: \(filePath) + // Indentation: \ + \(sourceRequest.indentSize) \ + \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + """ : "" + let prefixContent = """ + \(fileInfo)\(fileInfo.isEmpty ? "" : "\n")\(includedSnippets.map(\.content).joined(separator: "\n\n")) + \(truncatedPrefix.joined()) + """ + + let suffixContent = suffix.isEmpty ? "\n// End of file" : suffix + + return [ + .init( + role: .user, + content: template + .replacingOccurrences(of: "{prefix}", with: prefixContent) + .replacingOccurrences(of: "{suffix}", with: suffixContent) + .trimmingCharacters(in: .whitespacesAndNewlines) + ), + ] + } + } +} + +struct FillInTheMiddleWithSystemPromptRequestStrategy: RequestStrategy { + let strategy: FillInTheMiddleRequestStrategy + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) { + strategy = .init(sourceRequest: sourceRequest, prefix: prefix, suffix: suffix) + } + + func createPrompt() -> some PromptStrategy { + var prompt = strategy.createPrompt() + prompt.systemPrompt = """ + You are a senior programer who take the surrounding code and \ + references from the codebase into account in order to write high-quality code to \ + complete the code enclosed in the given code. \ + You only respond with code that works and fits seamlessly with surrounding code. \ + Don't include anything else beyond the code. \ + The prefix will follow the PRE tag and the suffix will follow the SUF tag. \ + You should write the code that fits seamlessly after the MID tag. + """.trimmingCharacters(in: .whitespacesAndNewlines) + + return prompt + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + strategy.createStreamStopStrategy(model: model) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + strategy.createRawSuggestionPostProcessor() + } +} + +struct FIMStreamStopStrategy: StreamStopStrategy { + let prefix: [String] + + func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + if let prefixLastLine = prefix.last { + if let lastLineIndex = existedLines.lastIndex(of: prefixLastLine) { + if existedLines.count >= lastLineIndex + 1 + proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } else { + if existedLines.count >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } + } else { + if existedLines.count >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/NaiveRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/NaiveRequestStrategy.swift new file mode 100644 index 00000000..a448876d --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/NaiveRequestStrategy.swift @@ -0,0 +1,86 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// This strategy mixed and rearrange everything naively to make the model think it's writing code +/// at the end of a file. +struct NaiveRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Request { + Request( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + NoOpRawSuggestionPostProcessingStrategy() + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + DefaultStreamStopStrategy() + } + + struct Request: PromptStrategy { + let systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { ["\n\n"] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + let promptLinesCount = min(10, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix(truncatedPrefix.count - promptLinesCount) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix.infillValue] + }() + + /// Mix and rearrange the file and relevant code snippets. + let code = { + var codes = [String]() + if !includedSnippets.isEmpty { + codes.append(includedSnippets.map(\.content).joined(separator: "\n\n")) + } + if !truncatedSuffix.isEmpty { + codes.append(""" + // From the end of the file + \(truncatedSuffix.joined()) + // End + """) + } + codes.append("\(prefixLines.joined())\(promptLines.joined())") + return codes.joined(separator: "\n\n") + }() + + return [.init(role: .user, content: """ + File path: \(filePath) + + --- + + \(code) + """.trimmingCharacters(in: .whitespacesAndNewlines))] + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/TabbyRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/TabbyRequestStrategy.swift new file mode 100644 index 00000000..4b1ab9ab --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/TabbyRequestStrategy.swift @@ -0,0 +1,66 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// A special strategy for Tabby. +struct TabbyRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + NoOpRawSuggestionPostProcessingStrategy() + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + NeverStreamStopStrategy() + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix) + } + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) { + self.sourceRequest = sourceRequest + + let prefix = sourceRequest.relevantCodeSnippets.map { $0.content + "\n\n" } + + prefix + + self.prefix = prefix + self.suffix = suffix + } + + /// Not used by ``TabbyService``. + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + [] + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift new file mode 100644 index 00000000..b1fee7cf --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift @@ -0,0 +1,74 @@ +import CopilotForXcodeKit +import Foundation +import AIModel +import Preferences + +/// Prompts may behave differently in different LLMs. +/// This protocol allows for different strategies to be used to generate prompts. +protocol RequestStrategy { + associatedtype Prompt: PromptStrategy + associatedtype RawSuggestionPostProcessor: RawSuggestionPostProcessingStrategy + associatedtype SomeStreamStopStrategy: StreamStopStrategy + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) + + /// If the request should be skipped. + var shouldSkip: Bool { get } + + /// Create a prompt to generate code completion. + func createPrompt() -> Prompt + + /// Control how a stream should stop early. + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> SomeStreamStopStrategy + + /// The AI model may not return a suggestion in a ideal format. You can use it to reformat the + /// suggestions. + func createRawSuggestionPostProcessor() -> RawSuggestionPostProcessor +} + +extension RequestStrategyOption { + var strategy: any RequestStrategy.Type { + switch self { + case .default: + return DefaultRequestStrategy.self + case .naive: + return NaiveRequestStrategy.self + case .continue: + return ContinueRequestStrategy.self + case .codeLlamaFillInTheMiddle: + return FillInTheMiddleRequestStrategy.self + case .codeLlamaFillInTheMiddleWithSystemPrompt: + return FillInTheMiddleWithSystemPromptRequestStrategy.self + case .anthropic: + return AnthropicRequestStrategy.self + } + } +} + +// MARK: - Default Implementations + +extension RequestStrategy { + var shouldSkip: Bool { false } +} + +// MARK: - Suggestion Prefix Helpers + +extension SuggestionPrefix { + func curlyBracesLineBreak() -> SuggestionPrefix { + func mutate(_ string: String) -> String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix("{") { + return string + " " + } + if trimmed.hasSuffix("}") { + return string + "\n" + } + return string + } + + let infillValue = mutate(infillValue) + let prependingValue = mutate(prependingValue) + return .init(original: original, infillValue: infillValue, prependingValue: prependingValue) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/ResponseStream.swift b/Tool/Sources/CustomSuggestionService/ResponseStream.swift new file mode 100644 index 00000000..82a4b37d --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/ResponseStream.swift @@ -0,0 +1,52 @@ +import Foundation + +struct ResponseStream: AsyncSequence { + func makeAsyncIterator() -> Stream.AsyncIterator { + stream.makeAsyncIterator() + } + + typealias Stream = AsyncThrowingStream + typealias AsyncIterator = Stream.AsyncIterator + typealias Element = Chunk + + struct LineContent { + let chunk: Chunk? + let done: Bool + } + + let stream: Stream + + init(single chunk: Chunk) { + stream = AsyncThrowingStream { continuation in + continuation.yield(chunk) + continuation.finish() + } + } + + init(result: URLSession.AsyncBytes, lineExtractor: @escaping (String) throws -> LineContent) { + stream = AsyncThrowingStream { continuation in + let task = Task { + do { + for try await line in result.lines { + if Task.isCancelled { break } + let content = try lineExtractor(line) + if let chunk = content.chunk { + continuation.yield(chunk) + } + + if content.done { break } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + result.task.cancel() + } + } + continuation.onTermination = { _ in + task.cancel() + result.task.cancel() + } + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamLineLimiter.swift b/Tool/Sources/CustomSuggestionService/StreamLineLimiter.swift new file mode 100644 index 00000000..2f65f871 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamLineLimiter.swift @@ -0,0 +1,63 @@ +import Foundation +import Preferences + +final class StreamLineLimiter { + public private(set) var result = "" + private var currentLine = "" + private var existedLines = [String]() + private let lineLimit: Int + private let strategy: any StreamStopStrategy + + enum PushResult: Equatable { + case `continue` + case finish(String) + } + + init( + lineLimit: Int = UserDefaults.shared.value(for: \.customSuggestionMaxLines), + strategy: any StreamStopStrategy + ) { + self.lineLimit = lineLimit + self.strategy = strategy + } + + func push(_ token: String) -> PushResult { + currentLine.append(token) + if let newLine = currentLine.last(where: { $0.isNewline }) { + let lines = currentLine + .breakLines(proposedLineEnding: String(newLine), appendLineBreakToLastLine: false) + let (newLines, lastLine) = lines.headAndTail + existedLines.append(contentsOf: newLines) + currentLine = lastLine ?? "" + } + + let stopResult = if lineLimit <= 0 { + StreamStopStrategyResult.continue + } else { + strategy.shouldStop( + existedLines: existedLines, + currentLine: currentLine, + proposedLineLimit: lineLimit + ) + } + + switch stopResult { + case .continue: + result.append(token) + return .continue + case let .stop(appendingNewContent): + if appendingNewContent { + result.append(token) + } + return .finish(result) + } + } +} + +extension Array { + var headAndTail: ([Element], Element?) { + guard let tail = last else { return ([], nil) } + return (Array(dropLast()), tail) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/DefaultStreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/DefaultStreamStopStrategy.swift new file mode 100644 index 00000000..8e9c3592 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/DefaultStreamStopStrategy.swift @@ -0,0 +1,15 @@ +public struct DefaultStreamStopStrategy: StreamStopStrategy { + public init() {} + + public func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + if existedLines.count >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/NeverStreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/NeverStreamStopStrategy.swift new file mode 100644 index 00000000..fa924dd6 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/NeverStreamStopStrategy.swift @@ -0,0 +1,12 @@ +public struct NeverStreamStopStrategy: StreamStopStrategy { + public init() {} + + public func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + .continue + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift new file mode 100644 index 00000000..02cd3819 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift @@ -0,0 +1,31 @@ +import Foundation + +public struct OpeningTagBasedStreamStopStrategy: StreamStopStrategy { + public let openingTag: String + public let toleranceIfNoOpeningTagFound: Int + + public init(openingTag: String, toleranceIfNoOpeningTagFound: Int) { + self.openingTag = openingTag + self.toleranceIfNoOpeningTagFound = toleranceIfNoOpeningTagFound + } + + public func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + if let index = existedLines.firstIndex(where: { $0.contains(openingTag) }) { + if existedLines.count - index - 1 >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } else { + if existedLines.count >= proposedLineLimit + toleranceIfNoOpeningTagFound { + return .stop(appendingNewContent: true) + } else { + return .continue + } + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/StreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/StreamStopStrategy.swift new file mode 100644 index 00000000..b493d55d --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/StreamStopStrategy.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum StreamStopStrategyResult { + case `continue` + case stop(appendingNewContent: Bool) +} + +public protocol StreamStopStrategy { + func shouldStop(existedLines: [String], currentLine: String, proposedLineLimit: Int) + -> StreamStopStrategyResult +} + diff --git a/Tool/Sources/CustomSuggestionService/TextProcessing/ConvertRange.swift b/Tool/Sources/CustomSuggestionService/TextProcessing/ConvertRange.swift new file mode 100644 index 00000000..7b41b073 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/TextProcessing/ConvertRange.swift @@ -0,0 +1,34 @@ +import CopilotForXcodeKit +import Foundation + +public func convertRangeToCursorRange( + _ range: ClosedRange, + in lines: [String] +) -> CursorRange { + guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) } + var countS = 0 + var countE = 0 + var cursorRange = CursorRange(start: .zero, end: .outOfScope) + for (i, line) in lines.enumerated() { + // The range is counted in UTF8, which causes line endings like \r\n to be of length 2. + let lineEndingAddition = line.lineEnding.utf8.count - 1 + if countS <= range.lowerBound, + range.lowerBound < countS + line.count + lineEndingAddition + { + cursorRange.start = .init(line: i, character: range.lowerBound - countS) + } + if countE <= range.upperBound, + range.upperBound < countE + line.count + lineEndingAddition + { + cursorRange.end = .init(line: i, character: range.upperBound - countE) + break + } + countS += line.count + lineEndingAddition + countE += line.count + lineEndingAddition + } + if cursorRange.end == .outOfScope { + cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0) + } + return cursorRange +} + diff --git a/Tool/Sources/CustomSuggestionService/TextProcessing/String+Extensions.swift b/Tool/Sources/CustomSuggestionService/TextProcessing/String+Extensions.swift new file mode 100644 index 00000000..ddbe9903 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/TextProcessing/String+Extensions.swift @@ -0,0 +1,52 @@ +import Foundation + +public extension String { + /// The line ending of the string. + /// + /// We are pretty safe to just check the last character here, in most case, a line ending + /// will be in the end of the string. + /// + /// For other situations, we can assume that they are "\n". + var lineEnding: Character { + if let last, last.isNewline { return last } + return "\n" + } + + func splitByNewLine( + omittingEmptySubsequences: Bool = true, + fast: Bool = true + ) -> [Substring] { + if fast { + let lineEndingInText = lineEnding + return split( + separator: lineEndingInText, + omittingEmptySubsequences: omittingEmptySubsequences + ) + } + return split( + omittingEmptySubsequences: omittingEmptySubsequences, + whereSeparator: \.isNewline + ) + } + + /// Break a string into lines. + func breakLines( + proposedLineEnding: String? = nil, + appendLineBreakToLastLine: Bool = false + ) -> [String] { + let lineEndingInText = lineEnding + let lineEnding = proposedLineEnding ?? String(lineEndingInText) + // Split on character for better performance. + let lines = split(separator: lineEndingInText, omittingEmptySubsequences: false) + var all = [String]() + for (index, line) in lines.enumerated() { + if !appendLineBreakToLastLine, index == lines.endIndex - 1 { + all.append(String(line)) + } else { + all.append(String(line) + lineEnding) + } + } + return all + } +} + diff --git a/Tool/Sources/CustomSuggestionService/TruncateStrategy/TruncateStrategy.swift b/Tool/Sources/CustomSuggestionService/TruncateStrategy/TruncateStrategy.swift new file mode 100644 index 00000000..a418b720 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/TruncateStrategy/TruncateStrategy.swift @@ -0,0 +1,91 @@ +import CopilotForXcodeKit +import Foundation + +public protocol TruncateStrategy { + func createTruncatedPrompt(promptStrategy: PromptStrategy) -> [PromptMessage] +} + +public struct DefaultTruncateStrategy: TruncateStrategy { + let maxTokenLimit: Int + let countToken: ([PromptMessage]) -> Int + + public init(maxTokenLimit: Int, countToken: @escaping ([PromptMessage]) -> Int = { + $0.reduce(0) { $0 + $1.content.count } + }) { + self.maxTokenLimit = maxTokenLimit + self.countToken = countToken + } + + public func createTruncatedPrompt(promptStrategy: PromptStrategy) -> [PromptMessage] { + var prefix = promptStrategy.prefix + var suffix = promptStrategy.suffix + var snippets = promptStrategy.relevantCodeSnippets + + var prompts = promptStrategy.createPrompt( + truncatedPrefix: prefix, + truncatedSuffix: suffix, + includedSnippets: snippets + ) + + let limit = maxTokenLimit - countToken([.init( + role: .user, + content: promptStrategy.systemPrompt + )]) + + let prefixDropWeight = 1 + let suffixDropWeight = 5 + let snippetsDropWeight = 8 + + while countToken(prompts) > limit, + !(prefix.isEmpty && suffix.isEmpty && snippets.isEmpty) + { + let p = prefix.count * prefixDropWeight + let s = suffix.count * suffixDropWeight + let n = snippets.count * snippetsDropWeight + + let maxScore = max(p, s, n) + switch maxScore { + case s: + truncateSuffix(&suffix) + case n: + truncateSnippets(&snippets) + case p: + truncatePrefix(&prefix) + default: + truncateSuffix(&suffix) + } + + prompts = promptStrategy.createPrompt( + truncatedPrefix: prefix, + truncatedSuffix: suffix, + includedSnippets: snippets + ) + } + + return prompts + } + + /// Drop the last one third. + func truncateSuffix(_ suffix: inout [String]) { + let step = 3 + + if suffix.isEmpty { return } + let dropCount = max(suffix.count / step, 1) + suffix.removeLast(dropCount) + } + + /// Drop the leading one fourth. + func truncatePrefix(_ prefix: inout [String]) { + let step = 4 + + if prefix.isEmpty { return } + let dropCount = max(prefix.count / step, 1) + prefix.removeFirst(dropCount) + } + + func truncateSnippets(_ snippets: inout [RelevantCodeSnippet]) { + if snippets.isEmpty { return } + snippets.removeLast() + } +} + diff --git a/Tool/Sources/Preferences/AppLanguage.swift b/Tool/Sources/Preferences/AppLanguage.swift new file mode 100644 index 00000000..a8bcd54b --- /dev/null +++ b/Tool/Sources/Preferences/AppLanguage.swift @@ -0,0 +1,49 @@ +import Foundation + +/// The languages this build ships localizations for. +/// +/// `Bundle` picks a localization from `AppleLanguages`, so an in-app choice has to be mirrored into +/// the process' own defaults before the first localized lookup happens. Every executable that shows +/// text does that in ``AppLanguage/applyPreferredLanguage()`` at the very start of its launch. +public enum AppLanguage: String, CaseIterable, Codable, Equatable { + /// Follow the languages configured in System Settings. + case system = "" + case english = "en" + case simplifiedChinese = "zh-Hans" + case traditionalChinese = "zh-Hant" + + /// The name of the language, always written in that language. + public var displayName: String { + switch self { + case .system: return NSLocalizedString("Follow System", comment: "App language option") + case .english: return "English" + case .simplifiedChinese: return "简体中文" + case .traditionalChinese: return "繁體中文" + } + } +} + +public extension AppLanguage { + private static let appleLanguagesKey = "AppleLanguages" + + /// Mirrors the shared language preference into this process' `AppleLanguages` override. + /// + /// Call this before any localized string is read. It is cheap and idempotent, so processes that + /// cannot pin down a single earliest moment may call it more than once. + static func applyPreferredLanguage() { + let preference = UserDefaults.shared.value(for: \.appLanguage) + let standard = UserDefaults.standard + let existing = standard.array(forKey: appleLanguagesKey) as? [String] + + guard !preference.isEmpty else { + // Removing the app-level override hands the choice back to System Settings. + if existing != nil { + standard.removeObject(forKey: appleLanguagesKey) + } + return + } + + guard existing != [preference] else { return } + standard.set([preference], forKey: appleLanguagesKey) + } +} diff --git a/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift b/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift new file mode 100644 index 00000000..3ee05c2d --- /dev/null +++ b/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Which OpenAI endpoint to call for chat models. Defaults to `/v1/responses`; models whose +/// server only speaks `/v1/chat/completions` need the endpoint picker switched per model. +public enum OpenAIChatAPI: String, Codable, CaseIterable, Equatable { + /// `POST /v1/chat/completions` + case chatCompletions + /// `POST /v1/responses` + case responses +} + +/// `reasoning_effort` (Chat Completions) / `reasoning.effort` (Responses). +/// +/// The raw value is sent verbatim. When the preference is empty the field is omitted and the +/// request keeps the legacy shape (`temperature`, `stop`, `max_tokens`). +public enum ReasoningEffort: String, Codable, CaseIterable, Equatable { + case none + case minimal + case low + case medium + case high + case xhigh + + /// `low/medium/high/xhigh` add `reasoningTokenBudget` to max output tokens. + public var addsReasoningTokenBudget: Bool { + switch self { + case .none, .minimal: return false + case .low, .medium, .high, .xhigh: return true + } + } +} + +/// Per-chat-model API options for the custom suggestion provider. +public struct ChatModelAPIOptions: Codable, Equatable { + public var api: OpenAIChatAPI + public var reasoningEffort: ReasoningEffort? + public var reasoningTokenBudget: Int + + public init( + api: OpenAIChatAPI = .responses, + reasoningEffort: ReasoningEffort? = nil, + reasoningTokenBudget: Int = 1500 + ) { + self.api = api + self.reasoningEffort = reasoningEffort + self.reasoningTokenBudget = reasoningTokenBudget + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + api = try container.decodeIfPresent(OpenAIChatAPI.self, forKey: .api) ?? .responses + reasoningEffort = try container.decodeIfPresent( + ReasoningEffort.self, + forKey: .reasoningEffort + ) + reasoningTokenBudget = try container.decodeIfPresent( + Int.self, + forKey: .reasoningTokenBudget + ) ?? 1500 + } + + /// Reads options for `modelId`, migrating the old global keys once if needed. + public static func stored( + for modelId: String, + defaults: UserDefaultsType = UserDefaults.shared + ) -> ChatModelAPIOptions { + var dict = defaults.value(for: \.customSuggestionChatModelAPIOptions) + if dict.isEmpty, migrateLegacyGlobals(into: &dict, modelId: modelId, defaults: defaults) { + defaults.set(dict, for: \.customSuggestionChatModelAPIOptions) + } + return dict[modelId] ?? ChatModelAPIOptions() + } + + public static func update( + _ options: ChatModelAPIOptions, + for modelId: String, + defaults: UserDefaultsType = UserDefaults.shared + ) { + var dict = defaults.value(for: \.customSuggestionChatModelAPIOptions) + if dict.isEmpty { + _ = migrateLegacyGlobals(into: &dict, modelId: modelId, defaults: defaults) + } + dict[modelId] = options + defaults.set(dict, for: \.customSuggestionChatModelAPIOptions) + } + + /// Old global preference keys, kept for a one-time compatibility read. + public static let legacyOpenAIAPIKey = "CustomSuggestionService-OpenAIAPI" + public static let legacyReasoningEffortKey = "CustomSuggestionService-ReasoningEffort" + + /// Returns true when values were written into `dict`. + @discardableResult + public static func migrateLegacyGlobals( + into dict: inout [String: ChatModelAPIOptions], + modelId: String, + defaults: UserDefaultsType + ) -> Bool { + let oldAPI = defaults.value(forKey: legacyOpenAIAPIKey) as? String + let oldEffort = defaults.value(forKey: legacyReasoningEffortKey) as? String + let hasNonDefaultAPI = oldAPI != nil + let hasNonDefaultEffort = !(oldEffort ?? "").isEmpty + guard hasNonDefaultAPI || hasNonDefaultEffort else { return false } + + dict[modelId] = ChatModelAPIOptions( + api: OpenAIChatAPI(rawValue: oldAPI ?? "") ?? .responses, + reasoningEffort: oldEffort.flatMap(ReasoningEffort.init(rawValue:)) + ) + defaults.set(nil, forKey: legacyOpenAIAPIKey) + defaults.set(nil, forKey: legacyReasoningEffortKey) + return true + } +} diff --git a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift new file mode 100644 index 00000000..2d0d00b4 --- /dev/null +++ b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift @@ -0,0 +1,92 @@ +import AIModel +import Foundation + +/// Preference keys of the built-in custom model suggestion provider. +/// +/// 键名沿用独立 App,但不迁移其配置;旧值 `chatModel` 视为无效模型。 +public extension UserDefaultPreferenceKeys { + /// Either a `CustomModelType` raw value, or the id of a chat model in ``chatModels``. + var customSuggestionModelId: PreferenceKey { + .init( + defaultValue: CustomModelType.default.rawValue, + key: "CustomSuggestionService-SuggestionChatModelId" + ) + } + + + var customSuggestionCompletionModel: PreferenceKey> { + .init( + defaultValue: .init(CompletionModel( + id: "ID", + name: "Custom", + format: .openAI, + info: .init() + )), + key: "CustomSuggestionService-CustomCompletionModel" + ) + } + + var customSuggestionFIMModel: PreferenceKey> { + .init( + defaultValue: .init(FIMModel(id: "ID", name: "Custom", format: .mistral, info: .init())), + key: "CustomSuggestionService-CustomFIMModel" + ) + } + + var customSuggestionTabbyModel: PreferenceKey> { + .init( + defaultValue: .init(.init( + url: "", + authorizationMode: .none, + apiKeyName: "", + authorizationHeaderName: "", + username: "" + )), + key: "CustomSuggestionService-TabbyModel" + ) + } + + var customSuggestionRequestStrategyId: PreferenceKey { + .init(defaultValue: "", key: "CustomSuggestionService-RequestStrategyId") + } + + /// Per chat-model API options, keyed by `ChatModel.id`. + var customSuggestionChatModelAPIOptions: PreferenceKey> { + .init( + defaultValue: .init([:]), + key: "CustomSuggestionService-ChatModelAPIOptions" + ) + } + + /// 0 means stop words only. + var customSuggestionMaxLines: PreferenceKey { + .init(defaultValue: 0, key: "CustomSuggestionService-MaxNumberOfLinesOfSuggestion") + } + + var customSuggestionMaxGenerationToken: PreferenceKey { + .init(defaultValue: 200, key: "CustomSuggestionService-MaxGenerationToken") + } + + var customSuggestionVerboseLog: PreferenceKey { + .init(defaultValue: false, key: "CustomSuggestionService-VerboseLog") + } + + var customSuggestionFIMStopToken: PreferenceKey { + .init(defaultValue: "", key: "CustomSuggestionService-FimStopToken") + } + + var customSuggestionFIMTemplate: PreferenceKey { + .init( + defaultValue: "
 {prefix} {suffix} ",
+            key: "CustomSuggestionService-FimTemplate"
+        )
+    }
+
+    var customSuggestionFIMPromptIsRaw: PreferenceKey {
+        .init(defaultValue: false, key: "CustomSuggestionService-FimPromptIsRaw")
+    }
+
+    var customSuggestionFIMAttachFileInfo: PreferenceKey {
+        .init(defaultValue: true, key: "CustomSuggestionService-fimAttachFileInfo")
+    }
+}
diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift
index 7b955d50..c8e5500c 100644
--- a/Tool/Sources/Preferences/Keys.swift
+++ b/Tool/Sources/Preferences/Keys.swift
@@ -105,6 +105,14 @@ public struct UserDefaultPreferenceKeys {
         defaultValue: false,
         key: "DebugOverlayPanel"
     )
+
+    // MARK: App Language
+
+    /// An empty value means "follow System Settings". See ``AppLanguage``.
+    public let appLanguage = PreferenceKey(
+        defaultValue: "",
+        key: "AppLanguage"
+    )
 }
 
 // MARK: - OpenAI Account Settings
diff --git a/Tool/Sources/Preferences/RequestStrategyOption.swift b/Tool/Sources/Preferences/RequestStrategyOption.swift
new file mode 100644
index 00000000..2b8cc854
--- /dev/null
+++ b/Tool/Sources/Preferences/RequestStrategyOption.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+public enum RequestStrategyOption: String, CaseIterable, Codable {
+    case `default` = ""
+    case naive
+    case `continue`
+    case codeLlamaFillInTheMiddle
+    case codeLlamaFillInTheMiddleWithSystemPrompt
+    case anthropic
+}
diff --git a/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
index b6f2eeb5..190337a5 100644
--- a/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
+++ b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
@@ -3,6 +3,8 @@ import Foundation
 public enum BuiltInSuggestionFeatureProvider: Int, CaseIterable, Codable {
     case gitHubCopilot
     case codeium
+    /// Not the next sequential Int: a persisted `2` from earlier builds will fail to decode.
+    case customModel = 1000
 }
 
 public enum SuggestionFeatureProvider: RawRepresentable, Hashable {
diff --git a/Tool/Sources/SharedUIComponents/SettingsDivider.swift b/Tool/Sources/SharedUIComponents/SettingsDivider.swift
index 15db820d..73e4fdb7 100644
--- a/Tool/Sources/SharedUIComponents/SettingsDivider.swift
+++ b/Tool/Sources/SharedUIComponents/SettingsDivider.swift
@@ -30,7 +30,7 @@ public struct SettingsDivider: View {
 }
 
 extension SettingsDivider where Title == Text {
-    public init(_ title: String) {
+    public init(_ title: LocalizedStringKey) {
         self.title = Text(title)
     }
 }
diff --git a/Tool/Sources/SharedUIComponents/SubSection.swift b/Tool/Sources/SharedUIComponents/SubSection.swift
index 4fc78274..acc99083 100644
--- a/Tool/Sources/SharedUIComponents/SubSection.swift
+++ b/Tool/Sources/SharedUIComponents/SubSection.swift
@@ -44,7 +44,7 @@ public struct SubSection: View {
 }
 
 public extension SubSection where Description == Text {
-    init(title: Title, description: String, @ViewBuilder content: @escaping () -> Content) {
+    init(title: Title, description: LocalizedStringKey, @ViewBuilder content: @escaping () -> Content) {
         self.init(title: title, description: Text(description), content: content)
     }
 }
@@ -68,7 +68,7 @@ public extension SubSection where Title == EmptyView, Description == EmptyView {
 }
 
 public extension SubSection where Title == EmptyView, Description == Text {
-    init(description: String, @ViewBuilder content: @escaping () -> Content) {
+    init(description: LocalizedStringKey, @ViewBuilder content: @escaping () -> Content) {
         self.init(title: EmptyView(), description: description, content: content)
     }
 }
diff --git a/Tool/Sources/SuggestionProvider/NeighboringSnippetRetriever.swift b/Tool/Sources/SuggestionProvider/NeighboringSnippetRetriever.swift
new file mode 100644
index 00000000..aecfc0ca
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/NeighboringSnippetRetriever.swift
@@ -0,0 +1,183 @@
+import Foundation
+import SuggestionBasic
+
+/// Splits code into the identifier-ish tokens used to compare two pieces of code.
+public enum CodeTokenizer {
+    /// Words that appear in almost every file and so carry no signal.
+    static let stopWords: Set = [
+        "self", "true", "false", "nil", "null", "none", "let", "var", "func", "class", "struct",
+        "enum", "protocol", "extension", "import", "return", "if", "else", "guard", "for", "while",
+        "in", "case", "switch", "default", "break", "continue", "do", "try", "catch", "throw",
+        "throws", "async", "await", "public", "private", "internal", "fileprivate", "static",
+        "final", "override", "init", "deinit", "get", "set", "where", "as", "is", "new", "this",
+        "const", "int", "string", "bool", "void", "def", "end", "type", "value", "data",
+    ]
+
+    /// Tokens shorter than this are noise (`i`, `x`, `id` …).
+    static let minimumLength = 3
+
+    public static func tokens(in lines: S) -> Set where S.Element == String {
+        var result = Set()
+        for line in lines {
+            var current = ""
+            for character in line {
+                if character.isLetter || character.isNumber || character == "_" {
+                    current.append(character)
+                } else {
+                    add(current, to: &result)
+                    current = ""
+                }
+            }
+            add(current, to: &result)
+        }
+        return result
+    }
+
+    private static func add(_ token: String, to set: inout Set) {
+        guard token.count >= minimumLength else { return }
+        let lowered = token.lowercased()
+        guard !stopWords.contains(lowered) else { return }
+        set.insert(lowered)
+    }
+}
+
+/// A file that has been prepared once so it can be scored cheaply on every keystroke.
+public struct IndexedSnippetSource {
+    public struct Window {
+        public var startLine: Int
+        public var endLine: Int
+        public var tokens: Set
+    }
+
+    public var filePath: String
+    public var lines: [String]
+    public var windows: [Window]
+
+    public init(filePath: String, lines: [String], windows: [Window]) {
+        self.filePath = filePath
+        self.lines = lines
+        self.windows = windows
+    }
+}
+
+/// Copilot-style "neighboring tabs" retrieval.
+///
+/// Every other opened file is cut into fixed windows, each window is scored against the code
+/// around the cursor with Jaccard similarity over identifier tokens, and the best windows become
+/// reference snippets. It is deliberately index-free: no embeddings, no network, no warm-up.
+public enum NeighboringSnippetRetriever {
+    public struct Options {
+        /// How many lines before the cursor form the query.
+        public var referenceWindow: Int
+        public var windowSize: Int
+        public var windowStride: Int
+        public var maxSnippets: Int
+        public var maxSnippetsPerFile: Int
+        public var minimumScore: Double
+
+        public init(
+            referenceWindow: Int = 40,
+            windowSize: Int = 30,
+            windowStride: Int = 15,
+            maxSnippets: Int = 3,
+            maxSnippetsPerFile: Int = 1,
+            minimumScore: Double = 0.1
+        ) {
+            self.referenceWindow = referenceWindow
+            self.windowSize = windowSize
+            self.windowStride = windowStride
+            self.maxSnippets = maxSnippets
+            self.maxSnippetsPerFile = maxSnippetsPerFile
+            self.minimumScore = minimumScore
+        }
+    }
+
+    public static func index(
+        filePath: String,
+        lines: [String],
+        options: Options = .init()
+    ) -> IndexedSnippetSource {
+        var windows = [IndexedSnippetSource.Window]()
+        guard !lines.isEmpty else {
+            return .init(filePath: filePath, lines: lines, windows: windows)
+        }
+        let size = max(1, options.windowSize)
+        let stride = max(1, options.windowStride)
+        var start = 0
+        while start < lines.count {
+            let end = min(lines.count, start + size)
+            windows.append(.init(
+                startLine: start,
+                endLine: end,
+                tokens: CodeTokenizer.tokens(in: lines[start.. Set {
+        guard !currentLines.isEmpty else { return [] }
+        let cursorLine = min(max(0, cursorPosition.line), currentLines.count - 1)
+        let start = max(0, cursorLine - options.referenceWindow)
+        let end = min(currentLines.count, cursorLine + 1)
+        guard start < end else { return [] }
+        return CodeTokenizer.tokens(in: currentLines[start.., _ rhs: Set) -> Double {
+        if lhs.isEmpty || rhs.isEmpty { return 0 }
+        let intersection = lhs.intersection(rhs).count
+        if intersection == 0 { return 0 }
+        let union = lhs.count + rhs.count - intersection
+        return Double(intersection) / Double(union)
+    }
+
+    public static func retrieve(
+        query: Set,
+        sources: [IndexedSnippetSource],
+        commentPrefix: String = "//",
+        options: Options = .init()
+    ) -> [RelevantCodeSnippet] {
+        guard !query.isEmpty, options.maxSnippets > 0 else { return [] }
+
+        struct Match {
+            var filePath: String
+            var lines: ArraySlice
+            var score: Double
+        }
+
+        var matches = [Match]()
+        for source in sources {
+            var best = [Match]()
+            for window in source.windows {
+                let score = jaccardSimilarity(query, window.tokens)
+                guard score >= options.minimumScore else { continue }
+                best.append(.init(
+                    filePath: source.filePath,
+                    lines: source.lines[window.startLine.. $1.score }
+            matches.append(contentsOf: best.prefix(max(1, options.maxSnippetsPerFile)))
+        }
+
+        matches.sort { $0.score > $1.score }
+        let chosen = matches.prefix(options.maxSnippets)
+
+        return chosen.enumerated().map { index, match in
+            RelevantCodeSnippet(
+                content: "\(commentPrefix) Path: \(match.filePath)\n"
+                    + match.lines.joined(separator: "\n"),
+                priority: chosen.count - index,
+                filePath: match.filePath
+            )
+        }
+    }
+}
diff --git a/Tool/Sources/SuggestionProvider/RecentEditSnippet.swift b/Tool/Sources/SuggestionProvider/RecentEditSnippet.swift
new file mode 100644
index 00000000..c96a6da0
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/RecentEditSnippet.swift
@@ -0,0 +1,137 @@
+import Foundation
+
+/// Line comment token per language, so injected context stays valid code wherever it lands.
+public enum CodeCommentStyle {
+    public static func prefix(forFileExtension fileExtension: String) -> String {
+        switch fileExtension.lowercased() {
+        case "py", "rb", "sh", "bash", "zsh", "yml", "yaml", "toml", "pl", "r", "cmake":
+            return "#"
+        case "sql", "lua", "hs", "elm", "applescript":
+            return "--"
+        default:
+            return "//"
+        }
+    }
+}
+
+/// One observed change to a file: the lines that were there and the lines that replaced them.
+public struct RecentEditHunk: Equatable {
+    public var filePath: String
+    public var startLine: Int
+    public var removed: [String]
+    public var inserted: [String]
+
+    public init(filePath: String, startLine: Int, removed: [String], inserted: [String]) {
+        self.filePath = filePath
+        self.startLine = startLine
+        self.removed = removed
+        self.inserted = inserted
+    }
+}
+
+public enum RecentEditDiff {
+    /// Diffs two versions of a file by trimming the common head and tail.
+    ///
+    /// Suggestion rounds fire while the user types, so the delta between two rounds is almost
+    /// always a single contiguous edit. That makes the cheap trim as accurate as a real diff here,
+    /// and it stays O(n) on every keystroke.
+    public static func hunk(
+        filePath: String,
+        before: [String],
+        after: [String]
+    ) -> RecentEditHunk? {
+        let normalizedBefore = before.map(trimNewline)
+        let normalizedAfter = after.map(trimNewline)
+
+        var head = 0
+        while head < normalizedBefore.count,
+              head < normalizedAfter.count,
+              normalizedBefore[head] == normalizedAfter[head]
+        {
+            head += 1
+        }
+
+        var tail = 0
+        while tail < normalizedBefore.count - head,
+              tail < normalizedAfter.count - head,
+              normalizedBefore[normalizedBefore.count - 1 - tail]
+              == normalizedAfter[normalizedAfter.count - 1 - tail]
+        {
+            tail += 1
+        }
+
+        let removed = Array(normalizedBefore[head..<(normalizedBefore.count - tail)])
+        let inserted = Array(normalizedAfter[head..<(normalizedAfter.count - tail)])
+        guard !(removed.isEmpty && inserted.isEmpty) else { return nil }
+
+        return .init(
+            filePath: filePath,
+            startLine: head,
+            removed: removed,
+            inserted: inserted
+        )
+    }
+
+    static func trimNewline(_ line: String) -> String {
+        var line = line
+        while let last = line.last, last.isNewline { line.removeLast() }
+        return line
+    }
+}
+
+/// Renders recent edits as a comment block.
+///
+/// This is the one signal Cursor Tab has that plain retrieval does not: knowing what the user just
+/// changed usually says more about the next edit than anything else in the file.
+public enum RecentEditSnippetBuilder {
+    public static let header =
+        "The user's most recent edits, oldest first. They show what the user is doing right now."
+
+    public static func snippet(
+        from hunks: [RecentEditHunk],
+        commentPrefix: String = "//",
+        priority: Int,
+        maxLinesPerHunk: Int = 8,
+        maxHunks: Int = 3
+    ) -> RelevantCodeSnippet? {
+        let hunks = Array(hunks.suffix(maxHunks))
+        guard !hunks.isEmpty else { return nil }
+
+        var lines = ["\(commentPrefix) \(header)"]
+        for hunk in hunks {
+            lines.append("\(commentPrefix) --- \(hunk.filePath)")
+            lines.append(contentsOf: render(
+                hunk.removed,
+                marker: "-",
+                commentPrefix: commentPrefix,
+                maxLines: maxLinesPerHunk
+            ))
+            lines.append(contentsOf: render(
+                hunk.inserted,
+                marker: "+",
+                commentPrefix: commentPrefix,
+                maxLines: maxLinesPerHunk
+            ))
+        }
+
+        return .init(
+            content: lines.joined(separator: "\n"),
+            priority: priority,
+            filePath: hunks.last?.filePath ?? ""
+        )
+    }
+
+    private static func render(
+        _ lines: [String],
+        marker: String,
+        commentPrefix: String,
+        maxLines: Int
+    ) -> [String] {
+        guard !lines.isEmpty else { return [] }
+        var rendered = lines.prefix(maxLines).map { "\(commentPrefix) \(marker) \($0)" }
+        if lines.count > maxLines {
+            rendered.append("\(commentPrefix) \(marker) … \(lines.count - maxLines) more lines")
+        }
+        return rendered
+    }
+}
diff --git a/Tool/Sources/SuggestionProvider/StreamingSuggestionServiceProvider.swift b/Tool/Sources/SuggestionProvider/StreamingSuggestionServiceProvider.swift
new file mode 100644
index 00000000..5203b01e
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/StreamingSuggestionServiceProvider.swift
@@ -0,0 +1,57 @@
+import struct CopilotForXcodeKit.WorkspaceInfo
+import Foundation
+import SuggestionBasic
+
+/// A provider that can hand out partial suggestions while the model is still generating.
+///
+/// Every element of the stream replaces the previous one: the text is cumulative and the ids are
+/// stable for the whole round. The last element is the final result and equals what
+/// ``SuggestionServiceProvider/getSuggestions(_:workspaceInfo:)`` would have returned.
+public protocol StreamingSuggestionServiceProvider: SuggestionServiceProvider {
+    func streamSuggestions(
+        _ request: SuggestionRequest,
+        workspaceInfo: WorkspaceInfo
+    ) async -> AsyncThrowingStream<[CodeSuggestion], Error>
+}
+
+/// Helpers to build suggestion streams without repeating the task / continuation dance.
+public enum SuggestionStreams {
+    /// A stream that emits the result of one async operation and finishes.
+    public static func single(
+        _ operation: @escaping () async throws -> Element
+    ) -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    continuation.yield(try await operation())
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+    }
+
+    /// Forwards `upstream`, transforming every element. Dropping the consumer cancels `upstream`.
+    public static func forward(
+        _ upstream: Upstream,
+        mapError: @escaping (Error) -> Error = { $0 },
+        transform: @escaping (Upstream.Element) async throws -> Element
+    ) -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    for try await element in upstream {
+                        try Task.checkCancellation()
+                        continuation.yield(try await transform(element))
+                    }
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: mapError(error))
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+    }
+}
diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift
index adcebfe1..63a49725 100644
--- a/Tool/Sources/Toast/Toast.swift
+++ b/Tool/Sources/Toast/Toast.swift
@@ -99,7 +99,7 @@ public class ToastController: ObservableObject {
             // Find existing message with same content and type (and namespace)
             if let existingIndex = messages.firstIndex(where: {
                 $0.type == type &&
-                $0.content == Text(content) &&
+                $0.content == Text(LocalizedStringKey(content)) &&
                 $0.namespace == namespace
             }) {
                 let existingMessage = messages[existingIndex]
@@ -121,7 +121,7 @@ public class ToastController: ObservableObject {
                 id: id,
                 type: type,
                 namespace: namespace,
-                content: Text(content),
+                content: Text(LocalizedStringKey(content)),
                 buttons: buttons.map { b in
                     Message.MessageButton(label: b.label, action: { [weak self] in
                         b.action()
diff --git a/Tool/Sources/WorkspaceSuggestionService/SuggestionContextCollector.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionContextCollector.swift
new file mode 100644
index 00000000..7c22465e
--- /dev/null
+++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionContextCollector.swift
@@ -0,0 +1,169 @@
+import Foundation
+import SuggestionBasic
+import SuggestionProvider
+import Workspace
+
+/// Builds the reference snippets that go into a suggestion request.
+///
+/// The custom-model service already advertises `acceptsRelevantCodeSnippets`, but nothing ever
+/// filled that array, so the model only ever saw the file being edited. This collects two things
+/// that the products people compare us to both rely on:
+///
+/// - Neighboring tabs: the most similar windows of the other opened files, scored with Jaccard
+///   similarity the way GitHub Copilot does it. No index, no embeddings, no extra round trip.
+/// - Recent edits: what the user changed in the last few rounds, which is the signal Cursor Tab
+///   leans on to guess where the current edit is going.
+@WorkspaceActor
+final class SuggestionContextCollector {
+    static let shared = SuggestionContextCollector()
+
+    /// Reading and tokenizing every opened file on each keystroke would be wasteful, so a file is
+    /// only re-read when its size or modification date changes.
+    private struct CachedSource {
+        var modificationDate: Date
+        var size: Int
+        var source: IndexedSnippetSource
+    }
+
+    private var sourceCache = [URL: CachedSource]()
+    private var lastSeenLines = [URL: [String]]()
+    private var recentEdits = [RecentEditHunk]()
+
+    /// Files larger than this are generated code or data, not useful references.
+    private let maximumFileSize = 256 * 1024
+    private let maximumCandidateFiles = 12
+    private let maximumRecentEdits = 3
+    private let retrievalOptions = NeighboringSnippetRetriever.Options()
+
+    func collectSnippets(
+        for fileURL: URL,
+        lines: [String],
+        cursorPosition: CursorPosition,
+        workspace: Workspace
+    ) -> [RelevantCodeSnippet] {
+        recordEdit(for: fileURL, lines: lines, projectRootURL: workspace.projectRootURL)
+
+        let commentPrefix = CodeCommentStyle.prefix(forFileExtension: fileURL.pathExtension)
+        var snippets = [RelevantCodeSnippet]()
+
+        if let editSnippet = RecentEditSnippetBuilder.snippet(
+            from: recentEdits,
+            commentPrefix: commentPrefix,
+            // Recent edits outrank retrieved code: they are about the change in progress.
+            priority: retrievalOptions.maxSnippets + 1
+        ) {
+            snippets.append(editSnippet)
+        }
+
+        let query = NeighboringSnippetRetriever.queryTokens(
+            currentLines: lines,
+            cursorPosition: cursorPosition,
+            options: retrievalOptions
+        )
+        snippets.append(contentsOf: NeighboringSnippetRetriever.retrieve(
+            query: query,
+            sources: indexedSources(excluding: fileURL, workspace: workspace),
+            commentPrefix: commentPrefix,
+            options: retrievalOptions
+        ))
+
+        return snippets
+    }
+
+    private func recordEdit(for fileURL: URL, lines: [String], projectRootURL: URL) {
+        defer { lastSeenLines[fileURL] = lines }
+        guard let previous = lastSeenLines[fileURL] else { return }
+        guard let hunk = RecentEditDiff.hunk(
+            filePath: relativePath(of: fileURL, in: projectRootURL),
+            before: previous,
+            after: lines
+        ) else { return }
+
+        // The trailing edit is usually the same one growing character by character; replacing it
+        // keeps the history at "the last few distinct places the user touched".
+        if let last = recentEdits.last, last.startLine == hunk.startLine,
+           last.filePath == hunk.filePath
+        {
+            recentEdits[recentEdits.endIndex - 1] = hunk
+        } else {
+            recentEdits.append(hunk)
+        }
+        if recentEdits.count > maximumRecentEdits {
+            recentEdits.removeFirst(recentEdits.count - maximumRecentEdits)
+        }
+    }
+
+    private func indexedSources(
+        excluding fileURL: URL,
+        workspace: Workspace
+    ) -> [IndexedSnippetSource] {
+        // Filespaces only cover what this service process has seen, which is nothing right after
+        // a restart. The persisted list of opened files fills that gap; it has no recency order,
+        // so it goes last and only tops the list up.
+        let recentlyEdited = workspace.filespaces.values
+            .filter { $0.fileURL != fileURL && $0.isTextReadable }
+            .sorted { $0.lastUpdateTime > $1.lastUpdateTime }
+            .map(\.fileURL)
+        let previouslyOpened = workspace.openedFileRecoverableStorage.openedFiles
+            .filter { $0 != fileURL }
+
+        var candidates = [URL]()
+        var seen = Set()
+        for url in recentlyEdited + previouslyOpened where seen.insert(url).inserted {
+            candidates.append(url)
+            if candidates.count >= maximumCandidateFiles { break }
+        }
+
+        pruneCaches(keeping: seen.union([fileURL]))
+
+        return candidates.compactMap {
+            indexedSource(for: $0, projectRootURL: workspace.projectRootURL)
+        }
+    }
+
+    private func indexedSource(for fileURL: URL, projectRootURL: URL) -> IndexedSnippetSource? {
+        guard let attributes = try? FileManager.default
+            .attributesOfItem(atPath: fileURL.path),
+            let size = attributes[.size] as? Int,
+            size > 0, size <= maximumFileSize
+        else { return nil }
+        let modificationDate = attributes[.modificationDate] as? Date ?? .distantPast
+
+        if let cached = sourceCache[fileURL],
+           cached.size == size,
+           cached.modificationDate == modificationDate
+        {
+            return cached.source
+        }
+
+        guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return nil }
+        let lines = content.components(separatedBy: "\n").map { line -> String in
+            line.hasSuffix("\r") ? String(line.dropLast()) : line
+        }
+        let source = NeighboringSnippetRetriever.index(
+            filePath: relativePath(of: fileURL, in: projectRootURL),
+            lines: lines,
+            options: retrievalOptions
+        )
+        sourceCache[fileURL] = .init(
+            modificationDate: modificationDate,
+            size: size,
+            source: source
+        )
+        return source
+    }
+
+    /// Keeps both caches bounded by the files that can still be picked as candidates, so a
+    /// closed file's content and edit history are dropped without a separate eviction policy.
+    private func pruneCaches(keeping fileURLs: Set) {
+        sourceCache = sourceCache.filter { fileURLs.contains($0.key) }
+        lastSeenLines = lastSeenLines.filter { fileURLs.contains($0.key) }
+    }
+
+    private func relativePath(of fileURL: URL, in projectRootURL: URL) -> String {
+        let path = fileURL.path
+        let root = projectRootURL.path
+        guard root != "/", path.hasPrefix(root) else { return fileURL.lastPathComponent }
+        return String(path.dropFirst(root.count))
+    }
+}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+StreamingSuggestion.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+StreamingSuggestion.swift
new file mode 100644
index 00000000..c0b24039
--- /dev/null
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+StreamingSuggestion.swift
@@ -0,0 +1,99 @@
+import Foundation
+import SuggestionBasic
+import SuggestionProvider
+import Workspace
+import XPCShared
+
+public extension Workspace {
+    /// Streaming variant of ``generateSuggestions(forFileAt:editor:)``.
+    ///
+    /// The filespace is updated with every partial result and `onPartial` is called right after,
+    /// so the caller can refresh the widget while the model is still writing. The loop ends as
+    /// soon as the round is over from the user's point of view (accepted, rejected, dismissed or
+    /// invalidated by typing), which also cancels the request. Providers that can't stream go
+    /// through the blocking path unchanged.
+    @WorkspaceActor
+    @discardableResult
+    func generateSuggestionsStreaming(
+        forFileAt fileURL: URL,
+        editor: EditorContent,
+        onPartial: (CodeSuggestion) -> Void
+    ) async throws -> [CodeSuggestion] {
+        guard let streamingService = suggestionService as? StreamingSuggestionServiceProvider
+        else {
+            return try await generateSuggestions(forFileAt: fileURL, editor: editor)
+        }
+
+        refreshUpdateTime()
+
+        let filespace = try createFilespaceIfNeeded(fileURL: fileURL)
+
+        guard !(await filespace.isGitIgnored) else { return [] }
+
+        if !editor.uti.isEmpty {
+            filespace.codeMetadata.uti = editor.uti
+            filespace.codeMetadata.tabSize = editor.tabSize
+            filespace.codeMetadata.indentSize = editor.indentSize
+            filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation
+        }
+
+        filespace.codeMetadata.guessLineEnding(from: editor.lines.first)
+
+        let snapshot = FilespaceSuggestionSnapshot(
+            lines: editor.lines,
+            cursorPosition: editor.cursorPosition
+        )
+
+        filespace.suggestionSourceSnapshot = snapshot
+
+        let content = editor.lines.joined(separator: "")
+        let relevantCodeSnippets = SuggestionContextCollector.shared.collectSnippets(
+            for: fileURL,
+            lines: editor.lines,
+            cursorPosition: editor.cursorPosition,
+            workspace: self
+        )
+        let stream = await streamingService.streamSuggestions(
+            .init(
+                fileURL: fileURL,
+                relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""),
+                content: content,
+                originalContent: content,
+                lines: editor.lines,
+                cursorPosition: editor.cursorPosition,
+                cursorOffset: editor.cursorOffset,
+                tabSize: editor.tabSize,
+                indentSize: editor.indentSize,
+                usesTabsForIndentation: editor.usesTabsForIndentation,
+                relevantCodeSnippets: relevantCodeSnippets
+            ),
+            workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL)
+        )
+
+        var latest = [CodeSuggestion]()
+        var presentedID: String?
+        for try await partial in stream {
+            if Task.isCancelled { break }
+            guard Self.roundIsStillOpen(filespace, snapshot: snapshot, presentedID: presentedID)
+            else { break }
+            latest = partial
+            guard let first = partial.first else { continue }
+            filespace.setSuggestions(partial)
+            presentedID = first.id
+            onPartial(first)
+        }
+        return latest
+    }
+
+    /// False once accept / reject / dismiss / invalidation has reset the round.
+    @WorkspaceActor
+    private static func roundIsStillOpen(
+        _ filespace: Filespace,
+        snapshot: FilespaceSuggestionSnapshot,
+        presentedID: String?
+    ) -> Bool {
+        guard filespace.suggestionSourceSnapshot == snapshot else { return false }
+        guard let presentedID else { return true }
+        return filespace.presentingSuggestion?.id == presentedID
+    }
+}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
index 99abe305..a1c686a4 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
@@ -55,6 +55,12 @@ public extension Workspace {
 
         guard let suggestionService else { throw SuggestionFeatureDisabledError() }
         let content = editor.lines.joined(separator: "")
+        let relevantCodeSnippets = SuggestionContextCollector.shared.collectSnippets(
+            for: fileURL,
+            lines: editor.lines,
+            cursorPosition: editor.cursorPosition,
+            workspace: self
+        )
         let completions = try await suggestionService.getSuggestions(
             .init(
                 fileURL: fileURL,
@@ -67,7 +73,7 @@ public extension Workspace {
                 tabSize: editor.tabSize,
                 indentSize: editor.indentSize,
                 usesTabsForIndentation: editor.usesTabsForIndentation,
-                relevantCodeSnippets: []
+                relevantCodeSnippets: relevantCodeSnippets
             ),
             workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL)
         )
diff --git a/Tool/Sources/XPCShared/XPCPeerRequirement.swift b/Tool/Sources/XPCShared/XPCPeerRequirement.swift
new file mode 100644
index 00000000..4ec26874
--- /dev/null
+++ b/Tool/Sources/XPCShared/XPCPeerRequirement.swift
@@ -0,0 +1,29 @@
+import Foundation
+import ObjectiveCExceptionHandling
+
+public enum XPCPeerRequirement {
+    /// anchor apple generic and certificate leaf[subject.OU] = ""
+    public static func codeSigningRequirement(teamID: String) -> String {
+        #"anchor apple generic and certificate leaf[subject.OU] = "\#(teamID)""#
+    }
+
+    /// `TEAM_ID_PREFIX` is `$(TeamIdentifierPrefix)` (e.g. `94G4SCKS9Z.`).
+    public static func teamID(fromPrefix prefix: String?) -> String? {
+        guard let prefix else { return nil }
+        let teamID = prefix.hasSuffix(".") ? String(prefix.dropLast()) : prefix
+        guard !teamID.isEmpty,
+              teamID.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) })
+        else { return nil }
+        return teamID
+    }
+
+    /// Malformed requirement strings raise `NSException` rather than a Swift `Error`.
+    public static func setCodeSigningRequirement(
+        on connection: NSXPCConnection,
+        teamID: String
+    ) throws {
+        try ObjcExceptionHandler.catchException {
+            connection.setCodeSigningRequirement(codeSigningRequirement(teamID: teamID))
+        }
+    }
+}
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
new file mode 100644
index 00000000..927a03f9
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
@@ -0,0 +1,427 @@
+import AIModel
+import Foundation
+import Preferences
+import XCTest
+
+@testable import CustomSuggestionService
+
+final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
+    func test_responsesEndpoint_four_branches() {
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(baseURL: "")),
+            "https://api.openai.com/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/v1/chat/completions",
+                isFullURL: true
+            )),
+            "https://gw.example/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/custom/responses",
+                isFullURL: true
+            )),
+            "https://gw.example/custom/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/",
+                isFullURL: false
+            )),
+            "https://gw.example/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/v1/chat/completions/",
+                isFullURL: true
+            )),
+            "https://gw.example/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/v1/chat/completions?api-version=1",
+                isFullURL: true
+            )),
+            "https://gw.example/v1/responses?api-version=1"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: ChatModel(
+                id: "id",
+                name: "Test",
+                format: .openAI,
+                info: .init(
+                    baseURL: "https://api.openai.com",
+                    isFullURL: true,
+                    modelName: "gpt-5.5"
+                )
+            )),
+            "https://api.openai.com/v1/responses"
+        )
+    }
+
+    func test_chatModelAPIOptions_per_id_read_write_and_legacy_migration() {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        ChatModelAPIOptions.update(
+            .init(api: .responses, reasoningEffort: .high, reasoningTokenBudget: 2000),
+            for: "model-a",
+            defaults: defaults
+        )
+        let storedA = ChatModelAPIOptions.stored(for: "model-a", defaults: defaults)
+        XCTAssertEqual(storedA.api, .responses)
+        XCTAssertEqual(storedA.reasoningEffort, .high)
+        XCTAssertEqual(storedA.reasoningTokenBudget, 2000)
+        XCTAssertEqual(
+            ChatModelAPIOptions.stored(for: "model-b", defaults: defaults),
+            ChatModelAPIOptions()
+        )
+
+        let (legacyDefaults, legacySuite) = isolatedDefaults()
+        defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
+        legacyDefaults.set("responses", forKey: ChatModelAPIOptions.legacyOpenAIAPIKey)
+        legacyDefaults.set("minimal", forKey: ChatModelAPIOptions.legacyReasoningEffortKey)
+
+        let migrated = ChatModelAPIOptions.stored(for: "model-1", defaults: legacyDefaults)
+        XCTAssertEqual(migrated.api, .responses)
+        XCTAssertEqual(migrated.reasoningEffort, .minimal)
+        XCTAssertNil(legacyDefaults.value(forKey: ChatModelAPIOptions.legacyOpenAIAPIKey))
+        XCTAssertNil(legacyDefaults.value(forKey: ChatModelAPIOptions.legacyReasoningEffortKey))
+        XCTAssertEqual(
+            ChatModelAPIOptions.stored(for: "model-2", defaults: legacyDefaults).api,
+            .responses
+        )
+    }
+
+    func test_chat_request_body_legacy_and_reasoning_shapes() throws {
+        let messages = [OpenAIService.Message(role: .user, content: "hi")]
+        let legacy = OpenAIService.makeChatCompletionRequestBody(
+            modelName: "gpt-5.5",
+            messages: messages,
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: nil,
+            reasoningTokenBudget: 1500
+        )
+        let legacyJSON = try jsonObject(legacy)
+        XCTAssertEqual(legacyJSON["temperature"] as? Double, 0.2)
+        XCTAssertEqual(legacyJSON["stop"] as? [String], ["\n\n"])
+        XCTAssertEqual((legacyJSON["max_tokens"] as? NSNumber)?.intValue, 200)
+        XCTAssertNil(legacyJSON["max_completion_tokens"])
+        XCTAssertNil(legacyJSON["reasoning_effort"])
+
+        let reasoning = OpenAIService.makeChatCompletionRequestBody(
+            modelName: "gpt-5.5",
+            messages: messages,
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: .high,
+            reasoningTokenBudget: 1500
+        )
+        let reasoningJSON = try jsonObject(reasoning)
+        XCTAssertNil(reasoningJSON["temperature"])
+        XCTAssertNil(reasoningJSON["stop"])
+        XCTAssertNil(reasoningJSON["max_tokens"])
+        XCTAssertEqual((reasoningJSON["max_completion_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual(reasoningJSON["reasoning_effort"] as? String, "high")
+
+        let minimal = OpenAIService.makeChatCompletionRequestBody(
+            modelName: "gpt-5.5",
+            messages: messages,
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: .minimal,
+            reasoningTokenBudget: 1500
+        )
+        let minimalJSON = try jsonObject(minimal)
+        XCTAssertEqual((minimalJSON["max_completion_tokens"] as? NSNumber)?.intValue, 200)
+        XCTAssertEqual(minimalJSON["reasoning_effort"] as? String, "minimal")
+    }
+
+    func test_responses_request_body_store_false_and_temperature_when_effort_nil() throws {
+        let withoutEffort = OpenAIResponsesService.makeRequestBody(
+            modelName: "gpt-5.5",
+            input: [],
+            instructions: "complete",
+            maxToken: 200,
+            reasoningEffort: nil,
+            reasoningTokenBudget: 1500
+        )
+        let json = try jsonObject(withoutEffort)
+        XCTAssertEqual(json["store"] as? Bool, false)
+        XCTAssertEqual(json["temperature"] as? Double, 0.2)
+        XCTAssertEqual((json["max_output_tokens"] as? NSNumber)?.intValue, 200)
+        XCTAssertNil(json["reasoning"])
+
+        let withEffort = OpenAIResponsesService.makeRequestBody(
+            modelName: "gpt-5.5",
+            input: [],
+            instructions: "complete",
+            maxToken: 200,
+            reasoningEffort: .xhigh,
+            reasoningTokenBudget: 1500
+        )
+        let effortJSON = try jsonObject(withEffort)
+        XCTAssertEqual(effortJSON["store"] as? Bool, false)
+        XCTAssertNil(effortJSON["temperature"])
+        XCTAssertEqual((effortJSON["max_output_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual((effortJSON["reasoning"] as? [String: Any])?["effort"] as? String, "xhigh")
+    }
+
+    func test_anthropic_request_body_contains_output_config_effort() throws {
+        let body = AnthropicService.makeMessageRequestBody(
+            modelName: "claude-3-5-sonnet-latest",
+            messages: [.init(role: .user, content: "hi")],
+            systemPrompt: "complete",
+            maxToken: 200,
+            temperature: 0.2,
+            stopSequences: ["\n\n"],
+            reasoningEffort: .minimal
+        )
+        let json = try jsonObject(body)
+        XCTAssertEqual(
+            (json["output_config"] as? [String: Any])?["effort"] as? String,
+            "low"
+        )
+
+        let high = AnthropicService.makeMessageRequestBody(
+            modelName: "claude-3-5-sonnet-latest",
+            messages: [.init(role: .user, content: "hi")],
+            systemPrompt: nil,
+            maxToken: 200,
+            temperature: 0.2,
+            stopSequences: nil,
+            reasoningEffort: .xhigh
+        )
+        XCTAssertEqual(
+            (try jsonObject(high)["output_config"] as? [String: Any])?["effort"] as? String,
+            "high"
+        )
+    }
+
+    func test_client_stop_word_truncation() async throws {
+        let stream = AsyncThrowingStream { continuation in
+            continuation.yield("hello")
+            continuation.yield(" world")
+            continuation.yield("\n\n")
+            continuation.yield("more")
+            continuation.finish()
+        }
+        let limiter = StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        let result = try await collectCompletion(
+            from: stream,
+            stopWords: ["\n\n"],
+            limiter: limiter
+        )
+        XCTAssertEqual(result, "hello world")
+    }
+
+    func test_client_stop_word_truncation_split_across_tokens() async throws {
+        let stream = AsyncThrowingStream { continuation in
+            continuation.yield("hello")
+            continuation.yield("\n")
+            continuation.yield("\nmore")
+            continuation.yield("tail")
+            continuation.finish()
+        }
+        let limiter = StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        let result = try await collectCompletion(
+            from: stream,
+            stopWords: ["\n\n"],
+            limiter: limiter
+        )
+        XCTAssertEqual(result, "hello")
+    }
+
+    func test_azure_format_reasoning_request_body_contains_max_completion_tokens() throws {
+        let chat = ChatModel(
+            id: "azure-chat",
+            name: "Azure Chat",
+            format: .azureOpenAI,
+            info: .init(
+                apiKeyName: "azure",
+                baseURL: "https://example.openai.azure.com",
+                maxTokens: 8000,
+                modelName: "gpt-4o"
+            )
+        )
+        XCTAssertEqual(
+            chat.endpoint,
+            OpenAIService.azureEndpoint(
+                baseURL: chat.info.baseURL,
+                deployment: chat.info.modelName,
+                endpoint: .chatCompletion
+            )
+        )
+        XCTAssertEqual(
+            OpenAIService.azureEndpoint(
+                baseURL: "",
+                deployment: "gpt-4o",
+                endpoint: .chatCompletion
+            ),
+            ""
+        )
+        XCTAssertEqual(
+            try OpenAIService.resolveURL(
+                url: chat.endpoint,
+                authentication: .apiKeyHeader,
+                endpoint: .chatCompletion
+            ).absoluteString,
+            chat.endpoint
+        )
+        XCTAssertTrue(chat.endpoint.contains("api-version=2024-09-01-preview"))
+
+        XCTAssertThrowsError(
+            try OpenAIService.resolveURL(
+                url: "",
+                authentication: .apiKeyHeader,
+                endpoint: .chatCompletion
+            )
+        ) { error in
+            XCTAssertEqual(
+                error.localizedDescription,
+                OpenAIService.missingAzureEndpointMessage
+            )
+        }
+        XCTAssertThrowsError(
+            try OpenAIService.resolveURL(
+                url: "https://api.openai.com/v1/chat/completions",
+                authentication: .apiKeyHeader,
+                endpoint: .chatCompletion
+            )
+        ) { error in
+            XCTAssertEqual(
+                error.localizedDescription,
+                OpenAIService.missingAzureEndpointMessage
+            )
+        }
+
+        let completion = CompletionModel(
+            id: "azure-completion",
+            name: "Azure Completion",
+            format: .azureOpenAI,
+            info: .init(
+                baseURL: "https://example.openai.azure.com",
+                modelName: "gpt-35-turbo-instruct"
+            )
+        )
+        XCTAssertEqual(
+            completion.endpoint,
+            OpenAIService.azureEndpoint(
+                baseURL: completion.info.baseURL,
+                deployment: completion.info.modelName,
+                endpoint: .completion
+            )
+        )
+
+        let body = OpenAIService.makeChatCompletionRequestBody(
+            modelName: chat.info.modelName,
+            messages: [.init(role: .user, content: "hi")],
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: .high,
+            reasoningTokenBudget: 1500
+        )
+        let json = try jsonObject(body)
+        XCTAssertEqual((json["max_completion_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual(json["reasoning_effort"] as? String, "high")
+        XCTAssertNil(json["max_tokens"])
+        XCTAssertNil(json["temperature"])
+        XCTAssertNil(json["stop"])
+    }
+
+    func test_chatAPIOptions_current_defaults_invalid_raw_and_empty_key_filter() async {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        let model = ChatModel(
+            id: "model-defaults",
+            name: "Test",
+            format: .openAI,
+            info: .init(
+                modelName: "gpt-5.5",
+                customHeaderInfo: .init(headers: [
+                    .init(key: "", value: "skip-me"),
+                    .init(key: "X-Ok", value: "yes"),
+                ])
+            )
+        )
+
+        let options = await ChatAPIOptions.current(
+            for: model,
+            apiKey: "sk",
+            defaults: defaults
+        )
+        XCTAssertEqual(options.api, .responses)
+        XCTAssertNil(options.reasoningEffort)
+        XCTAssertEqual(options.reasoningTokenBudget, 1500)
+        XCTAssertEqual(options.extraHeaders.map(\.name), ["X-Ok"])
+        XCTAssertEqual(options.extraHeaders.first?.value, "yes")
+
+        defaults.set(
+            "not-json",
+            forKey: UserDefaultPreferenceKeys().customSuggestionChatModelAPIOptions.key
+        )
+        let invalidStored = await ChatAPIOptions.current(
+            for: model,
+            apiKey: "sk",
+            defaults: defaults
+        )
+        XCTAssertEqual(invalidStored.api, .responses)
+        XCTAssertNil(invalidStored.reasoningEffort)
+        XCTAssertEqual(invalidStored.reasoningTokenBudget, 1500)
+
+        let (legacyDefaults, legacySuite) = isolatedDefaults()
+        defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
+        legacyDefaults.set("not-an-api", forKey: ChatModelAPIOptions.legacyOpenAIAPIKey)
+        legacyDefaults.set("not-an-effort", forKey: ChatModelAPIOptions.legacyReasoningEffortKey)
+        let migrated = ChatModelAPIOptions.stored(for: "model-defaults", defaults: legacyDefaults)
+        XCTAssertEqual(migrated.api, .responses)
+        XCTAssertNil(migrated.reasoningEffort)
+        XCTAssertNil(OpenAIChatAPI(rawValue: "not-an-api"))
+        XCTAssertNil(ReasoningEffort(rawValue: "not-an-effort"))
+    }
+
+    func test_customBodyInfo_joinJSON_merge() throws {
+        let original = Data(#"{"model":"a","stream":true}"#.utf8)
+        let merged = CompletionJSON.mergeCustomBody(
+            original,
+            jsonBody: #"{"foo":1,"model":"b"}"#
+        )
+        let json = try XCTUnwrap(JSONSerialization.jsonObject(with: merged) as? [String: Any])
+        XCTAssertEqual((json["foo"] as? NSNumber)?.intValue, 1)
+        XCTAssertEqual(json["model"] as? String, "b")
+        XCTAssertEqual(json["stream"] as? Bool, true)
+        XCTAssertEqual(
+            CompletionJSON.mergeCustomBody(original, jsonBody: "  "),
+            original
+        )
+    }
+}
+
+private func jsonObject(_ value: T) throws -> [String: Any] {
+    let data = try JSONEncoder().encode(value)
+    return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
+}
+
+private func isolatedDefaults() -> (UserDefaults, String) {
+    let name = "wp3.semantics.\(UUID().uuidString)"
+    let defaults = UserDefaults(suiteName: name)!
+    defaults.removePersistentDomain(forName: name)
+    return (defaults, name)
+}
+
+private func chatModel(baseURL: String, isFullURL: Bool = false) -> ChatModel {
+    ChatModel(
+        id: "id",
+        name: "Test",
+        format: .openAICompatible,
+        info: .init(baseURL: baseURL, isFullURL: isFullURL, modelName: "gpt-5.5")
+    )
+}
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
new file mode 100644
index 00000000..e3ecd114
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
@@ -0,0 +1,449 @@
+import AIModel
+import CopilotForXcodeKit
+import Foundation
+import Preferences
+import XCTest
+
+@testable import CustomSuggestionService
+
+class ChatAPIStreamParsingTests: XCTestCase {
+    // MARK: Responses API
+
+    func test_responses_delta_event() throws {
+        let line = #"data: {"type":"response.output_text.delta","item_id":"msg_1","delta":"Hel"}"#
+        let result = try OpenAIResponsesService.parseStreamLine(line)
+        XCTAssertEqual(result.chunk?.text, "Hel")
+        XCTAssertFalse(result.done)
+    }
+
+    func test_responses_ignores_lines_without_text() throws {
+        for line in [
+            "",
+            "event: response.output_text.delta",
+            ": keep-alive",
+            #"data: {"type":"response.created","response":{"id":"resp_1","object":"response"}}"#,
+            #"data: {"type":"response.output_text.done","text":"Hello"}"#,
+        ] {
+            let result = try OpenAIResponsesService.parseStreamLine(line)
+            XCTAssertNil(result.chunk?.text, line)
+            XCTAssertFalse(result.done, line)
+        }
+    }
+
+    func test_responses_completed_event_finishes_the_stream() throws {
+        let line = #"data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed"}}"#
+        let result = try OpenAIResponsesService.parseStreamLine(line)
+        XCTAssertNil(result.chunk?.text)
+        XCTAssertTrue(result.done)
+        XCTAssertTrue(try OpenAIResponsesService.parseStreamLine("data: [DONE]").done)
+    }
+
+    func test_responses_incomplete_finishes_stream() throws {
+        let truncated = #"data: {"type":"response.incomplete","response":{"id":"resp_truncated","object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}"#
+        let result = try OpenAIResponsesService.parseStreamLine(truncated)
+        XCTAssertNil(result.chunk?.text)
+        XCTAssertTrue(result.done)
+
+        let other = #"data: {"type":"response.incomplete","response":{"id":"resp_other","object":"response","status":"incomplete","incomplete_details":{"reason":"content_filter"}}}"#
+        XCTAssertTrue(try OpenAIResponsesService.parseStreamLine(other).done)
+    }
+
+    func test_responses_error_events_throw() {
+        XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
+            #"data: {"type":"error","code":"rate_limit","message":"slow down"}"#
+        )) { XCTAssertEqual($0.localizedDescription, "slow down") }
+        XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
+            #"data: {"type":"response.failed","response":{"object":"response","status":"failed","error":{"message":"boom"}}}"#
+        )) { XCTAssertEqual($0.localizedDescription, "boom") }
+        XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
+            #"data: {"error":{"message":"boom"}}"#
+        )) { XCTAssertEqual($0.localizedDescription, "boom") }
+    }
+
+    func test_responses_non_streaming_body() throws {
+        let body = """
+        {"id":"resp_abc123","object":"response","model":"gpt-5.5","status":"completed",\
+        "output":[{"type":"reasoning","summary":[]},\
+        {"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hel"},{"type":"output_text","text":"lo!"}]}],\
+        "usage":{"input_tokens":12,"output_tokens":45,"total_tokens":57}}
+        """
+        let result = try OpenAIResponsesService.parseStreamLine(body)
+        XCTAssertEqual(result.chunk?.text, "Hello!")
+        XCTAssertTrue(result.done)
+    }
+
+    // MARK: Chat Completions API
+
+    func test_chatCompletions_delta_and_done_marker() throws {
+        let delta = #"data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#
+        let result = try OpenAIService.parseChatCompletionsStreamLine(delta)
+        XCTAssertEqual(result.chunk?.choices?.first?.delta?.content, "Hi")
+        XCTAssertFalse(result.done)
+
+        let finished = #"data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#
+        XCTAssertTrue(try OpenAIService.parseChatCompletionsStreamLine(finished).done)
+        XCTAssertTrue(try OpenAIService.parseChatCompletionsStreamLine("data: [DONE]").done)
+        XCTAssertFalse(try OpenAIService.parseChatCompletionsStreamLine("").done)
+    }
+
+    func test_chatCompletions_non_streaming_body() throws {
+        let body = #"{"id":"chatcmpl-abc123","object":"chat.completion","model":"gpt-5.5","choices":[{"index":0,"message":{"role":"assistant","content":"Hello! How can I help you today?"},"finish_reason":"stop"}]}"#
+        let result = try OpenAIService.parseChatCompletionsStreamLine(body)
+        XCTAssertEqual(result.chunk?.choices?.first?.delta?.content, "Hello! How can I help you today?")
+        XCTAssertTrue(result.done)
+    }
+
+    // MARK: Helpers
+
+    func test_sse_payload() {
+        XCTAssertEqual(SSELine.payload(of: "data: {}"), "{}")
+        XCTAssertEqual(SSELine.payload(of: "data:{}"), "{}")
+        XCTAssertEqual(SSELine.payload(of: "{\"a\":1}"), "{\"a\":1}")
+        XCTAssertNil(SSELine.payload(of: "event: x"))
+        XCTAssertNil(SSELine.payload(of: "id: 1"))
+        XCTAssertNil(SSELine.payload(of: "   "))
+    }
+
+    func test_header_placeholders() async {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        let model = ChatModel(
+            id: "header-model",
+            name: "Headers",
+            format: .openAI,
+            info: .init(
+                modelName: "gpt-5.5",
+                customHeaderInfo: .init(headers: [
+                    .init(key: "X-Auth", value: "Bearer {{api_key}} for {{model_name}}"),
+                    .init(key: "Bad Name", value: "nope"),
+                    .init(key: "X-NL", value: "a\nb"),
+                ])
+            )
+        )
+        let options = await ChatAPIOptions.current(
+            for: model,
+            apiKey: "sk-1",
+            defaults: defaults
+        )
+        XCTAssertEqual(options.extraHeaders.map(\.name), ["X-Auth"])
+        XCTAssertEqual(options.extraHeaders.first?.value, "Bearer sk-1 for gpt-5.5")
+    }
+
+    func test_parseCompletionsStreamLine_delta_done_and_error() throws {
+        let delta = #"data: {"id":"cmpl-1","object":"text_completion","choices":[{"text":"foo","index":0,"finish_reason":null}]}"#
+        let result = try OpenAIService.parseCompletionsStreamLine(delta)
+        XCTAssertEqual(result.chunk?.choices?.first?.text, "foo")
+        XCTAssertFalse(result.done)
+
+        XCTAssertTrue(try OpenAIService.parseCompletionsStreamLine("data: [DONE]").done)
+        XCTAssertFalse(try OpenAIService.parseCompletionsStreamLine("").done)
+        XCTAssertFalse(try OpenAIService.parseCompletionsStreamLine(": keepalive").done)
+
+        let finished = #"data: {"choices":[{"text":"","index":0,"finish_reason":"stop"}]}"#
+        XCTAssertTrue(try OpenAIService.parseCompletionsStreamLine(finished).done)
+
+        XCTAssertThrowsError(try OpenAIService.parseCompletionsStreamLine(
+            #"data: {"error":{"message":"boom"}}"#
+        )) { error in
+            guard case OpenAIService.Error.apiError(let message) = error else {
+                return XCTFail("expected apiError, got \(error)")
+            }
+            XCTAssertEqual(message, "boom")
+        }
+    }
+
+    func test_chatCompletions_error_line_throws_apiError() {
+        XCTAssertThrowsError(try OpenAIService.parseChatCompletionsStreamLine(
+            #"data: {"error":{"message":"boom"}}"#
+        )) { error in
+            XCTAssertEqual(error.localizedDescription, "boom")
+            guard case OpenAIService.Error.apiError(let message) = error else {
+                return XCTFail("expected apiError, got \(error)")
+            }
+            XCTAssertEqual(message, "boom")
+        }
+    }
+
+    func test_chatCompletions_pretty_printed_json_body_yields_message() async throws {
+        let body = """
+        {
+          "id": "chatcmpl-abc123",
+          "object": "chat.completion",
+          "model": "gpt-5.5",
+          "choices": [
+            {
+              "index": 0,
+              "message": {
+                "role": "assistant",
+                "content": "Hello! How can I help you today?"
+              },
+              "finish_reason": "stop"
+            }
+          ]
+        }
+        """
+        let parsed = try OpenAIService.parseChatCompletionsBody(
+            body,
+            url: URL(string: "https://api.example.invalid/v1/chat/completions")!
+        )
+        XCTAssertEqual(
+            parsed.choices?.first?.delta?.content,
+            "Hello! How can I help you today?"
+        )
+
+        let host = "wp2-openai-\(UUID().uuidString).invalid"
+        CompletionStubURLProtocol.host = host
+        CompletionStubURLProtocol.handler = { _ in
+            (200, ["Content-Type": "application/json"], Data(body.utf8))
+        }
+        URLProtocol.registerClass(CompletionStubURLProtocol.self)
+        defer { CompletionStubURLProtocol.reset() }
+
+        let service = try OpenAIService(
+            url: "http://\(host)/v1/chat/completions",
+            endpoint: .chatCompletion,
+            modelName: "gpt-5.5",
+            contextWindow: 4096,
+            maxToken: 64,
+            apiKey: "sk-test"
+        )
+        let didFinish = expectation(description: "openai json body")
+        let task = Task {
+            do {
+                let stream = try await service.getCompletion(ErrorVisibilityPrompt())
+                var text = ""
+                for try await part in stream {
+                    text += part
+                }
+                XCTAssertEqual(text, "Hello! How can I help you today?")
+            } catch {
+                XCTFail("unexpected error: \(error)")
+            }
+            didFinish.fulfill()
+        }
+        await fulfillment(of: [didFinish], timeout: 2)
+        task.cancel()
+    }
+
+    func test_azure_reasoning_request_sends_api_key_header_and_max_completion_tokens() async throws {
+        let host = "wp6-azure-\(UUID().uuidString).invalid"
+        final class Capture: @unchecked Sendable {
+            var request: URLRequest?
+        }
+        let capture = Capture()
+        CompletionStubURLProtocol.host = host
+        CompletionStubURLProtocol.handler = { request in
+            capture.request = request
+            let sse = #"data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}"#
+                + "\n\ndata: [DONE]\n"
+            return (200, ["Content-Type": "text/event-stream"], Data(sse.utf8))
+        }
+        URLProtocol.registerClass(CompletionStubURLProtocol.self)
+        defer { CompletionStubURLProtocol.reset() }
+
+        let model = ChatModel(
+            id: "azure",
+            name: "Azure",
+            format: .azureOpenAI,
+            info: .init(
+                apiKeyName: "azure",
+                baseURL: "http://\(host)",
+                modelName: "gpt-4o"
+            )
+        )
+        // Same initializer as CodeCompletionService `.azureOpenAI` chat.
+        let service = try OpenAIService(
+            url: model.endpoint,
+            endpoint: .chatCompletion,
+            modelName: model.info.modelName,
+            contextWindow: 8000,
+            maxToken: 200,
+            apiKey: "azure-key",
+            authentication: .apiKeyHeader,
+            reasoningEffort: .high,
+            reasoningTokenBudget: 1500
+        )
+        let didFinish = expectation(description: "azure stream")
+        let task = Task {
+            do {
+                let stream = try await service.getCompletion(ErrorVisibilityPrompt())
+                var text = ""
+                for try await part in stream {
+                    text += part
+                }
+                XCTAssertEqual(text, "ok")
+            } catch {
+                XCTFail("unexpected error: \(error)")
+            }
+            didFinish.fulfill()
+        }
+        await fulfillment(of: [didFinish], timeout: 2)
+        task.cancel()
+
+        let request = try XCTUnwrap(capture.request)
+        XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "azure-key")
+        XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
+        let requestURL = try XCTUnwrap(request.url)
+        XCTAssertTrue(
+            requestURL.path.contains("/openai/deployments/"),
+            requestURL.path
+        )
+        XCTAssertTrue(
+            requestURL.path.hasSuffix("/chat/completions"),
+            requestURL.path
+        )
+        XCTAssertEqual(
+            URLComponents(url: requestURL, resolvingAgainstBaseURL: false)?
+                .queryItems?.first(where: { $0.name == "api-version" })?.value,
+            "2024-09-01-preview"
+        )
+        let body = try XCTUnwrap(httpBody(of: request))
+        let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
+        XCTAssertEqual((json["max_completion_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual(json["reasoning_effort"] as? String, "high")
+        XCTAssertNil(json["max_tokens"])
+    }
+
+    func test_tabby_http_500_throws_immediately() async {
+        let host = "wp2-tabby-\(UUID().uuidString).invalid"
+        CompletionStubURLProtocol.host = host
+        CompletionStubURLProtocol.handler = { _ in
+            (500, ["Content-Type": "application/json"], Data("{\"error\":\"nope\"}".utf8))
+        }
+        URLProtocol.registerClass(CompletionStubURLProtocol.self)
+        defer { CompletionStubURLProtocol.reset() }
+
+        let service = TabbyService(
+            url: "http://\(host)/v1/completions",
+            authorizationMode: .none
+        )
+        let didThrow = expectation(description: "tabby throws")
+        let task = Task {
+            do {
+                let stream = try await service.getCompletion(ErrorVisibilityPrompt())
+                for try await _ in stream {
+                    XCTFail("should not yield")
+                }
+                XCTFail("should throw")
+            } catch {
+                XCTAssertTrue(
+                    error.localizedDescription.contains("HTTP 500"),
+                    error.localizedDescription
+                )
+                didThrow.fulfill()
+            }
+        }
+        await fulfillment(of: [didThrow], timeout: 2)
+        task.cancel()
+    }
+
+    func test_asyncThrowingStream_wrapper_surfaces_task_error() async {
+        struct Boom: Error {}
+        let stream = AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    throw Boom()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+        do {
+            for try await _ in stream {
+                XCTFail("should not yield")
+            }
+            XCTFail("should throw")
+        } catch is Boom {
+            // Gemini's URLSession is owned by GoogleGenerativeAI and was not intercepted.
+        } catch {
+            XCTFail("unexpected \(error)")
+        }
+    }
+}
+
+private func isolatedDefaults() -> (UserDefaults, String) {
+    let name = "wp3.tests.\(UUID().uuidString)"
+    let defaults = UserDefaults(suiteName: name)!
+    defaults.removePersistentDomain(forName: name)
+    return (defaults, name)
+}
+
+private struct ErrorVisibilityPrompt: PromptStrategy {
+    var systemPrompt: String { "complete" }
+    var prefix: [String] { ["let x = "] }
+    var suffix: [String] { [] }
+    var relevantCodeSnippets: [RelevantCodeSnippet] { [] }
+    var stopWords: [String] { [] }
+    var language: CodeLanguage? { nil }
+
+    func createPrompt(
+        truncatedPrefix: [String],
+        truncatedSuffix: [String],
+        includedSnippets: [RelevantCodeSnippet]
+    ) -> [PromptMessage] {
+        [.init(role: .user, content: truncatedPrefix.joined())]
+    }
+}
+
+private func httpBody(of request: URLRequest) -> Data? {
+    if let body = request.httpBody { return body }
+    guard let stream = request.httpBodyStream else { return nil }
+    stream.open()
+    defer { stream.close() }
+    var data = Data()
+    let buffer = UnsafeMutablePointer.allocate(capacity: 4096)
+    defer { buffer.deallocate() }
+    while stream.hasBytesAvailable {
+        let count = stream.read(buffer, maxLength: 4096)
+        if count <= 0 { break }
+        data.append(buffer, count: count)
+    }
+    return data
+}
+
+private final class CompletionStubURLProtocol: URLProtocol {
+    static var host: String?
+    static var handler: ((URLRequest) throws -> (Int, [String: String], Data))?
+
+    static func reset() {
+        URLProtocol.unregisterClass(CompletionStubURLProtocol.self)
+        host = nil
+        handler = nil
+    }
+
+    override class func canInit(with request: URLRequest) -> Bool {
+        request.url?.host == host
+    }
+
+    override class func canInit(with task: URLSessionTask) -> Bool {
+        guard let request = task.currentRequest else { return false }
+        return canInit(with: request)
+    }
+
+    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+    override func startLoading() {
+        guard let handler = Self.handler, let url = request.url else {
+            client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+            return
+        }
+        do {
+            let (status, headers, data) = try handler(request)
+            let response = HTTPURLResponse(
+                url: url,
+                statusCode: status,
+                httpVersion: "HTTP/1.1",
+                headerFields: headers
+            )!
+            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+            client?.urlProtocol(self, didLoad: data)
+            client?.urlProtocolDidFinishLoading(self)
+        } catch {
+            client?.urlProtocol(self, didFailWithError: error)
+        }
+    }
+
+    override func stopLoading() {}
+}
diff --git a/Tool/Tests/CustomSuggestionServiceTests/CodeSplitAtCursorTests.swift b/Tool/Tests/CustomSuggestionServiceTests/CodeSplitAtCursorTests.swift
new file mode 100644
index 00000000..e62edb2b
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/CodeSplitAtCursorTests.swift
@@ -0,0 +1,100 @@
+import Foundation
+import AIModel
+import XCTest
+
+@testable import CustomSuggestionService
+
+class CodeSplitAtCursorTests: XCTestCase {
+    func test_split_at_the_end_of_a_file() {
+        let code = """
+        func mergeSort(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[.. SuggestionRequest {
+    SuggestionRequest(
+        fileURL: URL(fileURLWithPath: "/tmp/a.swift"),
+        relativePath: "a.swift",
+        language: CodeLanguage(rawValue: "swift") ?? .plaintext,
+        content: "let x = ",
+        originalContent: "let x = ",
+        cursorPosition: .init(line: 0, character: 8),
+        tabSize: 4,
+        indentSize: 4,
+        usesTabsForIndentation: false,
+        relevantCodeSnippets: []
+    )
+}
diff --git a/Tool/Tests/CustomSuggestionServiceTests/DefaultRawSuggestionPostProcessingStrategyTests.swift b/Tool/Tests/CustomSuggestionServiceTests/DefaultRawSuggestionPostProcessingStrategyTests.swift
new file mode 100644
index 00000000..605c38c2
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/DefaultRawSuggestionPostProcessingStrategyTests.swift
@@ -0,0 +1,225 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+class DefaultRawSuggestionPostProcessingStrategyTests: XCTestCase {
+    func test_whenSuggestionHasCodeTagAtTheFirstLine_shouldExtractCodeInside() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestion
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenSuggestionHasCodeTagAtTheFirstLine_closingTagInOtherLines_shouldExtractCodeInside(
+    ) {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestion
+            yes
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\nyes")
+    }
+
+    func test_whenSuggestionHasCodeTag_butNoClosingTag_shouldExtractCodeAfterTheTag() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestion
+            yes
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\nyes")
+    }
+
+    func test_whenMultipleOpeningTagFound_shouldTreatTheNextOneAsClosing() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestionhello
+            """
+        )
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenMarkdownCodeBlockFound_shouldExtractCodeInside() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            ```language
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\n")
+    }
+
+    func test_whenOnlyLinebreaksOrSpacesBeforeMarkdownCodeBlock_shouldExtractCodeInside() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+
+
+                 ```
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\n")
+
+        let result2 = strategy.extractSuggestion(
+            from: """
+                    ```
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result2, "suggestion\n")
+
+        let result3 = strategy.extractSuggestion(
+            from: """
+
+
+            ```
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result3, "suggestion\n")
+    }
+
+    func test_whenMarkdownCodeBlockAndCodeTagFound_firstlyExtractCodeTag_thenCodeTag() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            ```language
+            suggestion
+            suggestion
+            ```
+            """
+        )
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenMarkdownCodeBlockAndCodeTagFound_butNoClosingTag_firstlyExtractCodeTag_thenCodeTag(
+    ) {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            ```language
+            suggestion
+            suggestion
+            ```
+            """
+        )
+        XCTAssertEqual(result, "suggestion\nsuggestion\n")
+    }
+
+    func test_whenSuggestionHasTheSamePrefix_removeThePrefix() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: "suggestion"
+        )
+
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenSuggestionLooksLikeAMessage_parseItCorrectly() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            Here is the suggestion:
+            ```language
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\n")
+    }
+
+    func test_whenSuggestionHasTheSamePrefix_inTags_removeThePrefix() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        var suggestion = "prefix suggestion"
+        strategy.removePrefix(from: &suggestion, infillPrefix: "prefix")
+
+        XCTAssertEqual(suggestion, " suggestion")
+    }
+
+    func test_whenSuggestionHasTheSameSuffix_removeTheSuffix() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        var suggestion = "suggestion\na\nb"
+        strategy.removeSuffix(from: &suggestion, suffix: [
+            "a\n",
+            "b\n",
+        ])
+
+        XCTAssertEqual(suggestion, "suggestion\n")
+
+        var suggestion2 = "suggestion\na\nb"
+        strategy.removeSuffix(from: &suggestion2, suffix: [])
+
+        XCTAssertEqual(suggestion2, "suggestion\na\nb")
+
+        var suggestion3 = "suggestion\na\nb"
+        strategy.removeSuffix(from: &suggestion3, suffix: ["b\n"])
+
+        XCTAssertEqual(suggestion3, "suggestion\na\n")
+    }
+
+    func test_case_1() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.postProcess(
+            rawSuggestion: """
+            ```language
+            prefix suggestion
+            a
+            b
+            c
+            ```
+            """,
+            infillPrefix: "prefix",
+            suffix: ["a\n", "b\n", "c\n"]
+        )
+        XCTAssertEqual(result, "prefix suggestion")
+    }
+}
+
diff --git a/Tool/Tests/CustomSuggestionServiceTests/DefaultRequestStrategyTests.swift b/Tool/Tests/CustomSuggestionServiceTests/DefaultRequestStrategyTests.swift
new file mode 100644
index 00000000..58d8ce39
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/DefaultRequestStrategyTests.swift
@@ -0,0 +1,126 @@
+import Foundation
+import AIModel
+import XCTest
+
+@testable import CustomSuggestionService
+
+class DefaultRequestStrategyTests: XCTestCase {
+    func test_source_prompt_creation_empty_suffix() {
+        let prefix = """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """
+
+        guard let (summary, infillBlock) = DefaultRequestStrategy.Prompt.createCodeSummary(
+            truncatedPrefix: prefix.breakLines(),
+            truncatedSuffix: [], 
+            suggestionPrefix: "let cat:"
+        ) else {
+            XCTFail()
+            return
+        }
+
+        XCTAssertEqual(summary, """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        
+        """)
+        XCTAssertEqual(infillBlock, """
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """, "At most 10 lines")
+    }
+
+    func test_source_prompt_creation_has_suffix() {
+        let prefix = """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """
+        
+        let suffix = """
+        
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        """
+
+        guard let (summary, infillBlock) = DefaultRequestStrategy.Prompt.createCodeSummary(
+            truncatedPrefix: prefix.breakLines(),
+            truncatedSuffix: suffix.breakLines(),
+            suggestionPrefix: "let cat:"
+        ) else {
+            XCTFail()
+            return
+        }
+
+        XCTAssertEqual(summary, """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        """)
+        XCTAssertEqual(infillBlock, """
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """, "At most 10 lines")
+    }
+}
+
diff --git a/Tool/Tests/CustomSuggestionServiceTests/DuplicatedIndentationPostProcessingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/DuplicatedIndentationPostProcessingTests.swift
new file mode 100644
index 00000000..e4ba3dca
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/DuplicatedIndentationPostProcessingTests.swift
@@ -0,0 +1,43 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+final class DuplicatedIndentationPostProcessingTests: XCTestCase {
+    let strategy = DefaultRawSuggestionPostProcessingStrategy(
+        codeWrappingTags: ("", "")
+    )
+
+    func test_dropsLeadingWhitespaceWhenThePrefixAlreadyEndsWithWhitespace() {
+        let result = strategy.postProcess(
+            rawSuggestion: "        ))\nlet shareItem = 1",
+            infillPrefix: "/// ",
+            suffix: []
+        )
+        XCTAssertEqual(result, "/// ))\nlet shareItem = 1")
+    }
+
+    func test_dropsExtraIndentationOnAnAutoIndentedEmptyLine() {
+        let result = strategy.postProcess(
+            rawSuggestion: "    let a = 1",
+            infillPrefix: "        ",
+            suffix: []
+        )
+        XCTAssertEqual(result, "        let a = 1")
+    }
+
+    func test_keepsLeadingWhitespaceWhenThePrefixIsEmpty() {
+        let result = strategy.postProcess(rawSuggestion: "    let a = 1", infillPrefix: "", suffix: [])
+        XCTAssertEqual(result, "    let a = 1")
+    }
+
+    func test_keepsLeadingWhitespaceWhenThePrefixEndsWithNonWhitespace() {
+        let result = strategy.postProcess(rawSuggestion: " = 1", infillPrefix: "let a", suffix: [])
+        XCTAssertEqual(result, "let a = 1")
+    }
+
+    func test_dropsTabsToo() {
+        let result = strategy.postProcess(rawSuggestion: "\t\tfoo()", infillPrefix: "\t", suffix: [])
+        XCTAssertEqual(result, "\tfoo()")
+    }
+}
diff --git a/Tool/Tests/CustomSuggestionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift b/Tool/Tests/CustomSuggestionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift
new file mode 100644
index 00000000..3dd725c4
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift
@@ -0,0 +1,106 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+class OpeningTagBasedStreamStopStrategyTests: XCTestCase {
+    func test_no_opening_tag_found_and_not_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 1, strategy: strategy)
+        let content = """
+        Hello World
+        My Friend
+        """
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.result, content)
+    }
+    
+    func test_no_opening_tag_found_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 1, strategy: strategy)
+        let content = """
+        Hello World
+        My Friend
+        How Are You
+        I Am Fine
+        Thank You
+        """
+        
+        let expected = """
+        Hello World
+        My Friend
+        How Are You
+        I Am Fine
+        
+        """
+        
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish(expected) {
+                XCTAssertEqual(limiter.result, expected)
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\n\(limiter.result)")
+    }
+    
+    func test_opening_tag_found_not_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: strategy)
+        let content = """
+        Hello World
+        
+        How Are You
+        """
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.result, content)
+    }
+    
+    func test_opening_tag_found_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: strategy)
+        let content = """
+        Hello World
+        
+        How Are You
+        I Am Fine
+        Thank You
+        """
+        
+        let expected = """
+        Hello World
+        
+        How Are You
+        I Am Fine
+        
+        """
+        
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish(expected) {
+                XCTAssertEqual(limiter.result, expected)
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\n\(limiter.result)")
+    }
+}
+
diff --git a/Tool/Tests/CustomSuggestionServiceTests/StreamLineLimiterTests.swift b/Tool/Tests/CustomSuggestionServiceTests/StreamLineLimiterTests.swift
new file mode 100644
index 00000000..5549adc8
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/StreamLineLimiterTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+class StreamLineLimiterTests: XCTestCase {
+    func test_pushing_characters_without_hitting_limit() {
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: DefaultStreamStopStrategy())
+        let content = "hello world\n"
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.result, content)
+    }
+
+    func test_pushing_characters_hitting_limit() {
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: DefaultStreamStopStrategy())
+        let content = "hello world\nhello world\nhello world"
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish("hello world\nhello world\n") {
+                XCTAssertEqual(limiter.result, "hello world\nhello world\n")
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\(limiter.result)")
+    }
+
+    func test_pushing_characters_with_early_exit_strategy() {
+        struct Strategy: StreamStopStrategy {
+            func shouldStop(
+                existedLines: [String],
+                currentLine: String,
+                proposedLineLimit: Int
+            ) -> StreamStopStrategyResult {
+                let hasPrefixP = currentLine.hasPrefix("p")
+                let hasNewLine = existedLines.first?.hasSuffix("\n") ?? false
+                if hasPrefixP && hasNewLine {
+                    return .stop(appendingNewContent: false)
+                }
+                return .continue
+            }
+        }
+
+        let limiter = StreamLineLimiter(lineLimit: 10, strategy: Strategy())
+        let content = "hello world\npikachu\n"
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish("hello world\n") {
+                XCTAssertEqual(limiter.result, "hello world\n")
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\(limiter.result)")
+    }
+
+    func test_receiving_multiple_line_ending_as_a_single_token() {
+        let limiter = StreamLineLimiter(lineLimit: 4, strategy: DefaultStreamStopStrategy())
+        let content = "hello world"
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.push("\n\n\n"), .continue)
+        XCTAssertEqual(limiter.push("\n"), .finish("hello world\n\n\n\n"))
+    }
+}
+
diff --git a/Tool/Tests/CustomSuggestionServiceTests/StreamingCompletionTests.swift b/Tool/Tests/CustomSuggestionServiceTests/StreamingCompletionTests.swift
new file mode 100644
index 00000000..bb47f948
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/StreamingCompletionTests.swift
@@ -0,0 +1,148 @@
+import CopilotForXcodeKit
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+final class StreamingCompletionTests: XCTestCase {
+    private func tokens(_ parts: [String]) -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            for part in parts { continuation.yield(part) }
+            continuation.finish()
+        }
+    }
+
+    func test_displayablePrefix_holdsBackWhatCouldStillBecomeAStopWord() {
+        let stopWords = ["", "\n\n"]
+        XCTAssertEqual(
+            CompletionStopWords.displayablePrefix(of: "let a = 1", "junk"]),
+            stopWords: ["", "\n\n"],
+            limiter: limiter
+        ) { partials.append($0) }
+
+        XCTAssertEqual(result, "let a = 1\n")
+        XCTAssertEqual(partials, ["let ", "let a", "let a = 1", "let a = 1\n"])
+    }
+
+    func test_streamCompletion_finalTextMatchesCollectCompletion() async throws {
+        let parts = ["fo", "o\n", "bar\n", "\n", "dropped"]
+        let streamed = try await feedCompletion(
+            from: tokens(parts),
+            stopWords: ["\n\n"],
+            limiter: StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        ) { _ in }
+        let collected = try await collectCompletion(
+            from: tokens(parts),
+            stopWords: ["\n\n"],
+            limiter: StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        )
+        XCTAssertEqual(streamed, "foo\nbar")
+        XCTAssertEqual(streamed, collected)
+    }
+
+    func test_streamCompletion_stopsWhereTheLimiterStops() async throws {
+        let limiter = StreamLineLimiter(lineLimit: 1, strategy: DefaultStreamStopStrategy())
+        var partials = [String]()
+        let result = try await feedCompletion(
+            from: tokens(["a\n", "b\n", "c"]),
+            stopWords: [],
+            limiter: limiter
+        ) { partials.append($0) }
+
+        XCTAssertEqual(result, "a\n")
+        XCTAssertEqual(partials, [])
+    }
+
+    func test_streamingEntryPoint_yieldsPartialsThenTheFinalText() async throws {
+        let service = ErasedCompletionService(FakeService(parts: ["x", "y", ""]))
+        var elements = [String]()
+        for try await element in service.streamCompletion(
+            FakePrompt(stopWords: [""]),
+            streamStopStrategy: NeverStreamStopStrategy()
+        ) {
+            elements.append(element)
+        }
+        XCTAssertEqual(elements, ["x", "xy", "xy"])
+    }
+
+    func test_erasedService_propagatesErrors() async {
+        struct Boom: Error {}
+        let service = ErasedCompletionService(FakeService(parts: ["x"], failure: Boom()))
+        var elements = [String]()
+        do {
+            for try await element in service.streamCompletion(
+                FakePrompt(stopWords: []),
+                streamStopStrategy: NeverStreamStopStrategy()
+            ) {
+                elements.append(element)
+            }
+            XCTFail("expected the error to propagate")
+        } catch {
+            XCTAssertTrue(error is Boom)
+        }
+        XCTAssertEqual(elements, ["x"])
+
+        do {
+            _ = try await service.getCompletions(
+                FakePrompt(stopWords: []),
+                streamStopStrategy: NeverStreamStopStrategy(),
+                count: 1
+            )
+            XCTFail("expected the error to propagate")
+        } catch {
+            XCTAssertTrue(error is Boom)
+        }
+    }
+}
+
+private struct FakeService: CodeCompletionServiceType {
+    let parts: [String]
+    var failure: Error? = nil
+
+    func getCompletion(_ request: PromptStrategy) async throws -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            for part in parts { continuation.yield(part) }
+            if let failure {
+                continuation.finish(throwing: failure)
+            } else {
+                continuation.finish()
+            }
+        }
+    }
+}
+
+private struct FakePrompt: PromptStrategy {
+    var systemPrompt: String = ""
+    var prefix: [String] = []
+    var suffix: [String] = []
+    var relevantCodeSnippets: [RelevantCodeSnippet] = []
+    var stopWords: [String]
+    var language: CodeLanguage? = nil
+
+    func createPrompt(
+        truncatedPrefix: [String],
+        truncatedSuffix: [String],
+        includedSnippets: [RelevantCodeSnippet]
+    ) -> [PromptMessage] {
+        []
+    }
+}
diff --git a/Tool/Tests/SuggestionProviderTests/NeighboringSnippetRetrieverTests.swift b/Tool/Tests/SuggestionProviderTests/NeighboringSnippetRetrieverTests.swift
new file mode 100644
index 00000000..9a190f3e
--- /dev/null
+++ b/Tool/Tests/SuggestionProviderTests/NeighboringSnippetRetrieverTests.swift
@@ -0,0 +1,154 @@
+import Foundation
+import SuggestionBasic
+import XCTest
+
+@testable import SuggestionProvider
+
+final class CodeTokenizerTests: XCTestCase {
+    func test_splits_on_punctuation_and_lowercases() {
+        let tokens = CodeTokenizer.tokens(in: ["let workoutSummary = WorkoutStore.shared"])
+        XCTAssertTrue(tokens.contains("workoutsummary"))
+        XCTAssertTrue(tokens.contains("workoutstore"))
+        XCTAssertTrue(tokens.contains("shared"))
+    }
+
+    func test_drops_stop_words_and_short_tokens() {
+        let tokens = CodeTokenizer.tokens(in: ["let a = self.id"])
+        XCTAssertFalse(tokens.contains("let"))
+        XCTAssertFalse(tokens.contains("self"))
+        XCTAssertFalse(tokens.contains("a"))
+        XCTAssertFalse(tokens.contains("id"))
+    }
+
+    func test_keeps_underscored_identifiers_whole() {
+        let tokens = CodeTokenizer.tokens(in: ["private var _cachedWorkout: Int"])
+        XCTAssertTrue(tokens.contains("_cachedworkout"))
+    }
+}
+
+final class NeighboringSnippetRetrieverTests: XCTestCase {
+    let options = NeighboringSnippetRetriever.Options(
+        referenceWindow: 40,
+        windowSize: 3,
+        windowStride: 3,
+        maxSnippets: 3,
+        maxSnippetsPerFile: 1,
+        minimumScore: 0.05
+    )
+
+    func makeQuery(_ lines: [String]) -> Set {
+        NeighboringSnippetRetriever.queryTokens(
+            currentLines: lines,
+            cursorPosition: .init(line: lines.count - 1, character: 0),
+            options: options
+        )
+    }
+
+    func test_index_covers_every_line() {
+        let source = NeighboringSnippetRetriever.index(
+            filePath: "A.swift",
+            lines: (0..<7).map { "line\($0)" },
+            options: options
+        )
+        XCTAssertEqual(source.windows.map(\.startLine), [0, 3, 6])
+        XCTAssertEqual(source.windows.last?.endLine, 7)
+    }
+
+    func test_jaccard_similarity() {
+        XCTAssertEqual(NeighboringSnippetRetriever.jaccardSimilarity(["a", "b"], ["a", "b"]), 1)
+        XCTAssertEqual(NeighboringSnippetRetriever.jaccardSimilarity(["a", "b"], ["c"]), 0)
+        XCTAssertEqual(
+            NeighboringSnippetRetriever.jaccardSimilarity(["a", "b"], ["b", "c"]),
+            1.0 / 3.0,
+            accuracy: 0.0001
+        )
+        XCTAssertEqual(NeighboringSnippetRetriever.jaccardSimilarity([], ["a"]), 0)
+    }
+
+    func test_picks_the_file_that_shares_identifiers() {
+        let related = NeighboringSnippetRetriever.index(
+            filePath: "WorkoutStore.swift",
+            lines: [
+                "final class WorkoutStore {",
+                "    func loadWorkoutSummary() -> WorkoutSummary { .init() }",
+                "}",
+            ],
+            options: options
+        )
+        let unrelated = NeighboringSnippetRetriever.index(
+            filePath: "NetworkClient.swift",
+            lines: [
+                "final class NetworkClient {",
+                "    func request(path: String) -> Data { .init() }",
+                "}",
+            ],
+            options: options
+        )
+
+        let snippets = NeighboringSnippetRetriever.retrieve(
+            query: makeQuery(["let summary = WorkoutStore.shared.loadWorkoutSummary()"]),
+            sources: [unrelated, related],
+            options: options
+        )
+
+        XCTAssertEqual(snippets.count, 1)
+        XCTAssertEqual(snippets.first?.filePath, "WorkoutStore.swift")
+        XCTAssertTrue(snippets.first?.content.hasPrefix("// Path: WorkoutStore.swift\n") ?? false)
+        XCTAssertTrue(snippets.first?.content.contains("loadWorkoutSummary") ?? false)
+    }
+
+    func test_minimum_score_drops_unrelated_files() {
+        let unrelated = NeighboringSnippetRetriever.index(
+            filePath: "NetworkClient.swift",
+            lines: ["final class NetworkClient {}"],
+            options: options
+        )
+        var strict = options
+        strict.minimumScore = 0.9
+        let snippets = NeighboringSnippetRetriever.retrieve(
+            query: makeQuery(["let summary = WorkoutStore.shared"]),
+            sources: [unrelated],
+            options: strict
+        )
+        XCTAssertTrue(snippets.isEmpty)
+    }
+
+    func test_respects_per_file_and_total_limits_and_orders_best_first() {
+        let lines = (0..<9).map { index in
+            index == 3 ? "let workoutSummary = 1" : "let unrelated\(index) = \(index)"
+        }
+        let source = NeighboringSnippetRetriever.index(
+            filePath: "A.swift",
+            lines: lines,
+            options: options
+        )
+        let other = NeighboringSnippetRetriever.index(
+            filePath: "B.swift",
+            lines: ["let workoutSummary = 2"],
+            options: options
+        )
+
+        let snippets = NeighboringSnippetRetriever.retrieve(
+            query: makeQuery(["let workoutSummary = 0"]),
+            sources: [source, other],
+            options: options
+        )
+
+        XCTAssertEqual(snippets.count, 2, "one window per file")
+        XCTAssertEqual(snippets.map(\.priority), [2, 1], "best first")
+        XCTAssertEqual(snippets.first?.filePath, "B.swift", "the tighter match wins")
+    }
+
+    func test_empty_query_returns_nothing() {
+        let source = NeighboringSnippetRetriever.index(
+            filePath: "A.swift",
+            lines: ["let workoutSummary = 1"],
+            options: options
+        )
+        XCTAssertTrue(NeighboringSnippetRetriever.retrieve(
+            query: [],
+            sources: [source],
+            options: options
+        ).isEmpty)
+    }
+}
diff --git a/Tool/Tests/SuggestionProviderTests/RecentEditSnippetTests.swift b/Tool/Tests/SuggestionProviderTests/RecentEditSnippetTests.swift
new file mode 100644
index 00000000..f51d6ed3
--- /dev/null
+++ b/Tool/Tests/SuggestionProviderTests/RecentEditSnippetTests.swift
@@ -0,0 +1,149 @@
+import Foundation
+import XCTest
+
+@testable import SuggestionProvider
+
+final class RecentEditDiffTests: XCTestCase {
+    func test_no_change_produces_no_hunk() {
+        XCTAssertNil(RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", "b"],
+            after: ["a", "b"]
+        ))
+    }
+
+    func test_insertion() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", "c"],
+            after: ["a", "b", "c"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, [])
+        XCTAssertEqual(hunk?.inserted, ["b"])
+    }
+
+    func test_deletion() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", "b", "c"],
+            after: ["a", "c"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, ["b"])
+        XCTAssertEqual(hunk?.inserted, [])
+    }
+
+    func test_replacement_keeps_only_the_changed_middle() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["head", "old1", "old2", "tail"],
+            after: ["head", "new1", "tail"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, ["old1", "old2"])
+        XCTAssertEqual(hunk?.inserted, ["new1"])
+    }
+
+    func test_line_endings_are_normalized_before_comparing() {
+        XCTAssertNil(RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a\n", "b\n"],
+            after: ["a", "b"]
+        ))
+
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["let a = 1\n"],
+            after: ["let a = 12\n"]
+        )
+        XCTAssertEqual(hunk?.removed, ["let a = 1"])
+        XCTAssertEqual(hunk?.inserted, ["let a = 12"])
+    }
+
+    func test_typing_at_the_end_of_a_file() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", ""],
+            after: ["a", "b"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, [""])
+        XCTAssertEqual(hunk?.inserted, ["b"])
+    }
+}
+
+final class RecentEditSnippetBuilderTests: XCTestCase {
+    func makeHunk(_ path: String, inserted: [String]) -> RecentEditHunk {
+        .init(filePath: path, startLine: 0, removed: [], inserted: inserted)
+    }
+
+    func test_no_hunks_produces_no_snippet() {
+        XCTAssertNil(RecentEditSnippetBuilder.snippet(from: [], priority: 1))
+    }
+
+    func test_every_line_is_commented_out() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [.init(
+                filePath: "A.swift",
+                startLine: 3,
+                removed: ["var a: Int"],
+                inserted: ["var a: Int = 0"]
+            )],
+            priority: 4
+        )
+        let lines = snippet?.content.components(separatedBy: "\n") ?? []
+        XCTAssertFalse(lines.isEmpty)
+        for line in lines {
+            XCTAssertTrue(line.hasPrefix("//"), "unsafe line in a code prompt: \(line)")
+        }
+        XCTAssertTrue(lines.contains("// --- A.swift"))
+        XCTAssertTrue(lines.contains("// - var a: Int"))
+        XCTAssertTrue(lines.contains("// + var a: Int = 0"))
+        XCTAssertEqual(snippet?.priority, 4)
+    }
+
+    func test_uses_the_language_comment_prefix() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [makeHunk("a.py", inserted: ["x = 1"])],
+            commentPrefix: CodeCommentStyle.prefix(forFileExtension: "py"),
+            priority: 1
+        )
+        XCTAssertTrue(snippet?.content.contains("# + x = 1") ?? false)
+    }
+
+    func test_keeps_only_the_most_recent_hunks() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [
+                makeHunk("Oldest.swift", inserted: ["a"]),
+                makeHunk("Middle.swift", inserted: ["b"]),
+                makeHunk("Newest.swift", inserted: ["c"]),
+            ],
+            priority: 1,
+            maxHunks: 2
+        )
+        let content = snippet?.content ?? ""
+        XCTAssertFalse(content.contains("Oldest.swift"))
+        XCTAssertTrue(content.contains("Middle.swift"))
+        XCTAssertTrue(content.contains("Newest.swift"))
+    }
+
+    func test_truncates_long_hunks() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [makeHunk("A.swift", inserted: (0..<10).map { "line\($0)" })],
+            priority: 1,
+            maxLinesPerHunk: 2
+        )
+        let content = snippet?.content ?? ""
+        XCTAssertTrue(content.contains("// + line0"))
+        XCTAssertTrue(content.contains("// + line1"))
+        XCTAssertFalse(content.contains("// + line2"))
+        XCTAssertTrue(content.contains("8 more lines"))
+    }
+
+    func test_comment_style_defaults_to_double_slash() {
+        XCTAssertEqual(CodeCommentStyle.prefix(forFileExtension: "swift"), "//")
+        XCTAssertEqual(CodeCommentStyle.prefix(forFileExtension: "SQL"), "--")
+        XCTAssertEqual(CodeCommentStyle.prefix(forFileExtension: "unknown"), "//")
+    }
+}
diff --git a/Version.xcconfig b/Version.xcconfig
index 9fbbef26..deb5c5a7 100644
--- a/Version.xcconfig
+++ b/Version.xcconfig
@@ -1,4 +1,4 @@
-APP_VERSION = 0.38.0
-APP_BUILD = 504
+APP_VERSION = 0.40.0
+APP_BUILD = 505
 RELEASE_CHANNEL =
 RELEASE_NUMBER = 1