diff --git a/Core/Package.swift b/Core/Package.swift index 0eab77ee..02e7d479 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -275,6 +275,7 @@ let package = Package( .product(name: "AppMonitoring", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), + .product(name: "CustomAsyncAlgorithms", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 0e9b2062..52964392 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -1,4 +1,5 @@ import AIModel +import Toast import ComposableArchitecture import Dependencies import Keychain @@ -40,7 +41,12 @@ struct ChatModelEdit: ReducerProtocol { case baseURLSelection(BaseURLSelection.Action) } - @Dependency(\.toast) var toast + var toast: (String, ToastType) -> Void { + @Dependency(\.namespacedToast) var toast + return { + toast($0, $1, "ChatModelEdit") + } + } @Dependency(\.apiKeyKeychain) var keychain var body: some ReducerProtocol { @@ -86,14 +92,22 @@ struct ChatModelEdit: ReducerProtocol { ) return .run { send in do { - let reply = - try await ChatGPTService( - configuration: UserPreferenceChatGPTConfiguration() - .overriding { - $0.model = model - } - ).sendAndWait(content: "Hello") + let service = ChatGPTService( + configuration: UserPreferenceChatGPTConfiguration() + .overriding { + $0.model = model + } + ) + let reply = try await service + .sendAndWait(content: "Respond with \"Test succeeded\"") await send(.testSucceeded(reply ?? "No Message")) + let stream = try await service + .send(content: "Respond with \"Stream response is working\"") + var streamReply = "" + for try await chunk in stream { + streamReply += chunk + } + await send(.testSucceeded(streamReply)) } catch { await send(.testFailed(error.localizedDescription)) } @@ -101,12 +115,12 @@ struct ChatModelEdit: ReducerProtocol { case let .testSucceeded(message): state.isTesting = false - toast(message, .info) + toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .info) return .none case let .testFailed(message): state.isTesting = false - toast(message, .error) + toast(message.trimmingCharacters(in: .whitespacesAndNewlines), .error) return .none case .refreshAvailableModelNames: @@ -132,6 +146,15 @@ struct ChatModelEdit: ReducerProtocol { state.suggestedMaxTokens = nil } return .none + case .claude: + if let knownModel = ClaudeChatCompletionsService + .KnownModel(rawValue: state.modelName) + { + state.suggestedMaxTokens = knownModel.contextWindow + } else { + state.suggestedMaxTokens = nil + } + return .none default: state.suggestedMaxTokens = nil return .none @@ -192,13 +215,12 @@ extension ChatModel { isFullURL: state.isFullURL, maxTokens: state.maxTokens, supportsFunctionCalling: { - if case .googleAI = state.format { - return false - } - if case .ollama = state.format { + switch state.format { + case .googleAI, .ollama, .claude: return false + case .azureOpenAI, .openAI, .openAICompatible: + return state.supportsFunctionCalling } - return state.supportsFunctionCalling }(), modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), ollamaInfo: .init(keepAlive: state.ollamaKeepAlive) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index fd6b1e21..fe03c453 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -1,5 +1,6 @@ import AIModel import ComposableArchitecture +import OpenAIService import Preferences import SwiftUI @@ -26,6 +27,8 @@ struct ChatModelEditView: View { googleAI case .ollama: ollama + case .claude: + claude } } } @@ -68,6 +71,7 @@ struct ChatModelEditView: View { store.send(.appear) } .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "ChatModelEdit") } var nameTextField: some View { @@ -96,6 +100,8 @@ struct ChatModelEditView: View { Text("Google Generative AI").tag(format) case .ollama: Text("Ollama").tag(format) + case .claude: + Text("Claude").tag(format) } } }, @@ -348,7 +354,7 @@ struct ChatModelEditView: View { maxTokensTextField } - + @ViewBuilder var ollama: some View { baseURLTextField(prompt: Text("http://127.0.0.1:11434")) { @@ -363,7 +369,7 @@ struct ChatModelEditView: View { } maxTokensTextField - + WithViewStore( store, removeDuplicates: { $0.ollamaKeepAlive == $1.ollamaKeepAlive } @@ -380,6 +386,51 @@ struct ChatModelEditView: View { } .padding(.vertical) } + + @ViewBuilder + var claude: some View { + baseURLTextField(prompt: Text("https://api.anthropic.com")) { + Text("/v1/messages") + } + + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$modelName, + content: { + if ClaudeChatCompletionsService + .KnownModel(rawValue: viewStore.state.modelName) == nil + { + Text("Custom Model").tag(viewStore.state.modelName) + } + ForEach( + ClaudeChatCompletionsService.KnownModel.allCases, + id: \.self + ) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + } + + maxTokensTextField + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " For more details, please visit [https://anthropic.com](https://anthropic.com)." + ) + } + .padding(.vertical) + } } #Preview("OpenAI") { diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift index 1bbb109b..8fbe3a52 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -12,6 +12,7 @@ extension ChatModel: ManageableAIModel { case .openAICompatible: return "OpenAI Compatible" case .googleAI: return "Google Generative AI" case .ollama: return "Ollama" + case .claude: return "Claude" } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index c5d1378e..45ae25fd 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -1,4 +1,5 @@ import AIModel +import Toast import ComposableArchitecture import Dependencies import Keychain @@ -39,7 +40,12 @@ struct EmbeddingModelEdit: ReducerProtocol { case baseURLSelection(BaseURLSelection.Action) } - @Dependency(\.toast) var toast + var toast: (String, ToastType) -> Void { + @Dependency(\.namespacedToast) var toast + return { + toast($0, $1, "EmbeddingModelEdit") + } + } @Dependency(\.apiKeyKeychain) var keychain var body: some ReducerProtocol { diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index 2bad443f..ca7037e2 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -66,6 +66,7 @@ struct EmbeddingModelEditView: View { store.send(.appear) } .fixedSize(horizontal: false, vertical: true) + .handleToast(namespace: "EmbeddingModelEdit") } var nameTextField: some View { diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift index 12808a59..6f33d599 100644 --- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift @@ -8,10 +8,10 @@ import ProHostApp struct PromptToCodeSettingsView: View { final class Settings: ObservableObject { - @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) - var hideCommonPrecedingSpacesInSuggestion - @AppStorage(\.suggestionCodeFontSize) - var suggestionCodeFontSize + @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) + var hideCommonPrecedingSpaces + @AppStorage(\.promptToCodeCodeFontSize) + var fontSize @AppStorage(\.promptToCodeGenerateDescription) var promptToCodeGenerateDescription @AppStorage(\.promptToCodeGenerateDescriptionInUserPreferredLanguage) @@ -84,25 +84,25 @@ struct PromptToCodeSettingsView: View { } } - SettingsDivider("Mirroring Settings of Suggestion Feature") + SettingsDivider("UI") Form { - Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { + Toggle(isOn: $settings.hideCommonPrecedingSpaces) { Text("Hide Common Preceding Spaces") - }.disabled(true) + } HStack { TextField(text: .init(get: { - "\(Int(settings.suggestionCodeFontSize))" + "\(Int(settings.fontSize))" }, set: { - settings.suggestionCodeFontSize = Double(Int($0) ?? 0) + settings.fontSize = Double(Int($0) ?? 0) })) { Text("Font size of suggestion code") } .textFieldStyle(.roundedBorder) Text("pt") - }.disabled(true) + } } ScopeForm() diff --git a/Core/Sources/HostApp/HandleToast.swift b/Core/Sources/HostApp/HandleToast.swift new file mode 100644 index 00000000..564fdada --- /dev/null +++ b/Core/Sources/HostApp/HandleToast.swift @@ -0,0 +1,49 @@ +import Dependencies +import SwiftUI +import Toast + +struct ToastHandler: View { + @ObservedObject var toastController: ToastController + let namespace: String? + + init(toastController: ToastController, namespace: String?) { + _toastController = .init(wrappedValue: toastController) + self.namespace = namespace + } + + var body: some View { + VStack(spacing: 4) { + ForEach(toastController.messages) { message in + if let n = message.namespace, n != namespace { + EmptyView() + } else { + message.content + .foregroundColor(.white) + .padding(8) + .background({ + switch message.type { + case .info: return Color.accentColor + case .error: return Color(nsColor: .systemRed) + case .warning: return Color(nsColor: .systemOrange) + } + }() as Color, in: RoundedRectangle(cornerRadius: 8)) + .shadow(color: Color.black.opacity(0.2), radius: 4) + } + } + } + .padding() + .allowsHitTesting(false) + } +} + +extension View { + func handleToast(namespace: String? = nil) -> some View { + @Dependency(\.toastController) var toastController + return overlay(alignment: .bottom) { + ToastHandler(toastController: toastController, namespace: namespace) + }.environment(\.toast) { [toastController] content, type in + toastController.toast(content: content, type: type, namespace: namespace) + } + } +} + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 720ddcf4..ac4bbb40 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -75,33 +75,12 @@ public struct TabContainer: View { } .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) - .overlay(alignment: .bottom) { - VStack(spacing: 4) { - ForEach(toastController.messages) { message in - message.content - .foregroundColor(.white) - .padding(8) - .background({ - switch message.type { - case .info: return Color.accentColor - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) - } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) - .shadow(color: Color.black.opacity(0.2), radius: 4) - } - } - .padding() - .allowsHitTesting(false) - } } .focusable(false) .padding(.top, 8) .background(.ultraThinMaterial.opacity(0.01)) .background(Color(nsColor: .controlBackgroundColor).opacity(0.4)) - .environment(\.toast) { [toastController] content, type in - toastController.toast(content: content, type: type) - } + .handleToast() .onPreferenceChange(TabBarItemPreferenceKey.self) { items in tabBarItems = items } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index 490c9cc6..da791871 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -1,5 +1,6 @@ import AppKit import ComposableArchitecture +import CustomAsyncAlgorithms import Dependencies import Foundation import PromptToCodeService @@ -43,7 +44,7 @@ public struct PromptToCode: ReducerProtocol { } } } - + public enum FocusField: Equatable { case textField } @@ -113,7 +114,7 @@ public struct PromptToCode: ReducerProtocol { self.generateDescriptionRequirement = generateDescriptionRequirement self.isAttachedToSelectionRange = isAttachedToSelectionRange self.commandName = commandName - + if selectionRange?.isEmpty ?? true { self.isAttachedToSelectionRange = false } @@ -151,7 +152,7 @@ public struct PromptToCode: ReducerProtocol { switch action { case .binding: return .none - + case .focusOnTextField: state.focusedField = .textField return .none @@ -186,8 +187,8 @@ public struct PromptToCode: ReducerProtocol { extraSystemPrompt: copiedState.extraSystemPrompt, generateDescriptionRequirement: copiedState .generateDescriptionRequirement - ) - #warning("TODO: make the action call debounced.") + ).timedDebounce(for: 0.2) + for try await fragment in stream { try Task.checkCancellation() await send(.modifyCodeChunkReceived( diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 9f63b0f4..011dcaf1 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -56,6 +56,10 @@ struct XcodeLikeFrame: View { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(Material.bar) ) + .overlay( + RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) + .stroke(Color.black.opacity(0.1), style: .init(lineWidth: 1)) + ) // Add an extra border just incase the background is not displayed. .overlay( RoundedRectangle(cornerRadius: max(0, cornerRadius - 1), style: .continuous) .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index b5f60d67..8ba5d57a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -7,6 +7,7 @@ struct CodeBlockSuggestionPanel: View { @AppStorage(\.suggestionCodeFontSize) var fontSize @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpaces struct ToolBar: View { @ObservedObject var suggestion: CodeSuggestionProvider @@ -101,7 +102,8 @@ struct CodeBlockSuggestionPanel: View { language: suggestion.language, startLineIndex: suggestion.startLineIndex, colorScheme: colorScheme, - fontSize: fontSize + fontSize: fontSize, + droppingLeadingSpaces: hideCommonPrecedingSpaces ) .frame(maxWidth: .infinity) } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 98b5707f..2414803e 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -202,7 +202,8 @@ extension PromptToCodePanel { struct Content: View { let store: StoreOf @Environment(\.colorScheme) var colorScheme - @AppStorage(\.suggestionCodeFontSize) var fontSize + @AppStorage(\.promptToCodeCodeFontSize) var fontSize + @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces struct CodeContent: Equatable { var code: String @@ -278,7 +279,8 @@ extension PromptToCodePanel { colorScheme: colorScheme, firstLinePrecedingSpaceCount: viewStore.state .firstLinePrecedingSpaceCount, - fontSize: fontSize + fontSize: fontSize, + droppingLeadingSpaces: hideCommonPrecedingSpaces ) .frame(maxWidth: .infinity) .scaleEffect(x: 1, y: -1, anchor: .center) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 87cbf182..618b3682 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -70,7 +70,7 @@ public extension SuggestionWidgetController { } func presentError(_ errorDescription: String) { - store.send(.toastPanel(.toast(.toast(errorDescription, .error)))) + store.send(.toastPanel(.toast(.toast(errorDescription, .error, nil)))) } func presentChatRoom() { diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 04c11c86..2b4b3b8e 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -140,7 +140,6 @@ struct WidgetAnimatedCircle: View { struct WidgetContextMenu: View { @AppStorage(\.useGlobalChat) var useGlobalChat @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle - @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpacesInSuggestion @AppStorage(\.disableSuggestionFeatureGlobally) var disableSuggestionFeatureGlobally @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList @@ -198,15 +197,6 @@ struct WidgetContextMenu: View { Image(systemName: "checkmark") } } - - Button(action: { - hideCommonPrecedingSpacesInSuggestion.toggle() - }, label: { - Text("Hide Common Preceding Spaces in Suggestion") - if hideCommonPrecedingSpacesInSuggestion { - Image(systemName: "checkmark") - } - }) } Divider() diff --git a/Tool/Package.swift b/Tool/Package.swift index 61b90ca5..bfd91c4b 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -44,6 +44,7 @@ let package = Package( .library(name: "GitIgnoreCheck", targets: ["GitIgnoreCheck"]), .library(name: "DebounceFunction", targets: ["DebounceFunction"]), .library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]), + .library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]), ], dependencies: [ // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. @@ -85,6 +86,13 @@ let package = Package( .target(name: "ObjectiveCExceptionHandling"), + .target( + name: "CustomAsyncAlgorithms", + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") + ] + ), + .target( name: "Keychain", dependencies: ["Configs", "Preferences"] diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index 325592a7..00e9fc01 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -22,6 +22,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { case openAICompatible case googleAI case ollama + case claude } public struct Info: Codable, Equatable { @@ -107,6 +108,10 @@ public struct ChatModel: Codable, Equatable, Identifiable { let baseURL = info.baseURL if baseURL.isEmpty { return "http://localhost:11434/api/chat" } return "\(baseURL)/api/chat" + case .claude: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.anthropic.com/v1/messages" } + return "\(baseURL)/v1/messages" } } } diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift new file mode 100644 index 00000000..7936b494 --- /dev/null +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -0,0 +1,67 @@ +import Foundation + +private actor TimedDebounceFunction { + let duration: TimeInterval + let block: (Element) async -> Void + + var task: Task? + var lastValue: Element? + var lastFireTime: Date = .init(timeIntervalSince1970: 0) + + init(duration: TimeInterval, block: @escaping (Element) async -> Void) { + self.duration = duration + self.block = block + } + + func callAsFunction(_ value: Element) async { + task?.cancel() + if lastFireTime.timeIntervalSinceNow < -duration { + await fire(value) + task = nil + } else { + lastValue = value + task = Task.detached { [weak self, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await self?.fire(value) + } + } + } + + func finish() async { + task?.cancel() + if let lastValue { + await fire(lastValue) + } + } + + private func fire(_ value: Element) async { + lastFireTime = Date() + lastValue = nil + await block(value) + } +} + +public extension AsyncSequence { + /// Debounce, but only if the value is received within a certain time frame. + func timedDebounce( + for duration: TimeInterval + ) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + Task { + let function = TimedDebounceFunction(duration: duration) { value in + continuation.yield(value) + } + do { + for try await value in self { + await function(value) + } + await function.finish() + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift new file mode 100644 index 00000000..081795c4 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -0,0 +1,368 @@ +import AIModel +import AsyncAlgorithms +import CodableWrappers +import Foundation +import Logger +import Preferences + +/// https://docs.anthropic.com/claude/reference/messages_post +public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { + public enum KnownModel: String, CaseIterable { + case claude3Opus = "claude-3-opus-20240229" + case claude3Sonnet = "claude-3-sonnet-20240229" + case claude3Haiku = "claude-3-haiku-20240307" + + public var contextWindow: Int { + switch self { + case .claude3Opus: return 200_000 + case .claude3Sonnet: return 200_000 + case .claude3Haiku: return 200_000 + } + } + } + + struct APIError: Error, Decodable, LocalizedError { + struct ErrorDetail: Decodable { + var message: String? + var type: String? + } + + var error: ErrorDetail? + var type: String + + var errorDescription: String? { + error?.message ?? "Unknown Error" + } + } + + enum MessageRole: String, Codable { + case user + case assistant + + var formalized: ChatCompletionsRequestBody.Message.Role { + switch self { + case .user: return .user + case .assistant: return .assistant + } + } + } + + struct StreamDataChunk: Decodable { + var type: String + var message: Message? + var index: Int? + var content_block: ContentBlock? + var delta: Delta? + var error: APIError? + + struct Message: Decodable { + var id: String + var type: String + var role: MessageRole? + var content: [ContentBlock]? + var model: String + var stop_reason: String? + var stop_sequence: String? + var usage: Usage? + } + + struct ContentBlock: Decodable { + var type: String + var text: String? + } + + struct Delta: Decodable { + var type: String + var text: String? + var stop_reason: String? + var stop_sequence: String? + var usage: Usage? + } + + struct Usage: Decodable { + var input_tokens: Int? + var output_tokens: Int? + } + } + + struct ResponseBody: Codable, Equatable { + struct Content: Codable, Equatable { + enum ContentType: String, Codable, FallbackValueProvider { + case text + case unknown + static var defaultValue: ContentType { .unknown } + } + + /// The type of the message. + /// + /// Currently, the only supported type is `text`. + @FallbackDecoding + var type: ContentType + /// The content of the message. + /// + /// If the request input messages ended with an assistant turn, + /// then the response content will continue directly from that last turn. + /// You can use this to constrain the model's output. + var text: String? + } + + struct Usage: Codable, Equatable { + var input_tokens: Int? + var output_tokens: Int? + } + + var id: String? + var model: String + var type: String + var usage: Usage + var role: MessageRole + var content: [Content] + var stop_reason: String? + var stop_sequence: String? + } + + struct RequestBody: Encodable, Equatable { + struct MessageContent: Encodable, Equatable { + enum MessageContentType: String, Encodable, Equatable { + case text + case image + } + + struct ImageSource: Encodable, Equatable { + var type: String = "base64" + /// currently support the base64 source type for images, + /// and the image/jpeg, image/png, image/gif, and image/webp media types. + var media_type: String = "image/jpeg" + var data: String + } + + var type: MessageContentType + var text: String? + var source: ImageSource? + } + + struct Message: Encodable, Equatable { + /// The role of the message. + var role: MessageRole + /// The content of the message. + var content: [MessageContent] + + mutating func appendText(_ text: String) { + var otherContents = [MessageContent]() + var existedText = "" + for existed in content { + switch existed.type { + case .text: + if existedText.isEmpty { + existedText = existed.text ?? "" + } else if let text = existed.text { + existedText += "\n\n" + text + } + default: + otherContents.append(existed) + } + } + + content = otherContents + [.init(type: .text, text: existedText + "\n\n\(text)")] + } + } + + var model: String + var system: String + var messages: [Message] + var temperature: Double? + var stream: Bool? + var stop_sequences: [String]? + var max_tokens: Int + } + + var apiKey: String + var endpoint: URL + var requestBody: RequestBody + var model: ChatModel + + init( + apiKey: String, + model: ChatModel, + endpoint: URL, + requestBody: ChatCompletionsRequestBody + ) { + self.apiKey = apiKey + self.endpoint = endpoint + self.requestBody = .init(requestBody) + self.model = model + } + + func callAsFunction() async throws + -> AsyncThrowingStream + { + requestBody.stream = true + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + if !apiKey.isEmpty { + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + } + + let (result, response) = try await URLSession.shared.bytes(for: request) + guard let response = response as? HTTPURLResponse else { + throw ChatGPTServiceError.responseInvalid + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + guard let data = text.data(using: .utf8) + else { throw ChatGPTServiceError.responseInvalid } + let decoder = JSONDecoder() + let error = try? decoder.decode(APIError.self, from: data) + throw error ?? ChatGPTServiceError.responseInvalid + } + + let stream = ResponseStream(result: result) { + var line = $0 + if line.hasPrefix("event:") { + return .init(chunk: nil, done: false) + } + + let prefix = "data: " + if line.hasPrefix(prefix) { + line.removeFirst(prefix.count) + } + + if line == "[DONE]" { return .init(chunk: nil, done: true) } + + do { + let chunk = try JSONDecoder().decode( + StreamDataChunk.self, + from: line.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.type == "message_stop") + } catch { + Logger.service.error("Error decoding stream data: \(error)") + return .init(chunk: nil, done: false) + } + } + + return stream.map { $0.formalized() }.toStream() + } + + func callAsFunction() async throws -> ChatCompletionResponseBody { + requestBody.stream = false + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + if !apiKey.isEmpty { + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + } + + let (result, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw ChatGPTServiceError.responseInvalid + } + + guard response.statusCode == 200 else { + let error = try? JSONDecoder().decode(APIError.self, from: result) + throw error ?? ChatGPTServiceError + .otherError(String(data: result, encoding: .utf8) ?? "Unknown Error") + } + + do { + let body = try JSONDecoder().decode(ResponseBody.self, from: result) + return body.formalized() + } catch { + dump(error) + throw error + } + } +} + +extension ClaudeChatCompletionsService.ResponseBody { + func formalized() -> ChatCompletionResponseBody { + return .init( + id: id, + object: "chat.completions", + model: model, + message: .init( + role: role.formalized, + content: content.reduce(into: "") { partialResult, next in + if let text = next.text { + partialResult += text + } + } + ), + otherChoices: [], + finishReason: stop_reason ?? "" + ) + } +} + +extension ClaudeChatCompletionsService.StreamDataChunk { + func formalized() -> ChatCompletionsStreamDataChunk { + return .init( + id: message?.id, + object: "chat.completions", + model: message?.model, + message: { + if let delta { + return .init(content: delta.text) + } + if let message { + return .init(role: message.role?.formalized) + } + return nil + }(), + finishReason: delta?.stop_reason + ) + } +} + +extension ClaudeChatCompletionsService.RequestBody { + init(_ body: ChatCompletionsRequestBody) { + model = body.model + + var systemPrompts = [String]() + var nonSystemMessages = [Message]() + + for message in body.messages { + switch message.role { + case .system: + systemPrompts.append(message.content) + case .tool, .assistant: + if let last = nonSystemMessages.last, last.role == .assistant { + nonSystemMessages[nonSystemMessages.endIndex - 1].appendText(message.content) + } else { + nonSystemMessages.append(.init( + role: .assistant, + content: [.init(type: .text, text: message.content)] + )) + } + case .user: + if let last = nonSystemMessages.last, last.role == .user { + nonSystemMessages[nonSystemMessages.endIndex - 1].appendText(message.content) + } else { + nonSystemMessages.append(.init( + role: .user, + content: [.init(type: .text, text: message.content)] + )) + } + } + } + + messages = nonSystemMessages + system = systemPrompts.joined(separator: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + temperature = body.temperature + stream = body.stream + stop_sequences = body.stop + max_tokens = body.maxTokens ?? 4000 + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 2eab6489..f3fd8911 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -238,8 +238,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI case .openAI: if !model.info.openAIInfo.organizationID.isEmpty { request.setValue( - "OpenAI-Organization", - forHTTPHeaderField: model.info.openAIInfo.organizationID + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" ) } request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") @@ -251,6 +251,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI assertionFailure("Unsupported") case .ollama: assertionFailure("Unsupported") + case .claude: + assertionFailure("Unsupported") } } @@ -319,6 +321,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI assertionFailure("Unsupported") case .ollama: assertionFailure("Unsupported") + case .claude: + assertionFailure("Unsupported") } } @@ -376,7 +380,7 @@ extension OpenAIChatCompletionsService.ResponseBody { ), ] } else { - return [] + return nil } }() ) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 61970a5e..17847e18 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -111,6 +111,13 @@ public class ChatGPTService: ChatGPTServiceType { endpoint: endpoint, requestBody: requestBody ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) } } @@ -138,6 +145,13 @@ public class ChatGPTService: ChatGPTServiceType { endpoint: endpoint, requestBody: requestBody ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) } } @@ -579,7 +593,7 @@ extension ChatGPTService { let serviceSupportsFunctionCalling = switch model.format { case .openAI, .openAICompatible, .azureOpenAI: model.info.supportsFunctionCalling - case .ollama, .googleAI: + case .ollama, .googleAI, .claude: false } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index d020accf..a7588692 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -294,6 +294,14 @@ public extension UserDefaultPreferenceKeys { var enableSenseScopeByDefaultInPromptToCode: PreferenceKey { .init(defaultValue: false, key: "EnableSenseScopeByDefaultInPromptToCode") } + + var promptToCodeCodeFontSize: PreferenceKey { + .init(defaultValue: 13, key: "PromptToCodeCodeFontSize") + } + + var hideCommonPrecedingSpacesInPromptToCode: PreferenceKey { + .init(defaultValue: true, key: "HideCommonPrecedingSpacesInPromptToCode") + } } // MARK: - Suggestion diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index fb5244d8..1cf3b0b4 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -25,6 +25,10 @@ public extension UserDefaults { for: \.suggestionFeatureProvider, defaultValue: .builtIn(shared.deprecatedValue(for: \.oldSuggestionFeatureProvider)) ) + shared.setupDefaultValue( + for: \.promptToCodeCodeFontSize, + defaultValue: shared.value(for: \.suggestionCodeFontSize) + ) } } diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift index 191d385f..c66a816a 100644 --- a/Tool/Sources/SharedUIComponents/CodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -9,6 +9,7 @@ public struct CodeBlock: View { public let highlightedCode: [NSAttributedString] public let firstLinePrecedingSpaceCount: Int public let fontSize: Double + public let droppingLeadingSpaces: Bool public init( code: String, @@ -16,12 +17,14 @@ public struct CodeBlock: View { startLineIndex: Int, colorScheme: ColorScheme, firstLinePrecedingSpaceCount: Int = 0, - fontSize: Double + fontSize: Double, + droppingLeadingSpaces: Bool ) { self.code = code self.language = language self.startLineIndex = startLineIndex self.colorScheme = colorScheme + self.droppingLeadingSpaces = droppingLeadingSpaces self.firstLinePrecedingSpaceCount = firstLinePrecedingSpaceCount self.fontSize = fontSize let padding = firstLinePrecedingSpaceCount > 0 @@ -31,7 +34,8 @@ public struct CodeBlock: View { code: padding + code, language: language, colorScheme: colorScheme, - fontSize: fontSize + fontSize: fontSize, + droppingLeadingSpaces: droppingLeadingSpaces ) commonPrecedingSpaceCount = result.commonLeadingSpaceCount highlightedCode = result.code @@ -72,14 +76,14 @@ public struct CodeBlock: View { code: String, language: String, colorScheme: ColorScheme, - fontSize: Double + fontSize: Double, + droppingLeadingSpaces: Bool ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { return highlighted( code: code, language: language, brightMode: colorScheme != .dark, - droppingLeadingSpaces: UserDefaults.shared - .value(for: \.hideCommonPrecedingSpacesInSuggestion), + droppingLeadingSpaces: droppingLeadingSpaces, fontSize: fontSize ) } @@ -98,7 +102,8 @@ struct CodeBlock_Previews: PreviewProvider { startLineIndex: 0, colorScheme: .dark, firstLinePrecedingSpaceCount: 0, - fontSize: 12 + fontSize: 12, + droppingLeadingSpaces: true ) } } diff --git a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift b/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift index 87e716a9..21c53d0b 100644 --- a/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/Experiment/NewCodeBlock.swift @@ -12,6 +12,7 @@ struct _CodeBlock: View { let commonPrecedingSpaceCount: Int let highlightedCode: AttributedString let colorScheme: ColorScheme + let droppingLeadingSpaces: Bool /// Create a text edit view with a certain text that uses a certain options. /// - Parameters: @@ -24,11 +25,13 @@ struct _CodeBlock: View { firstLinePrecedingSpaceCount: Int, colorScheme: ColorScheme, fontSize: Double, + droppingLeadingSpaces: Bool, selection: Binding = .constant(nil) ) { _selection = selection self.fontSize = fontSize self.colorScheme = colorScheme + self.droppingLeadingSpaces = droppingLeadingSpaces let padding = firstLinePrecedingSpaceCount > 0 ? String(repeating: " ", count: firstLinePrecedingSpaceCount) @@ -37,7 +40,8 @@ struct _CodeBlock: View { code: padding + code, language: language, colorScheme: colorScheme, - fontSize: fontSize + fontSize: fontSize, + droppingLeadingSpaces: droppingLeadingSpaces ) commonPrecedingSpaceCount = result.commonLeadingSpaceCount highlightedCode = result.code @@ -65,14 +69,14 @@ struct _CodeBlock: View { code: String, language: String, colorScheme: ColorScheme, - fontSize: Double + fontSize: Double, + droppingLeadingSpaces: Bool ) -> (code: AttributedString, commonLeadingSpaceCount: Int) { let (lines, commonLeadingSpaceCount) = highlighted( code: code, language: language, brightMode: colorScheme != .dark, - droppingLeadingSpaces: UserDefaults.shared - .value(for: \.hideCommonPrecedingSpacesInSuggestion), + droppingLeadingSpaces: droppingLeadingSpaces, fontSize: fontSize, replaceSpacesWithMiddleDots: false ) @@ -142,7 +146,7 @@ private struct _CodeBlockRepresentable: NSViewRepresentable { context.coordinator.parent = self let textView = scrollView.documentView as! STTextViewFrameObservable - + textView.onHeightChange = onHeightChange textView.showsInvisibleCharacters = true textView.textContainer.lineBreakMode = .byCharWrapping @@ -241,7 +245,10 @@ private class STTextViewFrameObservable: STTextView { var onHeightChange: ((Double) -> Void)? func recalculateSize() { var maxY = 0 as Double - textLayoutManager.enumerateTextLayoutFragments(in: textLayoutManager.documentRange, options: [.ensuresLayout]) { fragment in + textLayoutManager.enumerateTextLayoutFragments( + in: textLayoutManager.documentRange, + options: [.ensuresLayout] + ) { fragment in print(fragment.layoutFragmentFrame) maxY = max(maxY, fragment.layoutFragmentFrame.maxY) return true @@ -287,3 +294,4 @@ private final class ColumnRuler: NSRulerView { ]) } } + diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift index 3aca842d..553caa12 100644 --- a/Tool/Sources/Toast/Toast.swift +++ b/Tool/Sources/Toast/Toast.swift @@ -30,15 +30,28 @@ public extension DependencyValues { set { self[ToastControllerDependencyKey.self] = newValue } } - var toast: (String, ToastType) -> Void { toastController.toast } + var toast: (String, ToastType) -> Void { + return { content, type in + toastController.toast(content: content, type: type, namespace: nil) + } + } + + var namespacedToast: (String, ToastType, String) -> Void { + return { + content, type, namespace in + toastController.toast(content: content, type: type, namespace: namespace) + } + } } public class ToastController: ObservableObject { public struct Message: Identifiable, Equatable { + public var namespace: String? public var id: UUID public var type: ToastType public var content: Text - public init(id: UUID, type: ToastType, content: Text) { + public init(id: UUID, type: ToastType, namespace: String? = nil, content: Text) { + self.namespace = namespace self.id = id self.type = type self.content = content @@ -51,9 +64,9 @@ public class ToastController: ObservableObject { self.messages = messages } - public func toast(content: String, type: ToastType) { + public func toast(content: String, type: ToastType, namespace: String? = nil) { let id = UUID() - let message = Message(id: id, type: type, content: Text(content)) + let message = Message(id: id, type: type, namespace: namespace, content: Text(content)) Task { @MainActor in withAnimation(.easeInOut(duration: 0.2)) { @@ -73,7 +86,7 @@ public struct Toast: ReducerProtocol { public struct State: Equatable { var isObservingToastController = false public var messages: [Message] = [] - + public init(messages: [Message] = []) { self.messages = messages } @@ -82,13 +95,13 @@ public struct Toast: ReducerProtocol { public enum Action: Equatable { case start case updateMessages([Message]) - case toast(String, ToastType) + case toast(String, ToastType, String?) } @Dependency(\.toastController) var toastController struct CancelID: Hashable {} - + public init() {} public var body: some ReducerProtocol { @@ -114,8 +127,8 @@ public struct Toast: ReducerProtocol { case let .updateMessages(messages): state.messages = messages return .none - case let .toast(content, type): - toastController.toast(content: content, type: type) + case let .toast(content, type, namespace): + toastController.toast(content: content, type: type, namespace: namespace) return .none } } diff --git a/Version.xcconfig b/Version.xcconfig index efa04634..b9735f6d 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.31.2 -APP_BUILD = 340 +APP_VERSION = 0.31.3 +APP_BUILD = 343 diff --git a/appcast.xml b/appcast.xml index 294301d0..51d677eb 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,19 @@ Copilot for Xcode + + + 0.31.3 + Fri, 29 Mar 2024 18:52:57 +0800 + 343 + 0.31.3 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.31.3 + + + + 0.31.2 Thu, 14 Mar 2024 22:26:51 +0800