diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 4a34cde1..f1407c31 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -32,6 +32,7 @@ struct ChatModelEdit { var openAIOrganizationID: String = "" var openAIProjectID: String = "" var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [] + var openAICompatibleSupportsMultipartMessageContent = true } enum Action: Equatable, BindableAction { @@ -88,21 +89,33 @@ struct ChatModelEdit { let model = ChatModel(state: state) return .run { send in do { - let service = LegacyChatGPTService( - 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 + let configuration = UserPreferenceChatGPTConfiguration().overriding { + $0.model = model } + let service = ChatGPTService(configuration: configuration) + let stream = service.send(TemplateChatGPTMemory( + memoryTemplate: .init(messages: [ + .init(chatMessage: .init( + role: .system, + content: "You are a bot. Just do what is told." + )), + .init(chatMessage: .init( + role: .assistant, + content: "Hello" + )), + .init(chatMessage: .init( + role: .user, + content: "Respond with \"Test succeeded.\"" + )), + .init(chatMessage: .init( + role: .user, + content: "Respond with \"Test succeeded.\"" + )), + ]), + configuration: configuration, + functionProvider: NoChatGPTFunctionProvider() + )) + let streamReply = try await stream.asText() await send(.testSucceeded(streamReply)) } catch { await send(.testFailed(error.localizedDescription)) @@ -206,7 +219,11 @@ extension ChatModel { ), ollamaInfo: .init(keepAlive: state.ollamaKeepAlive), googleGenerativeAIInfo: .init(apiVersion: state.apiVersion), - openAICompatibleInfo: .init(enforceMessageOrder: state.enforceMessageOrder), + openAICompatibleInfo: .init( + enforceMessageOrder: state.enforceMessageOrder, + supportsMultipartMessageContent: state + .openAICompatibleSupportsMultipartMessageContent + ), customHeaderInfo: .init(headers: state.customHeaders) ) ) @@ -230,7 +247,9 @@ extension ChatModel { enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder, openAIOrganizationID: info.openAIInfo.organizationID, openAIProjectID: info.openAIInfo.projectID, - customHeaders: info.customHeaderInfo.headers + customHeaders: info.customHeaderInfo.headers, + openAICompatibleSupportsMultipartMessageContent: info.openAICompatibleInfo + .supportsMultipartMessageContent ) } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 595bc6c5..c6b74281 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -322,6 +322,10 @@ struct ChatModelEditView: View { Text("Enforce message order to be user/assistant alternated") } + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { + Text("Support multi-part message content") + } + Button("Custom Headers") { isEditingCustomHeader.toggle() } diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 53f2d99c..584d9101 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -198,7 +198,7 @@ struct CustomCommandView: View { VStack { SubSection(title: Text("Send Message")) { Text( - "This command sends a message to the active chat tab. You can provide additional context through the \"Extra System Prompt\" as well." + "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." ) } SubSection(title: Text("Modification")) { @@ -208,7 +208,7 @@ struct CustomCommandView: View { } SubSection(title: Text("Custom Chat")) { Text( - "This command will overwrite the system prompt to let the bot behave differently." + "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." ) } SubSection(title: Text("Single Round Dialog")) { @@ -275,7 +275,9 @@ struct CustomCommandView_Preview: PreviewProvider { extraSystemPrompt: nil, prompt: "Hello", useExtraSystemPrompt: false - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "2", @@ -285,7 +287,9 @@ struct CustomCommandView_Preview: PreviewProvider { prompt: "Refactor", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), ], "CustomCommandView_Preview")) @@ -299,7 +303,9 @@ struct CustomCommandView_Preview: PreviewProvider { extraSystemPrompt: nil, prompt: "Hello", useExtraSystemPrompt: false - ) + ), + ignoreExistingAttachments: false, + attachments: [] as [CustomCommand.Attachment] ))) ), reducer: { CustomCommandFeature(settings: settings) } @@ -319,7 +325,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider { extraSystemPrompt: nil, prompt: "Hello", useExtraSystemPrompt: false - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "2", @@ -329,7 +337,9 @@ struct CustomCommandView_NoEditing_Preview: PreviewProvider { prompt: "Refactor", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), ], "CustomCommandView_Preview")) diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift index f914d068..e927a9ff 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommand.swift @@ -23,11 +23,16 @@ struct EditCustomCommand { var promptToCode = EditPromptToCodeCommand.State() var customChat = EditCustomChatCommand.State() var singleRoundDialog = EditSingleRoundDialogCommand.State() + var attachments = EditCustomCommandAttachment.State() init(_ command: CustomCommand?) { isNewCommand = command == nil commandId = command?.id ?? UUID().uuidString name = command?.name ?? "New Command" + attachments = .init( + attachments: command?.attachments ?? [], + ignoreExistingAttachments: command?.ignoreExistingAttachments ?? false + ) switch command?.feature { case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): @@ -83,6 +88,7 @@ struct EditCustomCommand { case promptToCode(EditPromptToCodeCommand.Action) case customChat(EditCustomChatCommand.Action) case singleRoundDialog(EditSingleRoundDialogCommand.Action) + case attachments(EditCustomCommandAttachment.Action) } let settings: CustomCommandView.Settings @@ -106,6 +112,10 @@ struct EditCustomCommand { EditSingleRoundDialogCommand() } + Scope(state: \.attachments, action: \.attachments) { + EditCustomCommandAttachment() + } + BindingReducer() Reduce { state, action in @@ -151,7 +161,9 @@ struct EditCustomCommand { receiveReplyInNotification: state.receiveReplyInNotification ) } - }() + }(), + ignoreExistingAttachments: state.attachments.ignoreExistingAttachments, + attachments: state.attachments.attachments ) if state.isNewCommand { @@ -184,6 +196,32 @@ struct EditCustomCommand { return .none case .singleRoundDialog: return .none + case .attachments: + return .none + } + } + } +} + +@Reducer +struct EditCustomCommandAttachment { + @ObservableState + struct State: Equatable { + var attachments: [CustomCommand.Attachment] = [] + var ignoreExistingAttachments: Bool = false + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + } + + var body: some ReducerOf { + BindingReducer() + + Reduce { _, action in + switch action { + case .binding: + return .none } } } diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index 1dade9bb..d42cac83 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -57,6 +57,10 @@ struct EditCustomCommandView: View { store: store.scope( state: \.sendMessage, action: \.sendMessage + ), + attachmentStore: store.scope( + state: \.attachments, + action: \.attachments ) ) case .promptToCode: @@ -71,6 +75,10 @@ struct EditCustomCommandView: View { store: store.scope( state: \.customChat, action: \.customChat + ), + attachmentStore: store.scope( + state: \.attachments, + action: \.attachments ) ) case .singleRoundDialog: @@ -88,19 +96,19 @@ struct EditCustomCommandView: View { WithPerceptionTracking { VStack { Divider() - + VStack(alignment: .trailing) { Text( "After renaming or adding a custom command, please restart Xcode to refresh the menu." ) .foregroundStyle(.secondary) - + HStack { Spacer() Button("Close") { store.send(.close) } - + if store.isNewCommand { Button("Add") { store.send(.saveCommand) @@ -120,22 +128,168 @@ struct EditCustomCommandView: View { } } +struct CustomCommandAttachmentPickerView: View { + @Perception.Bindable var store: StoreOf + @State private var isFileInputPresented = false + @State private var filePath = "" + + #if canImport(ProHostApp) + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("Contexts") + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + if store.attachments.isEmpty { + Text("No context") + .foregroundStyle(.secondary) + } else { + ForEach(store.attachments, id: \.kind) { attachment in + HStack { + switch attachment.kind { + case let .file(path: path): + HStack { + Text("File:") + Text(path).foregroundStyle(.secondary) + } + default: + Text(attachment.kind.description) + } + Spacer() + Button { + store.attachments.removeAll { $0.kind == attachment.kind } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + } + } + } + } + .frame(minWidth: 240) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(.separator, lineWidth: 1) + } + + Form { + Menu { + ForEach(CustomCommand.Attachment.Kind.allCases.filter { kind in + !store.attachments.contains { $0.kind == kind } + }, id: \.self) { kind in + if kind == .file(path: "") { + Button { + isFileInputPresented = true + } label: { + Text("File...") + } + } else { + Button { + store.attachments.append(.init(kind: kind)) + } label: { + Text(kind.description) + } + } + } + } label: { + Label("Add context", systemImage: "plus") + } + + Toggle( + "Ignore existing contexts", + isOn: $store.ignoreExistingAttachments + ) + } + } + } + .sheet(isPresented: $isFileInputPresented) { + VStack(alignment: .leading, spacing: 16) { + Text("Enter file path:") + .font(.headline) + Text( + "You can enter either an absolute path or a path relative to the project root." + ) + .font(.caption) + .foregroundStyle(.secondary) + TextField("File path", text: $filePath) + .textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Cancel") { + isFileInputPresented = false + filePath = "" + } + Button("Add") { + store.attachments.append(.init(kind: .file(path: filePath))) + isFileInputPresented = false + filePath = "" + } + .disabled(filePath.isEmpty) + } + } + .padding() + .frame(minWidth: 400) + } + } + } + #else + var body: some View { EmptyView() } + #endif +} + +extension CustomCommand.Attachment.Kind { + public static var allCases: [CustomCommand.Attachment.Kind] { + [ + .activeDocument, + .debugArea, + .clipboard, + .senseScope, + .projectScope, + .webScope, + .gitStatus, + .gitLog, + .file(path: ""), + ] + } + + var description: String { + switch self { + case .activeDocument: return "Active Document" + case .debugArea: return "Debug Area" + case .clipboard: return "Clipboard" + case .senseScope: return "Sense Scope" + case .projectScope: return "Project Scope" + case .webScope: return "Web Scope" + case .gitStatus: return "Git Status" + case .gitLog: return "Git Log" + case .file: return "File" + } + } +} + struct EditSendMessageCommandView: View { @Perception.Bindable var store: StoreOf + var attachmentStore: StoreOf var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { - Toggle("Extra System Prompt", isOn: $store.useExtraSystemPrompt) + Toggle("Extra Context", isOn: $store.useExtraSystemPrompt) EditableText(text: $store.extraSystemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { - Text("Prompt") + Text("Send immediately") EditableText(text: $store.prompt) } .padding(.vertical, 4) + + CustomCommandAttachmentPickerView(store: attachmentStore) + .padding(.vertical, 4) } } } @@ -146,7 +300,6 @@ struct EditPromptToCodeCommandView: View { var body: some View { WithPerceptionTracking { Toggle("Continuous Mode", isOn: $store.continuousMode) - Toggle("Generate Description", isOn: $store.generateDescription) VStack(alignment: .leading, spacing: 4) { Text("Extra Context") @@ -155,7 +308,7 @@ struct EditPromptToCodeCommandView: View { .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { - Text("Prompt") + Text("Instruction") EditableText(text: $store.prompt) } .padding(.vertical, 4) @@ -165,20 +318,24 @@ struct EditPromptToCodeCommandView: View { struct EditCustomChatCommandView: View { @Perception.Bindable var store: StoreOf + var attachmentStore: StoreOf var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { - Text("System Prompt") + Text("Topic") EditableText(text: $store.systemPrompt) } .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { - Text("Prompt") + Text("Send immediately") EditableText(text: $store.prompt) } .padding(.vertical, 4) + + CustomCommandAttachmentPickerView(store: attachmentStore) + .padding(.vertical, 4) } } } @@ -195,8 +352,8 @@ struct EditSingleRoundDialogCommandView: View { .padding(.vertical, 4) Picker(selection: $store.overwriteSystemPrompt) { - Text("Append to Default System Prompt").tag(false) - Text("Overwrite Default System Prompt").tag(true) + Text("Append to default system prompt").tag(false) + Text("Overwrite default system prompt").tag(true) } label: { Text("Mode") } @@ -208,7 +365,7 @@ struct EditSingleRoundDialogCommandView: View { } .padding(.vertical, 4) - Toggle("Receive Reply in Notification", isOn: $store.receiveReplyInNotification) + Toggle("Receive response in notification", isOn: $store.receiveReplyInNotification) Text( "You will be prompted to grant the app permission to send notifications for the first time." ) @@ -232,7 +389,9 @@ struct EditCustomCommandView_Preview: PreviewProvider { prompt: "Hello", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] as [CustomCommand.Attachment] )), reducer: { EditCustomCommand( diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 008a5cfc..aad43195 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -478,7 +478,9 @@ struct PseudoCommandHandler: CommandHandler { extraSystemPrompt: nil, prompt: message, useExtraSystemPrompt: nil - ) + ), + ignoreExistingAttachments: false, + attachments: [] ))).finish() } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 97605a06..930149ec 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -251,6 +251,8 @@ extension PromptToCodePanelView { struct ActionButtons: View { @Perception.Bindable var store: StoreOf + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.promptToCodeChatModelId) var defaultChatModelId var body: some View { WithPerceptionTracking { @@ -275,6 +277,8 @@ extension PromptToCodePanelView { ) .toggleStyle(.checkbox) } + + chatModelMenu } label: { Image(systemName: "gearshape.fill") .resizable() @@ -315,6 +319,53 @@ extension PromptToCodePanelView { } } } + + @ViewBuilder + var chatModelMenu: some View { + let allModels = chatModels + + Menu("Chat Model") { + Button(action: { + defaultChatModelId = "" + }) { + HStack { + Text("Same as chat feature") + if defaultChatModelId.isEmpty { + Image(systemName: "checkmark") + } + } + } + + if !allModels.contains(where: { $0.id == defaultChatModelId }), + !defaultChatModelId.isEmpty + { + Button(action: { + defaultChatModelId = allModels.first?.id ?? "" + }) { + HStack { + Text( + (allModels.first?.name).map { "\($0) (Default)" } + ?? "No model found" + ) + Image(systemName: "checkmark") + } + } + } + + ForEach(allModels, id: \.id) { model in + Button(action: { + defaultChatModelId = model.id + }) { + HStack { + Text(model.name) + if model.id == defaultChatModelId { + Image(systemName: "checkmark") + } + } + } + } + } + } } struct AcceptButton: View { @@ -1011,3 +1062,4 @@ extension PromptToCodePanelView { .fixedSize(horizontal: false, vertical: true) .frame(width: 500, height: 500, alignment: .center) } + diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index e8391443..c98658c7 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -46,16 +46,22 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.projectID = projectID } } - + public struct OpenAICompatibleInfo: Codable, Equatable { @FallbackDecoding public var enforceMessageOrder: Bool + @FallbackDecoding + public var supportsMultipartMessageContent: Bool - public init(enforceMessageOrder: Bool = false) { + public init( + enforceMessageOrder: Bool = false, + supportsMultipartMessageContent: Bool = true + ) { self.enforceMessageOrder = enforceMessageOrder + self.supportsMultipartMessageContent = supportsMultipartMessageContent } } - + public struct GoogleGenerativeAIInfo: Codable, Equatable { @FallbackDecoding public var apiVersion: String @@ -64,21 +70,21 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.apiVersion = apiVersion } } - + public struct CustomHeaderInfo: Codable, Equatable { public struct HeaderField: Codable, Equatable { public var key: String public var value: String - + public init(key: String, value: String) { self.key = key self.value = value } } - + @FallbackDecoding public var headers: [HeaderField] - + public init(headers: [HeaderField] = []) { self.headers = headers } @@ -203,3 +209,7 @@ public struct EmptyChatModelOpenAICompatibleInfo: FallbackValueProvider { public struct EmptyChatModelCustomHeaderInfo: FallbackValueProvider { public static var defaultValue: ChatModel.Info.CustomHeaderInfo { .init() } } + +public struct EmptyTrue: FallbackValueProvider { + public static var defaultValue: Bool { true } +} diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift index f93d2bb8..1a889dfe 100644 --- a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift @@ -10,6 +10,8 @@ import XcodeInspector public class CodeiumChatTab: ChatTab { public static var name: String { "Codeium Chat" } + public static var isDefaultChatTabReplacement: Bool { false } + public static var canHandleOpenChatCommand: Bool { true } struct RestorableState: Codable {} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index bf7d24e4..590cbbe4 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -630,9 +630,6 @@ extension InitializingServer: GitHubCopilotLSP { } private func xcodeVersion() async -> String? { - if let xcode = await XcodeInspector.shared.safe.latestActiveXcode { - return xcode.version - } let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") process.arguments = ["xcodebuild", "-version"] diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 858f3149..1ad0c4d9 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -299,6 +299,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI requestBody, endpoint: endpoint, enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder, + supportsMultipartMessageContent: model.info.openAICompatibleInfo + .supportsMultipartMessageContent, canUseTool: model.info.supportsFunctionCalling, supportsImage: model.info.supportsImage, supportsAudio: model.info.supportsAudio @@ -336,7 +338,10 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } let decoder = JSONDecoder() let error = try? decoder.decode(CompletionAPIError.self, from: data) - throw error ?? ChatGPTServiceError.responseInvalid + throw error ?? ChatGPTServiceError.otherError( + text + + "\n\nPlease check your model settings, some capabilities may not be supported by the model." + ) } let stream = ResponseStream(result: result) { @@ -651,25 +656,43 @@ extension OpenAIChatCompletionsService.RequestBody { _ message: inout Message, content: String, images: [ChatCompletionsRequestBody.Message.Image], - audios: [ChatCompletionsRequestBody.Message.Audio] + audios: [ChatCompletionsRequestBody.Message.Audio], + supportsMultipartMessageContent: Bool ) { - switch message.role { - case .system, .assistant, .user: - let newParts = Self.convertContentPart( - content: content, - images: images, - audios: audios - ) - if case let .contentParts(existingParts) = message.content { - message.content = .contentParts(existingParts + newParts) - } else { - message.content = .contentParts(newParts) + if supportsMultipartMessageContent { + switch message.role { + case .system, .assistant, .user: + let newParts = Self.convertContentPart( + content: content, + images: images, + audios: audios + ) + if case let .contentParts(existingParts) = message.content { + message.content = .contentParts(existingParts + newParts) + } else { + message.content = .contentParts(newParts) + } + case .tool, .function: + if case let .text(existingText) = message.content { + message.content = .text(existingText + "\n\n" + content) + } else { + message.content = .text(content) + } } - case .tool, .function: - if case let .text(existingText) = message.content { - message.content = .text(existingText + "\n\n" + content) - } else { - message.content = .text(content) + } else { + switch message.role { + case .system, .assistant, .user: + if case let .text(existingText) = message.content { + message.content = .text(existingText + "\n\n" + content) + } else { + message.content = .text(content) + } + case .tool, .function: + if case let .text(existingText) = message.content { + message.content = .text(existingText + "\n\n" + content) + } else { + message.content = .text(content) + } } } } @@ -678,6 +701,7 @@ extension OpenAIChatCompletionsService.RequestBody { _ body: ChatCompletionsRequestBody, endpoint: URL, enforceMessageOrder: Bool, + supportsMultipartMessageContent: Bool, canUseTool: Bool, supportsImage: Bool, supportsAudio: Bool @@ -702,7 +726,7 @@ extension OpenAIChatCompletionsService.RequestBody { model = body.model // Special case for Claude through OpenRouter - + if endpoint.absoluteString.contains("openrouter.ai"), model.hasPrefix("anthropic/") { var body = body body.model = model.replacingOccurrences(of: "anthropic/", with: "") @@ -731,7 +755,7 @@ extension OpenAIChatCompletionsService.RequestBody { } return } - + // Enforce message order if enforceMessageOrder { @@ -752,16 +776,22 @@ extension OpenAIChatCompletionsService.RequestBody { &nonSystemMessages[nonSystemMessages.endIndex - 1], content: message.content, images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] + audios: supportsAudio ? message.audios : [], + supportsMultipartMessageContent: supportsMultipartMessageContent ) } else { nonSystemMessages.append(.init( role: .tool, - content: .contentParts(Self.convertContentPart( - content: message.content, - images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] - )), + content: { + if supportsMultipartMessageContent { + return .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + } + return .text(message.content) + }(), tool_calls: message.toolCalls?.map { tool in MessageToolCall( id: tool.id, @@ -780,16 +810,22 @@ extension OpenAIChatCompletionsService.RequestBody { &nonSystemMessages[nonSystemMessages.endIndex - 1], content: message.content, images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] + audios: supportsAudio ? message.audios : [], + supportsMultipartMessageContent: supportsMultipartMessageContent ) } else { nonSystemMessages.append(.init( role: .assistant, - content: .contentParts(Self.convertContentPart( - content: message.content, - images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] - )) + content: { + if supportsMultipartMessageContent { + return .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + } + return .text(message.content) + }() )) } case (.user, _): @@ -798,16 +834,22 @@ extension OpenAIChatCompletionsService.RequestBody { &nonSystemMessages[nonSystemMessages.endIndex - 1], content: message.content, images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] + audios: supportsAudio ? message.audios : [], + supportsMultipartMessageContent: supportsMultipartMessageContent ) } else { nonSystemMessages.append(.init( role: .user, - content: .contentParts(Self.convertContentPart( - content: message.content, - images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] - )), + content: { + if supportsMultipartMessageContent { + return .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + } + return .text(message.content) + }(), name: message.name, tool_call_id: message.toolCallId )) @@ -817,15 +859,25 @@ extension OpenAIChatCompletionsService.RequestBody { messages = [ .init( role: .system, - content: .contentParts(systemPrompts) + content: { + if supportsMultipartMessageContent { + return .contentParts(systemPrompts) + } + let textParts = systemPrompts.compactMap { + if case let .text(text) = $0 { return text.text } + return nil + } + + return .text(textParts.joined(separator: "\n\n")) + }() ), ] + nonSystemMessages return } - + // Default - + messages = body.messages.map { message in .init( role: { @@ -840,11 +892,16 @@ extension OpenAIChatCompletionsService.RequestBody { return .tool } }(), - content: .contentParts(Self.convertContentPart( - content: message.content, - images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] - )), + content: { + if supportsMultipartMessageContent { + return .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + } + return .text(message.content) + }(), name: message.name, tool_calls: message.toolCalls?.map { tool in MessageToolCall( diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 5ad46fda..22aa716a 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -587,7 +587,9 @@ public extension UserDefaultPreferenceKeys { extraSystemPrompt: "", prompt: "Explain the selected code concisely, step-by-step.", useExtraSystemPrompt: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "BuiltInCustomCommandAddDocumentationToSelection", @@ -597,7 +599,9 @@ public extension UserDefaultPreferenceKeys { prompt: "Add documentation on top of the code. Use triple slash if the language supports it.", continuousMode: false, generateDescription: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), .init( commandId: "BuiltInCustomCommandSendCodeToChat", @@ -610,7 +614,9 @@ public extension UserDefaultPreferenceKeys { ``` """, useExtraSystemPrompt: true - ) + ), + ignoreExistingAttachments: false, + attachments: [] ), ], key: "CustomCommands") } diff --git a/Tool/Sources/Preferences/Types/CustomCommand.swift b/Tool/Sources/Preferences/Types/CustomCommand.swift index b462e8a3..38c837e0 100644 --- a/Tool/Sources/Preferences/Types/CustomCommand.swift +++ b/Tool/Sources/Preferences/Types/CustomCommand.swift @@ -30,27 +30,63 @@ public struct CustomCommand: Codable, Equatable { ) } + public struct Attachment: Codable, Equatable { + public enum Kind: Codable, Equatable, Hashable { + case activeDocument + case debugArea + case clipboard + case senseScope + case projectScope + case webScope + case gitStatus + case gitLog + case file(path: String) + } + public var kind: Kind + public init(kind: Kind) { + self.kind = kind + } + } + public var id: String { commandId ?? legacyId } public var commandId: String? public var name: String public var feature: Feature - public init(commandId: String, name: String, feature: Feature) { + public var ignoreExistingAttachments: Bool + public var attachments: [Attachment] + + public init( + commandId: String, + name: String, + feature: Feature, + ignoreExistingAttachments: Bool, + attachments: [Attachment] + ) { self.commandId = commandId self.name = name self.feature = feature + self.ignoreExistingAttachments = ignoreExistingAttachments + self.attachments = attachments } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) commandId = try container.decodeIfPresent(String.self, forKey: .commandId) name = try container.decode(String.self, forKey: .name) - feature = (try? container - .decode(CustomCommand.Feature.self, forKey: .feature)) ?? .chatWithSelection( - extraSystemPrompt: "", - prompt: "", - useExtraSystemPrompt: false - ) + feature = ( + try? container + .decode(CustomCommand.Feature.self, forKey: .feature) + ) ?? .chatWithSelection( + extraSystemPrompt: "", + prompt: "", + useExtraSystemPrompt: false + ) + ignoreExistingAttachments = try container.decodeIfPresent( + Bool.self, + forKey: .ignoreExistingAttachments + ) ?? false + attachments = try container.decodeIfPresent([Attachment].self, forKey: .attachments) ?? [] } var legacyId: String { diff --git a/Version.xcconfig b/Version.xcconfig index 98ceb482..44c36d4a 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,4 @@ -APP_VERSION = 0.35.3 -APP_BUILD = 430 +APP_VERSION = 0.35.4 +APP_BUILD = 437 +RELEASE_CHANNEL = +RELEASE_NUMBER = 1