From e9a1f72d40d65b824bc6e29ebf96c4b1c70fe67c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 23 Aug 2023 15:33:42 +0800 Subject: [PATCH 01/81] Add top bar to prompt to code panel --- .../GUI/PromptToCodeProvider+Service.swift | 3 +- .../Providers/PromptToCodeProvider.swift | 20 +- .../SuggestionWidget/SharedPanelView.swift | 7 +- .../PromptToCodePanel.swift | 478 ++++++++++-------- 4 files changed, 290 insertions(+), 218 deletions(-) diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift index 73187619..1898c480 100644 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift @@ -13,8 +13,7 @@ extension PromptToCodeProvider { code: service.code, language: service.language.rawValue, description: "", - startLineIndex: service.selectionRange.start.line, - startLineColumn: service.selectionRange.start.character, + attachedToRange: service.selectionRange, name: name ) diff --git a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift b/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift index 63e7f3f7..ecc36c36 100644 --- a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift @@ -1,16 +1,18 @@ import Foundation +import SuggestionModel import SwiftUI public final class PromptToCodeProvider: ObservableObject { let id = UUID() let name: String? - + @Published public var code: String @Published public var language: String @Published public var description: String @Published public var isResponding: Bool - @Published public var startLineIndex: Int - @Published public var startLineColumn: Int + public var startLineIndex: Int { attachedToRange?.start.line ?? 0 } + public var startLineColumn: Int { attachedToRange?.start.character ?? 0 } + @Published public var attachedToRange: CursorRange? @Published public var requirement: String @Published public var errorMessage: String @Published public var canRevert: Bool @@ -28,8 +30,7 @@ public final class PromptToCodeProvider: ObservableObject { language: String = "", description: String = "", isResponding: Bool = false, - startLineIndex: Int = 0, - startLineColumn: Int = 0, + attachedToRange: CursorRange? = nil, requirement: String = "", errorMessage: String = "", canRevert: Bool = false, @@ -46,8 +47,7 @@ public final class PromptToCodeProvider: ObservableObject { self.language = language self.description = description self.isResponding = isResponding - self.startLineIndex = startLineIndex - self.startLineColumn = startLineColumn + self.attachedToRange = attachedToRange self.requirement = requirement self.errorMessage = errorMessage self.canRevert = canRevert @@ -65,11 +65,14 @@ public final class PromptToCodeProvider: ObservableObject { onRevertTapped() errorMessage = "" } + func stopResponding() { onStopRespondingTap() errorMessage = "" } + func cancel() { onCancelTapped() } + func sendRequirement() { guard !isResponding else { return } guard !requirement.isEmpty else { return } @@ -79,6 +82,7 @@ public final class PromptToCodeProvider: ObservableObject { } func acceptSuggestion() { onAcceptSuggestionTapped() } - + func toggleContinuous() { onContinuousToggleClick() } } + diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index bb039c9d..48d4f744 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -97,7 +97,8 @@ struct SharedPanelView: View { } struct CommandButtonStyle: ButtonStyle { - let color: Color + var color: Color + var cornerRadius: Double = 4 func makeBody(configuration: Configuration) -> some View { configuration.label @@ -105,12 +106,12 @@ struct CommandButtonStyle: ButtonStyle { .padding(.horizontal, 8) .foregroundColor(.white) .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(color.opacity(configuration.isPressed ? 0.8 : 1)) .animation(.easeOut(duration: 0.1), value: configuration.isPressed) ) .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 46407166..f56c29de 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -7,250 +7,315 @@ struct PromptToCodePanel: View { var body: some View { VStack(spacing: 0) { - PromptToCodePanelContent(provider: provider) - .overlay(alignment: .topTrailing) { - if !provider.code.isEmpty { - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(provider.code, forType: .string) - } - .padding(.trailing, 2) - .padding(.top, 2) - } - } + TopBar(provider: provider) + + Content(provider: provider) .overlay(alignment: .bottom) { - HStack { - if provider.isResponding { - Button(action: { - provider.stopResponding() - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop") - } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - } + ActionBar(provider: provider) + .padding(.bottom, 8) + } - let isRespondingButCodeIsReady = provider.isResponding - && !provider.code.isEmpty - && !provider.description.isEmpty + Divider() - if !provider.isResponding || isRespondingButCodeIsReady { - HStack { - Toggle( - "Continuous Mode", - isOn: .init( - get: { provider.isContinuous }, - set: { _ in provider.toggleContinuous() } - ) - ) - .toggleStyle(.checkbox) + Toolbar(provider: provider) + } + .background(.ultraThickMaterial) + .xcodeStyleFrame() + } +} - Button(action: { - provider.cancel() - }) { - Text("Cancel") - } - .buttonStyle(CommandButtonStyle(color: .gray)) - .keyboardShortcut("w", modifiers: [.command]) +extension PromptToCodePanel { + struct TopBar: View { + @ObservedObject var provider: PromptToCodeProvider - if !provider.code.isEmpty { - Button(action: { - provider.acceptSuggestion() - }) { - Text("Accept(⌘ + ⏎)") - } - .buttonStyle(CommandButtonStyle(color: .indigo)) - .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) - } - } - .padding(8) + var body: some View { + HStack { + Button(action: { + provider.acceptSuggestion() + }) { + let attachedToRange = provider.attachedToRange + let isAttached = attachedToRange != nil + let color: Color = isAttached ? .indigo : .secondary.opacity(0.6) + HStack(spacing: 4) { + Image(systemName: isAttached ? "bandage" : "character.cursor.ibeam") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(.white) .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) + color, + in: RoundedRectangle(cornerRadius: 4, style: .continuous) ) - .overlay { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } + + Text(attachedToRange?.description ?? "text cursor") + .foregroundColor(.primary) } - .padding(.bottom, 8) + .padding(2) + .padding(.trailing, 4) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke( + color, + lineWidth: 1 + ) + } + .background { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(color.opacity(0.2)) + } + .padding(2) } + .buttonStyle(.plain) + .keyboardShortcut("j", modifiers: [.command]) - PromptToCodePanelToolbar(provider: provider) + Spacer() + + if !provider.code.isEmpty { + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(provider.code, forType: .string) + } + } + } + .padding(2) } - .background(Color.contentBackground) - .xcodeStyleFrame() } -} - -struct PromptToCodePanelContent: View { - @ObservedObject var provider: PromptToCodeProvider - @Environment(\.colorScheme) var colorScheme - @AppStorage(\.suggestionCodeFontSize) var fontSize - var body: some View { - CustomScrollView { - VStack(spacing: 0) { - Spacer(minLength: 60) + struct ActionBar: View { + @ObservedObject var provider: PromptToCodeProvider - if !provider.errorMessage.isEmpty { - Text(provider.errorMessage) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) + var body: some View { + HStack { + if provider.isResponding { + Button(action: { + provider.stopResponding() + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop") + } + .padding(8) .background( - Color.red, - in: RoundedRectangle(cornerRadius: 8, style: .continuous) + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) ) - .scaleEffect(x: 1, y: -1, anchor: .center) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.plain) } - if !provider.description.isEmpty { - Markdown(provider.description) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - }) - .padding() - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } + let isRespondingButCodeIsReady = provider.isResponding + && !provider.code.isEmpty + && !provider.description.isEmpty - if provider.code.isEmpty { - Text( - provider.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." - ) - .foregroundColor(.secondary) - .padding() - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } else { - CodeBlock( - code: provider.code, - language: provider.language, - startLineIndex: provider.startLineIndex, - colorScheme: colorScheme, - firstLinePrecedingSpaceCount: provider.startLineColumn, - fontSize: fontSize + if !provider.isResponding || isRespondingButCodeIsReady { + HStack { + Toggle( + "Continuous Mode", + isOn: .init( + get: { provider.isContinuous }, + set: { _ in provider.toggleContinuous() } + ) + ) + .toggleStyle(.checkbox) + + Button(action: { + provider.cancel() + }) { + Text("Cancel") + } + .buttonStyle(CommandButtonStyle(color: .gray)) + .keyboardShortcut("w", modifiers: [.command]) + + if !provider.code.isEmpty { + Button(action: { + provider.acceptSuggestion() + }) { + Text("Accept(⌘ + ⏎)") + } + .buttonStyle(CommandButtonStyle(color: .indigo)) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + } + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) ) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } } + } + } + } + + struct Content: View { + @ObservedObject var provider: PromptToCodeProvider + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.suggestionCodeFontSize) var fontSize + + var body: some View { + CustomScrollView { + VStack(spacing: 0) { + Spacer(minLength: 60) - if let name = provider.name { - Text(name) - .font(.footnote) + if !provider.errorMessage.isEmpty { + Text(provider.errorMessage) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Color.red, + in: RoundedRectangle(cornerRadius: 4, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + } + .scaleEffect(x: 1, y: -1, anchor: .center) + } + + if !provider.description.isEmpty { + Markdown(provider.description) + .textSelection(.enabled) + .markdownTheme(.gitHub.text { + BackgroundColor(Color.clear) + }) + .padding() + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + + if provider.code.isEmpty { + Text( + provider.isResponding + ? "Thinking..." + : "Enter your requirement to generate code." + ) .foregroundColor(.secondary) - .padding(.top, 12) + .padding() + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) .scaleEffect(x: 1, y: -1, anchor: .center) + } else { + CodeBlock( + code: provider.code, + language: provider.language, + startLineIndex: provider.startLineIndex, + colorScheme: colorScheme, + firstLinePrecedingSpaceCount: provider.startLineColumn, + fontSize: fontSize + ) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + + if let name = provider.name { + Text(name) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.top, 12) + .scaleEffect(x: 1, y: -1, anchor: .center) + } } } + .scaleEffect(x: 1, y: -1, anchor: .center) } - .scaleEffect(x: 1, y: -1, anchor: .center) } -} -struct PromptToCodePanelToolbar: View { - @ObservedObject var provider: PromptToCodeProvider - @FocusState var isInputAreaFocused: Bool + struct Toolbar: View { + @ObservedObject var provider: PromptToCodeProvider + @FocusState var isInputAreaFocused: Bool - var body: some View { - HStack { - Button(action: { - provider.revert() - }) { - Group { - Image(systemName: "arrow.uturn.backward") + var body: some View { + HStack { + Button(action: { + provider.revert() + }) { + Group { + Image(systemName: "arrow.uturn.backward") + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle() + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } } - .padding(6) + .buttonStyle(.plain) + .disabled(provider.isResponding || !provider.canRevert) + + HStack(spacing: 0) { + ZStack(alignment: .center) { + // a hack to support dynamic height of TextEditor + Text(provider.requirement.isEmpty ? "Hi" : provider.requirement).opacity(0) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, maxHeight: 400) + .padding(.top, 1) + .padding(.bottom, 2) + .padding(.horizontal, 4) + + CustomTextEditor( + text: $provider.requirement, + font: .systemFont(ofSize: 14), + onSubmit: { provider.sendRequirement() } + ) + .padding(.top, 1) + .padding(.bottom, -1) + } + .focused($isInputAreaFocused) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + + Button(action: { + provider.sendRequirement() + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(provider.isResponding) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + } + .frame(maxWidth: .infinity) .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) } .overlay { - Circle() + RoundedRectangle(cornerRadius: 6) .stroke(Color(nsColor: .controlColor), lineWidth: 1) } - } - .buttonStyle(.plain) - .disabled(provider.isResponding || !provider.canRevert) - - HStack(spacing: 0) { - ZStack(alignment: .center) { - // a hack to support dynamic height of TextEditor - Text(provider.requirement.isEmpty ? "Hi" : provider.requirement).opacity(0) - .font(.system(size: 14)) - .frame(maxWidth: .infinity, maxHeight: 400) - .padding(.top, 1) - .padding(.bottom, 2) - .padding(.horizontal, 4) - - CustomTextEditor( - text: $provider.requirement, - font: .systemFont(ofSize: 14), - onSubmit: { provider.sendRequirement() } - ) - .padding(.top, 1) - .padding(.bottom, -1) - } - .focused($isInputAreaFocused) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - - Button(action: { - provider.sendRequirement() - }) { - Image(systemName: "paperplane.fill") - .padding(8) + .background { + Button(action: { + provider.requirement += "\n" + }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) } - .buttonStyle(.plain) - .disabled(provider.isResponding) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) } - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) + .onAppear { + isInputAreaFocused = true } - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - .background { - Button(action: { - provider.requirement += "\n" - }) { - EmptyView() - } - .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) - } - } - .onAppear { - isInputAreaFocused = true + .padding(8) + .background(.ultraThickMaterial) } - .padding(8) - .background(.ultraThickMaterial) } } // MARK: - Previews -struct PromptToCodePanel_Bright_Preview: PreviewProvider { +struct PromptToCodePanel_Preview: PreviewProvider { static var previews: some View { PromptToCodePanel(provider: PromptToCodeProvider( code: """ @@ -263,14 +328,17 @@ struct PromptToCodePanel_Bright_Preview: PreviewProvider { language: "swift", description: "Hello world", isResponding: false, - startLineIndex: 8 + attachedToRange: .init( + start: .init(line: 8, character: 0), + end: .init(line: 12, character: 2) + ), + name: "Generate Code" )) - .preferredColorScheme(.light) .frame(width: 450, height: 400) } } -struct PromptToCodePanel_Error_Bright_Preview: PreviewProvider { +struct PromptToCodePanel_Error_Detached_Preview: PreviewProvider { static var previews: some View { PromptToCodePanel(provider: PromptToCodeProvider( code: """ @@ -283,10 +351,10 @@ struct PromptToCodePanel_Error_Bright_Preview: PreviewProvider { language: "swift", description: "Hello world", isResponding: false, - startLineIndex: 8, - errorMessage: "Error" + attachedToRange: nil, + errorMessage: "Error", + name: "Generate Code" )) - .preferredColorScheme(.light) .frame(width: 450, height: 400) } } From cbf25dbe70c60ad07acfc66f4e8c4fc41f5de891 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 23 Aug 2023 15:59:23 +0800 Subject: [PATCH 02/81] Support detaching prompt to code from selection range --- .../PromptToCodeService/PromptToCodeService.swift | 14 ++------------ .../Service/GUI/PromptToCodeProvider+Service.swift | 11 ++++++++++- .../WindowBaseCommandHandler.swift | 13 +++++++++---- .../Providers/PromptToCodeProvider.swift | 7 ++++++- .../SuggestionPanelContent/PromptToCodePanel.swift | 2 +- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Core/Sources/PromptToCodeService/PromptToCodeService.swift b/Core/Sources/PromptToCodeService/PromptToCodeService.swift index a293629f..5be4a1a5 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeService.swift @@ -39,8 +39,8 @@ public final class PromptToCodeService: ObservableObject { @Published public var isResponding: Bool = false @Published public var description: String = "" @Published public var isContinuous = false + @Published public var selectionRange: CursorRange? public var canRevert: Bool { history != .empty } - public var selectionRange: CursorRange public var language: CodeLanguage public var indentSize: Int public var usesTabsForIndentation: Bool @@ -52,7 +52,7 @@ public final class PromptToCodeService: ObservableObject { public init( code: String, - selectionRange: CursorRange, + selectionRange: CursorRange?, language: CodeLanguage, identSize: Int, usesTabsForIndentation: Bool, @@ -122,16 +122,6 @@ public final class PromptToCodeService: ObservableObject { self.description = description } - public func generateCompletion() -> CodeSuggestion { - .init( - text: code, - position: selectionRange.start, - uuid: UUID().uuidString, - range: selectionRange, - displayText: code - ) - } - public func stopResponding() { runningAPI?.stopResponding() isResponding = false diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift index 1898c480..631fd2df 100644 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift @@ -25,6 +25,7 @@ extension PromptToCodeProvider { service.$isContinuous.sink(receiveValue: set(\.isContinuous)).store(in: &cancellables) service.$history.map { $0 != .empty } .sink(receiveValue: set(\.canRevert)).store(in: &cancellables) + service.$selectionRange.sink(receiveValue: set(\.attachedToRange)).store(in: &cancellables) onCancelTapped = { [cancellables] in _ = cancellables @@ -59,7 +60,7 @@ extension PromptToCodeProvider { let handler = PseudoCommandHandler() await handler.acceptSuggestion() if let app = ActiveApplicationMonitor.shared.previousApp, - app.isXcode, + app.isXcode, !(self?.isContinuous ?? false) { try await Task.sleep(nanoseconds: 200_000_000) @@ -71,6 +72,14 @@ extension PromptToCodeProvider { onContinuousToggleClick = { service.isContinuous.toggle() } + + onToggleAttachOrDetachToCode = { + if service.selectionRange != nil { + service.selectionRange = nil + } else { + // reset to selected or focused range. + } + } } func set(_ keyPath: WritableKeyPath) -> (T) -> Void { diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 5707ed32..89d2debb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -167,11 +167,16 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { let dataSource = Service.shared.guiController.widgetDataSource if let service = await dataSource.promptToCodes[fileURL]?.promptToCodeService { + let rangeStart = service.selectionRange?.start ?? editor.cursorPosition + let suggestion = CodeSuggestion( text: service.code, - position: service.selectionRange.start, + position: rangeStart, uuid: UUID().uuidString, - range: service.selectionRange, + range: service.selectionRange ?? .init( + start: editor.cursorPosition, + end: editor.cursorPosition + ), displayText: service.code ) @@ -184,7 +189,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { if service.isContinuous { service.selectionRange = .init( - start: service.selectionRange.start, + start: rangeStart, end: cursorPosition ) presenter.presentPromptToCode(fileURL: fileURL) @@ -195,7 +200,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return .init( content: String(lines.joined(separator: "")), - newSelection: .init(start: service.selectionRange.start, end: cursorPosition), + newSelection: .init(start: rangeStart, end: cursorPosition), modifications: extraInfo.modifications ) } else if let acceptedSuggestion = workspace.acceptSuggestion( diff --git a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift b/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift index ecc36c36..b70547fc 100644 --- a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift @@ -24,6 +24,7 @@ public final class PromptToCodeProvider: ObservableObject { public var onAcceptSuggestionTapped: () -> Void public var onRequirementSent: (String) -> Void public var onContinuousToggleClick: () -> Void + public var onToggleAttachOrDetachToCode: () -> Void public init( code: String = "", @@ -41,7 +42,8 @@ public final class PromptToCodeProvider: ObservableObject { onCancelTapped: @escaping () -> Void = {}, onAcceptSuggestionTapped: @escaping () -> Void = {}, onRequirementSent: @escaping (String) -> Void = { _ in }, - onContinuousToggleClick: @escaping () -> Void = {} + onContinuousToggleClick: @escaping () -> Void = {}, + onToggleAttachOrDetachToCode: @escaping () -> Void = {} ) { self.code = code self.language = language @@ -59,6 +61,7 @@ public final class PromptToCodeProvider: ObservableObject { self.onAcceptSuggestionTapped = onAcceptSuggestionTapped self.onRequirementSent = onRequirementSent self.onContinuousToggleClick = onContinuousToggleClick + self.onToggleAttachOrDetachToCode = onToggleAttachOrDetachToCode } func revert() { @@ -84,5 +87,7 @@ public final class PromptToCodeProvider: ObservableObject { func acceptSuggestion() { onAcceptSuggestionTapped() } func toggleContinuous() { onContinuousToggleClick() } + + func toggleAttachOrDetachToCode() { onToggleAttachOrDetachToCode() } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index f56c29de..65d8904a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -31,7 +31,7 @@ extension PromptToCodePanel { var body: some View { HStack { Button(action: { - provider.acceptSuggestion() + provider.toggleAttachOrDetachToCode() }) { let attachedToRange = provider.attachedToRange let isAttached = attachedToRange != nil From 832d7b7020646b672c497f8a0dd037ca5f36e823 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 23 Aug 2023 16:32:04 +0800 Subject: [PATCH 03/81] Add a command dedicated to accept prompt to code suggestion --- Copilot for Xcode.xcodeproj/project.pbxproj | 4 + Core/Sources/Client/AsyncXPCService.swift | 10 ++ .../GUI/PromptToCodeProvider+Service.swift | 2 +- .../PseudoCommandHandler.swift | 151 ++++++++++++------ .../SuggestionCommandHandler.swift | 2 + .../WindowBaseCommandHandler.swift | 56 ++++--- Core/Sources/Service/XPCService.swift | 9 ++ .../XPCShared/XPCServiceProtocol.swift | 4 + .../AcceptPromptToCodeCommand.swift | 31 ++++ EditorExtension/SourceEditorExtension.swift | 21 ++- 10 files changed, 212 insertions(+), 78 deletions(-) create mode 100644 EditorExtension/AcceptPromptToCodeCommand.swift diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 50fd7406..759f2944 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 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 */; }; + C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */; }; C81291D72994FE6900196E12 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C81291D52994FE6900196E12 /* Main.storyboard */; }; C814588F2939EFDC00135263 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C814588E2939EFDC00135263 /* Cocoa.framework */; }; C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458932939EFDC00135263 /* SourceEditorExtension.swift */; }; @@ -139,6 +140,7 @@ 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 = ""; }; + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptPromptToCodeCommand.swift; sourceTree = ""; }; C81291D52994FE6900196E12 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; C81291D92994FE7900196E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C814588C2939EFDC00135263 /* Copilot.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Copilot.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -241,6 +243,7 @@ C8520300293C4D9000460097 /* Helpers.swift */, C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */, C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */, + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */, C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */, C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */, C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */, @@ -557,6 +560,7 @@ C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */, C8520301293C4D9000460097 /* Helpers.swift in Sources */, C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */, + C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */, C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */, C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */, C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, diff --git a/Core/Sources/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift index 8398e464..bd366d0f 100644 --- a/Core/Sources/Client/AsyncXPCService.swift +++ b/Core/Sources/Client/AsyncXPCService.swift @@ -85,6 +85,16 @@ public struct AsyncXPCService { { $0.getRealtimeSuggestedCode } ) } + + public func getPromptToCodeAcceptedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + connection, + editorContent, + { $0.getPromptToCodeAcceptedCode } + ) + } public func toggleRealtimeSuggestion() async throws { try await withXPCServiceConnected(connection: connection) { diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift index 631fd2df..ff3bc418 100644 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift @@ -58,7 +58,7 @@ extension PromptToCodeProvider { onAcceptSuggestionTapped = { [weak self] in Task { [weak self] in let handler = PseudoCommandHandler() - await handler.acceptSuggestion() + await handler.acceptPromptToCode() if let app = ActiveApplicationMonitor.shared.previousApp, app.isXcode, !(self?.isContinuous ?? false) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index e40f8261..99a2e269 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -161,6 +161,45 @@ struct PseudoCommandHandler { } } + func acceptPromptToCode() async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + try await Environment.triggerAction("Accept Prompt to Code") + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = application.focusedElement, + focusElement.description == "Source Editor" + else { return } + guard let (content, lines, _, cursorPosition) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") + return + } + let handler = WindowBaseCommandHandler() + do { + guard let result = try await handler.acceptPromptToCode(editor: .init( + content: content, + lines: lines, + uti: "", + cursorPosition: cursorPosition, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) else { return } + + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) + } catch { + PresentInWindowSuggestionPresenter().presentError(error) + } + } + } + func acceptSuggestion() async { do { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { @@ -174,12 +213,7 @@ struct PseudoCommandHandler { guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" else { return } - guard let ( - content, - lines, - _, - cursorPosition - ) = await getFileContent(sourceEditor: nil) + guard let (content, lines, _, cursorPosition) = await getFileContent(sourceEditor: nil) else { PresentInWindowSuggestionPresenter() .presentErrorMessage("Unable to get file content.") @@ -198,52 +232,7 @@ struct PseudoCommandHandler { usesTabsForIndentation: false )) else { return } - let oldPosition = focusElement.selectedTextRange - let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue - - let error = AXUIElementSetAttributeValue( - focusElement, - kAXValueAttribute as CFString, - result.content as CFTypeRef - ) - - if error != AXError.success { - PresentInWindowSuggestionPresenter() - .presentErrorMessage("Fail to set editor content.") - } - - if let selection = result.newSelection { - var range = convertCursorRangeToRange(selection, in: result.content) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } - } else if let oldPosition { - var range = CFRange( - location: oldPosition.lowerBound, - length: 0 - ) - if let value = AXValueCreate(.cfRange, &range) { - AXUIElementSetAttributeValue( - focusElement, - kAXSelectedTextRangeAttribute as CFString, - value - ) - } - } - - if let oldScrollPosition, - let scrollBar = focusElement.parent?.verticalScrollBar - { - AXUIElementSetAttributeValue( - scrollBar, - kAXValueAttribute as CFString, - oldScrollPosition as CFTypeRef - ) - } + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) } catch { PresentInWindowSuggestionPresenter().presentError(error) } @@ -252,6 +241,64 @@ struct PseudoCommandHandler { } extension PseudoCommandHandler { + /// When Xcode commands are not available, we can fallback to directly + /// set the value of the editor with Accessibility API. + func injectUpdatedCodeWithAccessibilityAPI( + _ result: UpdatedContent, + focusElement: AXUIElement + ) throws { + let oldPosition = focusElement.selectedTextRange + let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue + + let error = AXUIElementSetAttributeValue( + focusElement, + kAXValueAttribute as CFString, + result.content as CFTypeRef + ) + + if error != AXError.success { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Fail to set editor content.") + } + + // recover selection range + + if let selection = result.newSelection { + var range = convertCursorRangeToRange(selection, in: result.content) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } else if let oldPosition { + var range = CFRange( + location: oldPosition.lowerBound, + length: 0 + ) + if let value = AXValueCreate(.cfRange, &range) { + AXUIElementSetAttributeValue( + focusElement, + kAXSelectedTextRangeAttribute as CFString, + value + ) + } + } + + // recover scroll position + + if let oldScrollPosition, + let scrollBar = focusElement.parent?.verticalScrollBar + { + AXUIElementSetAttributeValue( + scrollBar, + kAXValueAttribute as CFString, + oldScrollPosition as CFTypeRef + ) + } + } + func getFileContent(sourceEditor: AXUIElement?) async -> ( content: String, diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift index aac57389..35eec4fb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -13,6 +13,8 @@ protocol SuggestionCommandHandler { @ServiceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? @ServiceActor func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 89d2debb..d83ec755 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -166,9 +166,45 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { let dataSource = Service.shared.guiController.widgetDataSource + if let acceptedSuggestion = workspace.acceptSuggestion( + forFileAt: fileURL, + editor: editor + ) { + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: acceptedSuggestion, + extraInfo: &extraInfo + ) + + presenter.discardSuggestion(fileURL: fileURL) + + return .init( + content: String(lines.joined(separator: "")), + newSelection: .cursor(cursorPosition), + modifications: extraInfo.modifications + ) + } + + return nil + } + + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { + presenter.markAsProcessing(true) + defer { presenter.markAsProcessing(false) } + + let fileURL = try await Environment.fetchCurrentFileURL() + + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + let dataSource = Service.shared.guiController.widgetDataSource + if let service = await dataSource.promptToCodes[fileURL]?.promptToCodeService { let rangeStart = service.selectionRange?.start ?? editor.cursorPosition - + let suggestion = CodeSuggestion( text: service.code, position: rangeStart, @@ -203,24 +239,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { newSelection: .init(start: rangeStart, end: cursorPosition), modifications: extraInfo.modifications ) - } else if let acceptedSuggestion = workspace.acceptSuggestion( - forFileAt: fileURL, - editor: editor - ) { - injector.acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursorPosition, - completion: acceptedSuggestion, - extraInfo: &extraInfo - ) - - presenter.discardSuggestion(fileURL: fileURL) - - return .init( - content: String(lines.joined(separator: "")), - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) } return nil diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 07af3b57..9986baf6 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -102,6 +102,15 @@ public class XPCService: NSObject, XPCServiceProtocol { try await handler.acceptSuggestion(editor: editor) } } + + public func getPromptToCodeAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptSuggestion(editor: editor) + } + } public func getRealtimeSuggestedCode( editorContent: Data, diff --git a/Core/Sources/XPCShared/XPCServiceProtocol.swift b/Core/Sources/XPCShared/XPCServiceProtocol.swift index 1c567f3b..af74b11d 100644 --- a/Core/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Core/Sources/XPCShared/XPCServiceProtocol.swift @@ -27,6 +27,10 @@ public protocol XPCServiceProtocol { editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void ) + func getPromptToCodeAcceptedCode( + editorContent: Data, + withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void + ) func chatWithSelection( editorContent: Data, withReply reply: @escaping (Data?, Error?) -> Void diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift new file mode 100644 index 00000000..f7d7171c --- /dev/null +++ b/EditorExtension/AcceptPromptToCodeCommand.swift @@ -0,0 +1,31 @@ +import Client +import Foundation +import SuggestionModel +import XcodeKit + +class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Prompt to Code" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + try await (Task(timeout: 7) { + let service = try getService() + if let content = try await service.getPromptToCodeAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index 7ad0b841..396a8d00 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -11,21 +11,30 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { RejectSuggestionCommand(), NextSuggestionCommand(), PreviousSuggestionCommand(), - RealtimeSuggestionsCommand(), - PrefetchSuggestionsCommand(), - ChatWithSelectionCommand(), PromptToCodeCommand(), - + AcceptPromptToCodeCommand(), + ChatWithSelectionCommand(), + ].map(makeCommandDefinition) + } + + var internalUse: [[XCSourceEditorCommandDefinitionKey: Any]] { + [ SeparatorCommand().named("------"), + RealtimeSuggestionsCommand(), + PrefetchSuggestionsCommand(), ].map(makeCommandDefinition) } var custom: [[XCSourceEditorCommandDefinitionKey: Any]] { - customCommands() + let all = customCommands() + if all.isEmpty { + return [] + } + return [SeparatorCommand().named("------")].map(makeCommandDefinition) + all } var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { - return builtin + custom + return builtin + custom + internalUse } func extensionDidFinishLaunching() { From 22019c5602b3a9fc3a0b8959d877d976bc71ee95 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 23 Aug 2023 22:17:28 +0800 Subject: [PATCH 04/81] Move FocusedCodeFinder to Tool --- .../xcschemes/Copilot for Xcode.xcscheme | 2 +- Core/Package.swift | 5 +- .../ActiveDocumentChatContextCollector.swift | 105 +------------ Tool/Package.swift | 12 ++ .../ActiveDocumentContext.swift | 147 ++++++++++++++++++ .../FocusedCodeFinder/FocusedCodeFinder.swift | 26 ++-- .../SwiftFocusedCodeFinder.swift | 12 +- 7 files changed, 183 insertions(+), 126 deletions(-) create mode 100644 Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift rename {Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector => Tool/Sources}/FocusedCodeFinder/FocusedCodeFinder.swift (85%) rename {Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector => Tool/Sources}/FocusedCodeFinder/SwiftFocusedCodeFinder.swift (98%) diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme index 1f9b8f1f..1185dc66 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme @@ -76,7 +76,7 @@ buildConfiguration = "Debug"> diff --git a/Core/Package.swift b/Core/Package.swift index 32611501..abdbf3d6 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -360,12 +360,9 @@ let package = Package( name: "ActiveDocumentChatContextCollector", dependencies: [ "ChatContextCollector", - .product(name: "LangChain", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), - .product(name: "ASTParser", package: "Tool"), - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "FocusedCodeFinder", package: "Tool"), ], path: "Sources/ChatContextCollectors/ActiveDocumentChatContextCollector" ), diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 5e29ed6a..0f9cee2c 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -1,5 +1,6 @@ import ASTParser import ChatContextCollector +import FocusedCodeFinder import Foundation import OpenAIService import Preferences @@ -188,107 +189,3 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { } } -struct ActiveDocumentContext { - var filePath: String - var relativePath: String - var language: CodeLanguage - var fileContent: String - var lines: [String] - var selectedCode: String - var selectionRange: CursorRange - var lineAnnotations: [EditorInformation.LineAnnotation] - var imports: [String] - - struct FocusedContext { - var context: [String] - var contextRange: CursorRange - var codeRange: CursorRange - var code: String - var lineAnnotations: [EditorInformation.LineAnnotation] - var otherLineAnnotations: [EditorInformation.LineAnnotation] - } - - var focusedContext: FocusedContext? - - mutating func moveToFocusedCode() { - moveToCodeContainingRange(selectionRange) - } - - mutating func moveToCodeAroundLine(_ line: Int) { - moveToCodeContainingRange(.init( - start: .init(line: line, character: 0), - end: .init(line: line, character: 0) - )) - } - - mutating func expandFocusedRangeToContextRange() { - guard let focusedContext else { return } - moveToCodeContainingRange(focusedContext.contextRange) - } - - mutating func moveToCodeContainingRange(_ range: CursorRange) { - let finder: FocusedCodeFinder = { - switch language { - case .builtIn(.swift): - return SwiftFocusedCodeFinder() - default: - return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) - } - }() - - let codeContext = finder.findFocusedCode( - containingRange: range, - activeDocumentContext: self - ) - - imports = codeContext.imports - - let startLine = codeContext.focusedRange.start.line - let endLine = codeContext.focusedRange.end.line - var matchedAnnotations = [EditorInformation.LineAnnotation]() - var otherAnnotations = [EditorInformation.LineAnnotation]() - for annotation in lineAnnotations { - if annotation.line >= startLine, annotation.line <= endLine { - matchedAnnotations.append(annotation) - } else { - otherAnnotations.append(annotation) - } - } - - focusedContext = .init( - context: codeContext.scopeSignatures, - contextRange: codeContext.contextRange, - codeRange: codeContext.focusedRange, - code: codeContext.focusedCode, - lineAnnotations: matchedAnnotations, - otherLineAnnotations: otherAnnotations - ) - } - - mutating func update(_ info: EditorInformation) { - /// Whenever the file content, relative path, or selection range changes, - /// we should reset the context. - let changed: Bool = { - if info.relativePath != relativePath { return true } - if info.editorContent?.content != fileContent { return true } - if let range = info.editorContent?.selections.first, - range != selectionRange { return true } - return false - }() - - filePath = info.documentURL.path - relativePath = info.relativePath - language = info.language - fileContent = info.editorContent?.content ?? "" - lines = info.editorContent?.lines ?? [] - selectedCode = info.selectedContent - selectionRange = info.editorContent?.selections.first ?? .zero - lineAnnotations = info.editorContent?.lineAnnotations ?? [] - imports = [] - - if changed { - moveToFocusedCode() - } - } -} - diff --git a/Tool/Package.swift b/Tool/Package.swift index b595a4de..c1b00ade 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), .library(name: "ASTParser", targets: ["ASTParser"]), + .library(name: "FocusedCodeFinder", targets: ["FocusedCodeFinder"]), .library(name: "Toast", targets: ["Toast"]), .library(name: "Keychain", targets: ["Keychain"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), @@ -47,6 +48,7 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), + .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), // TreeSitter .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", from: "0.7.1"), @@ -184,6 +186,16 @@ let package = Package( ] ), + .target( + name: "FocusedCodeFinder", + dependencies: [ + "Preferences", + "ASTParser", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + ] + ), + // MARK: - Services .target( diff --git a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift new file mode 100644 index 00000000..6b4d9525 --- /dev/null +++ b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift @@ -0,0 +1,147 @@ +import Foundation +import SuggestionModel + +public struct ActiveDocumentContext { + public var filePath: String + public var relativePath: String + public var language: CodeLanguage + public var fileContent: String + public var lines: [String] + public var selectedCode: String + public var selectionRange: CursorRange + public var lineAnnotations: [EditorInformation.LineAnnotation] + public var imports: [String] + + public struct FocusedContext { + public var context: [String] + public var contextRange: CursorRange + public var codeRange: CursorRange + public var code: String + public var lineAnnotations: [EditorInformation.LineAnnotation] + public var otherLineAnnotations: [EditorInformation.LineAnnotation] + + public init( + context: [String], + contextRange: CursorRange, + codeRange: CursorRange, + code: String, + lineAnnotations: [EditorInformation.LineAnnotation], + otherLineAnnotations: [EditorInformation.LineAnnotation] + ) { + self.context = context + self.contextRange = contextRange + self.codeRange = codeRange + self.code = code + self.lineAnnotations = lineAnnotations + self.otherLineAnnotations = otherLineAnnotations + } + } + + public var focusedContext: FocusedContext? + + public init( + filePath: String, + relativePath: String, + language: CodeLanguage, + fileContent: String, + lines: [String], + selectedCode: String, + selectionRange: CursorRange, + lineAnnotations: [EditorInformation.LineAnnotation], + imports: [String], + focusedContext: FocusedContext? = nil + ) { + self.filePath = filePath + self.relativePath = relativePath + self.language = language + self.fileContent = fileContent + self.lines = lines + self.selectedCode = selectedCode + self.selectionRange = selectionRange + self.lineAnnotations = lineAnnotations + self.imports = imports + self.focusedContext = focusedContext + } + + public mutating func moveToFocusedCode() { + moveToCodeContainingRange(selectionRange) + } + + public mutating func moveToCodeAroundLine(_ line: Int) { + moveToCodeContainingRange(.init( + start: .init(line: line, character: 0), + end: .init(line: line, character: 0) + )) + } + + public mutating func expandFocusedRangeToContextRange() { + guard let focusedContext else { return } + moveToCodeContainingRange(focusedContext.contextRange) + } + + public mutating func moveToCodeContainingRange(_ range: CursorRange) { + let finder: FocusedCodeFinder = { + switch language { + case .builtIn(.swift): + return SwiftFocusedCodeFinder() + default: + return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + } + }() + + let codeContext = finder.findFocusedCode( + containingRange: range, + activeDocumentContext: self + ) + + imports = codeContext.imports + + let startLine = codeContext.focusedRange.start.line + let endLine = codeContext.focusedRange.end.line + var matchedAnnotations = [EditorInformation.LineAnnotation]() + var otherAnnotations = [EditorInformation.LineAnnotation]() + for annotation in lineAnnotations { + if annotation.line >= startLine, annotation.line <= endLine { + matchedAnnotations.append(annotation) + } else { + otherAnnotations.append(annotation) + } + } + + focusedContext = .init( + context: codeContext.scopeSignatures, + contextRange: codeContext.contextRange, + codeRange: codeContext.focusedRange, + code: codeContext.focusedCode, + lineAnnotations: matchedAnnotations, + otherLineAnnotations: otherAnnotations + ) + } + + public mutating func update(_ info: EditorInformation) { + /// Whenever the file content, relative path, or selection range changes, + /// we should reset the context. + let changed: Bool = { + if info.relativePath != relativePath { return true } + if info.editorContent?.content != fileContent { return true } + if let range = info.editorContent?.selections.first, + range != selectionRange { return true } + return false + }() + + filePath = info.documentURL.path + relativePath = info.relativePath + language = info.language + fileContent = info.editorContent?.content ?? "" + lines = info.editorContent?.lines ?? [] + selectedCode = info.selectedContent + selectionRange = info.editorContent?.selections.first ?? .zero + lineAnnotations = info.editorContent?.lineAnnotations ?? [] + imports = [] + + if changed { + moveToFocusedCode() + } + } +} + diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift similarity index 85% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift rename to Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift index 24642138..c85c4dfa 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift @@ -1,14 +1,14 @@ import Foundation import SuggestionModel -struct CodeContext: Equatable { - enum Scope: Equatable { +public struct CodeContext: Equatable { + public enum Scope: Equatable { case file case top case scope(signature: [String]) } - var scopeSignatures: [String] { + public var scopeSignatures: [String] { switch scope { case .file: return [] @@ -19,32 +19,32 @@ struct CodeContext: Equatable { } } - var scope: Scope - var contextRange: CursorRange - var focusedRange: CursorRange - var focusedCode: String - var imports: [String] + public var scope: Scope + public var contextRange: CursorRange + public var focusedRange: CursorRange + public var focusedCode: String + public var imports: [String] - static var empty: CodeContext { + public static var empty: CodeContext { .init(scope: .file, contextRange: .zero, focusedRange: .zero, focusedCode: "", imports: []) } } -protocol FocusedCodeFinder { +public protocol FocusedCodeFinder { func findFocusedCode( containingRange: CursorRange, activeDocumentContext: ActiveDocumentContext ) -> CodeContext } -struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { +public struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { let proposedSearchRange: Int - init(proposedSearchRange: Int) { + public init(proposedSearchRange: Int) { self.proposedSearchRange = proposedSearchRange } - func findFocusedCode( + public func findFocusedCode( containingRange: CursorRange, activeDocumentContext: ActiveDocumentContext ) -> CodeContext { diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift similarity index 98% rename from Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift rename to Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index e0e52e4c..0c2ea908 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -3,15 +3,19 @@ import Foundation import SuggestionModel import SwiftParser import SwiftSyntax +import Preferences -struct SwiftFocusedCodeFinder: FocusedCodeFinder { - let maxFocusedCodeLineCount: Int +public struct SwiftFocusedCodeFinder: FocusedCodeFinder { + public let maxFocusedCodeLineCount: Int - init(maxFocusedCodeLineCount: Int = UserDefaults.shared.value(for: \.maxFocusedCodeLineCount)) { + public init( + maxFocusedCodeLineCount: Int = UserDefaults.shared + .value(for: \.maxFocusedCodeLineCount) + ) { self.maxFocusedCodeLineCount = maxFocusedCodeLineCount } - func findFocusedCode( + public func findFocusedCode( containingRange range: CursorRange, activeDocumentContext: ActiveDocumentContext ) -> CodeContext { From 9090237a2139bc8e44071b2cdc2d85054369b38a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 24 Aug 2023 21:14:21 +0800 Subject: [PATCH 05/81] Add focusedEditorContent to XcodeInspector --- .../GetEditorInfo.swift | 35 +----------------- .../XcodeInspector/XcodeInspector.swift | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 7c88aa27..3907568a 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -3,39 +3,6 @@ import SuggestionModel import XcodeInspector func getEditorInformation() -> EditorInformation? { - guard !XcodeInspector.shared.xcodes.isEmpty else { return nil } - - let editorContent = XcodeInspector.shared.focusedEditor?.content - let documentURL = XcodeInspector.shared.activeDocumentURL - let projectURL = XcodeInspector.shared.activeProjectURL - let language = languageIdentifierFromFileURL(documentURL) - let relativePath = documentURL.path - .replacingOccurrences(of: projectURL.path, with: "") - - if let editorContent, let range = editorContent.selections.first { - let (selectedContent, selectedLines) = EditorInformation.code( - in: editorContent.lines, - inside: range - ) - return .init( - editorContent: editorContent, - selectedContent: selectedContent, - selectedLines: selectedLines, - documentURL: documentURL, - projectURL: projectURL, - relativePath: relativePath, - language: language - ) - } - - return .init( - editorContent: editorContent, - selectedContent: "", - selectedLines: [], - documentURL: documentURL, - projectURL: projectURL, - relativePath: relativePath, - language: language - ) + return XcodeInspector.shared.focusedEditorContent } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index b7789da6..2d271206 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -4,6 +4,7 @@ import AXExtension import AXNotificationStream import Combine import Foundation +import SuggestionModel public final class XcodeInspector: ObservableObject { public static let shared = XcodeInspector() @@ -23,6 +24,41 @@ public final class XcodeInspector: ObservableObject { @Published public internal(set) var focusedElement: AXUIElement? @Published public internal(set) var completionPanel: AXUIElement? + public var focusedEditorContent: EditorInformation? { + let editorContent = XcodeInspector.shared.focusedEditor?.content + let documentURL = XcodeInspector.shared.activeDocumentURL + let projectURL = XcodeInspector.shared.activeProjectURL + let language = languageIdentifierFromFileURL(documentURL) + let relativePath = documentURL.path + .replacingOccurrences(of: projectURL.path, with: "") + + if let editorContent, let range = editorContent.selections.first { + let (selectedContent, selectedLines) = EditorInformation.code( + in: editorContent.lines, + inside: range + ) + return .init( + editorContent: editorContent, + selectedContent: selectedContent, + selectedLines: selectedLines, + documentURL: documentURL, + projectURL: projectURL, + relativePath: relativePath, + language: language + ) + } + + return .init( + editorContent: editorContent, + selectedContent: "", + selectedLines: [], + documentURL: documentURL, + projectURL: projectURL, + relativePath: relativePath, + language: language + ) + } + public var realtimeActiveDocumentURL: URL { latestActiveXcode?.realtimeDocumentURL ?? URL(fileURLWithPath: "/") } From ebaebb3b078ebbde389ec2d26ab3294db0dc1aac Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 24 Aug 2023 21:14:49 +0800 Subject: [PATCH 06/81] Add attach to code to prompt to code service --- Core/Package.swift | 1 + .../PromptToCodeService.swift | 51 +++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index abdbf3d6..2f635ef8 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -157,6 +157,7 @@ let package = Package( .target( name: "PromptToCodeService", dependencies: [ + .product(name: "FocusedCodeFinder", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), diff --git a/Core/Sources/PromptToCodeService/PromptToCodeService.swift b/Core/Sources/PromptToCodeService/PromptToCodeService.swift index 5be4a1a5..c8802a2f 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeService.swift @@ -1,6 +1,8 @@ -import SuggestionModel +import FocusedCodeFinder import Foundation import OpenAIService +import SuggestionModel +import XcodeInspector public final class PromptToCodeService: ObservableObject { var designatedPromptToCodeAPI: PromptToCodeAPI? @@ -8,7 +10,7 @@ public final class PromptToCodeService: ObservableObject { if let designatedPromptToCodeAPI { return designatedPromptToCodeAPI } - + return OpenAIPromptToCodeAPI() } @@ -70,7 +72,7 @@ public final class PromptToCodeService: ObservableObject { self.projectRootURL = projectRootURL self.fileURL = fileURL self.allCode = allCode - self.history = .empty + history = .empty self.extraSystemPrompt = extraSystemPrompt self.generateDescriptionRequirement = generateDescriptionRequirement } @@ -126,6 +128,48 @@ public final class PromptToCodeService: ObservableObject { runningAPI?.stopResponding() isResponding = false } + + public func toggleAttachOrDetachToCode() { + if selectionRange != nil { + selectionRange = nil + } else { + let inspector = XcodeInspector.shared + guard let editor = XcodeInspector.shared.focusedEditorContent + else { return } + if let range = editor.editorContent?.selections.first, range.start != range.end { + selectionRange = range + } else { + let focusedCodeFinder: FocusedCodeFinder = { + switch language { + case .builtIn(.swift): + return SwiftFocusedCodeFinder() + default: + return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 10) + } + }() + + let cursorPosition = editor.editorContent?.cursorPosition ?? .zero + let codeContext = focusedCodeFinder.findFocusedCode( + containingRange: .init(start: cursorPosition, end: cursorPosition), + activeDocumentContext: .init( + filePath: editor.documentURL.path, + relativePath: editor.relativePath, + language: editor.language, + fileContent: editor.editorContent?.content ?? "", + lines: editor.editorContent?.lines ?? [], + selectedCode: editor.editorContent?.selectedContent ?? "", + selectionRange: editor.editorContent?.selections.first ?? .zero, + lineAnnotations: [], + imports: [] + ) + ) + + fileURL = editor.documentURL + projectRootURL = editor.projectURL + selectionRange = codeContext.contextRange + } + } + } } protocol PromptToCodeAPI { @@ -144,3 +188,4 @@ protocol PromptToCodeAPI { func stopResponding() } + From 33f4d780f55a8a3c67bf22e4fc18aedcf9a40280 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 24 Aug 2023 21:15:11 +0800 Subject: [PATCH 07/81] Update prompt to code --- .../Service/GUI/PromptToCodeProvider+Service.swift | 8 ++------ Core/Sources/Service/GUI/WidgetDataSource.swift | 14 +++++++------- .../WindowBaseCommandHandler.swift | 14 ++------------ Core/Sources/Service/XPCService.swift | 2 +- .../SwiftFocusedCodeFinderTests.swift | 1 + .../FocusedCodeFinder/FocusedCodeFinder.swift | 14 ++++++++++++++ 6 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift index ff3bc418..ec0d9c17 100644 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift @@ -72,13 +72,9 @@ extension PromptToCodeProvider { onContinuousToggleClick = { service.isContinuous.toggle() } - + onToggleAttachOrDetachToCode = { - if service.selectionRange != nil { - service.selectionRange = nil - } else { - // reset to selected or focused range. - } + service.toggleAttachOrDetachToCode() } } diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index edd72bd2..9727b139 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -22,7 +22,7 @@ final class WidgetDataSource { } } - private(set) var promptToCodes = [URL: PromptToCode]() + private(set) var promptToCode: PromptToCode? init() {} @@ -57,7 +57,7 @@ final class WidgetDataSource { service: service, name: name, onClosePromptToCode: { [weak self] in - self?.removePromptToCode(for: url) + self?.removePromptToCode() let presenter = PresentInWindowSuggestionPresenter() presenter.closePromptToCode(fileURL: url) if let app = ActiveApplicationMonitor.shared.previousApp, app.isXcode { @@ -72,16 +72,16 @@ final class WidgetDataSource { } let newPromptToCode = build() - promptToCodes[url] = newPromptToCode + promptToCode = newPromptToCode return newPromptToCode.promptToCodeService } - func removePromptToCode(for url: URL) { - promptToCodes[url] = nil + func removePromptToCode() { + promptToCode = nil } func cleanup(for url: URL) { - removePromptToCode(for: url) +// removePromptToCode(for: url) } } @@ -140,7 +140,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { } func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { - return promptToCodes[url]?.provider + return promptToCode?.provider } } diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index d83ec755..4490e0c4 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -136,14 +136,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let dataSource = Service.shared.guiController.widgetDataSource - - if await dataSource.promptToCodes[fileURL]?.promptToCodeService != nil { - await dataSource.removePromptToCode(for: fileURL) - presenter.closePromptToCode(fileURL: fileURL) - return - } - let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) @@ -164,8 +156,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - let dataSource = Service.shared.guiController.widgetDataSource - if let acceptedSuggestion = workspace.acceptSuggestion( forFileAt: fileURL, editor: editor @@ -202,7 +192,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { let dataSource = Service.shared.guiController.widgetDataSource - if let service = await dataSource.promptToCodes[fileURL]?.promptToCodeService { + if let service = await dataSource.promptToCode?.promptToCodeService { let rangeStart = service.selectionRange?.start ?? editor.cursorPosition let suggestion = CodeSuggestion( @@ -230,7 +220,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { ) presenter.presentPromptToCode(fileURL: fileURL) } else { - await dataSource.removePromptToCode(for: fileURL) + await dataSource.removePromptToCode() presenter.closePromptToCode(fileURL: fileURL) } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 9986baf6..3e5d8d06 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -108,7 +108,7 @@ public class XPCService: NSObject, XPCServiceProtocol { withReply reply: @escaping (Data?, Error?) -> Void ) { replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in - try await handler.acceptSuggestion(editor: editor) + try await handler.acceptPromptToCode(editor: editor) } } diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift index 1c546a94..c145c5f6 100644 --- a/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift @@ -1,6 +1,7 @@ import Foundation import SuggestionModel import XCTest +import FocusedCodeFinder @testable import ActiveDocumentChatContextCollector diff --git a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift index c85c4dfa..a571da5d 100644 --- a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift @@ -28,6 +28,20 @@ public struct CodeContext: Equatable { public static var empty: CodeContext { .init(scope: .file, contextRange: .zero, focusedRange: .zero, focusedCode: "", imports: []) } + + public init( + scope: Scope, + contextRange: CursorRange, + focusedRange: CursorRange, + focusedCode: String, + imports: [String] + ) { + self.scope = scope + self.contextRange = contextRange + self.focusedRange = focusedRange + self.focusedCode = focusedCode + self.imports = imports + } } public protocol FocusedCodeFinder { From 18203a530d04e2988ad333b4f5c4c39d67be8d28 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 25 Aug 2023 15:27:20 +0800 Subject: [PATCH 08/81] Change re-attach to attach to the initial selection range --- .../PromptToCodeService.swift | 51 ++++--------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/Core/Sources/PromptToCodeService/PromptToCodeService.swift b/Core/Sources/PromptToCodeService/PromptToCodeService.swift index c8802a2f..a48747e0 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeService.swift @@ -1,4 +1,3 @@ -import FocusedCodeFinder import Foundation import OpenAIService import SuggestionModel @@ -42,6 +41,7 @@ public final class PromptToCodeService: ObservableObject { @Published public var description: String = "" @Published public var isContinuous = false @Published public var selectionRange: CursorRange? + @Published public var isAttachedToSelectionRange: Bool public var canRevert: Bool { history != .empty } public var language: CodeLanguage public var indentSize: Int @@ -73,6 +73,7 @@ public final class PromptToCodeService: ObservableObject { self.fileURL = fileURL self.allCode = allCode history = .empty + isAttachedToSelectionRange = true self.extraSystemPrompt = extraSystemPrompt self.generateDescriptionRequirement = generateDescriptionRequirement } @@ -93,8 +94,12 @@ public final class PromptToCodeService: ObservableObject { indentSize: indentSize, usesTabsForIndentation: usesTabsForIndentation, requirement: prompt, - projectRootURL: projectRootURL, - fileURL: fileURL, + projectRootURL: isAttachedToSelectionRange + ? projectRootURL + : XcodeInspector.shared.activeProjectURL, + fileURL: isAttachedToSelectionRange + ? fileURL + : XcodeInspector.shared.activeDocumentURL, allCode: allCode, extraSystemPrompt: extraSystemPrompt, generateDescriptionRequirement: generateDescriptionRequirement @@ -130,45 +135,7 @@ public final class PromptToCodeService: ObservableObject { } public func toggleAttachOrDetachToCode() { - if selectionRange != nil { - selectionRange = nil - } else { - let inspector = XcodeInspector.shared - guard let editor = XcodeInspector.shared.focusedEditorContent - else { return } - if let range = editor.editorContent?.selections.first, range.start != range.end { - selectionRange = range - } else { - let focusedCodeFinder: FocusedCodeFinder = { - switch language { - case .builtIn(.swift): - return SwiftFocusedCodeFinder() - default: - return UnknownLanguageFocusedCodeFinder(proposedSearchRange: 10) - } - }() - - let cursorPosition = editor.editorContent?.cursorPosition ?? .zero - let codeContext = focusedCodeFinder.findFocusedCode( - containingRange: .init(start: cursorPosition, end: cursorPosition), - activeDocumentContext: .init( - filePath: editor.documentURL.path, - relativePath: editor.relativePath, - language: editor.language, - fileContent: editor.editorContent?.content ?? "", - lines: editor.editorContent?.lines ?? [], - selectedCode: editor.editorContent?.selectedContent ?? "", - selectionRange: editor.editorContent?.selections.first ?? .zero, - lineAnnotations: [], - imports: [] - ) - ) - - fileURL = editor.documentURL - projectRootURL = editor.projectURL - selectionRange = codeContext.contextRange - } - } + isAttachedToSelectionRange.toggle() } } From cf3a8798ac7158560aa4f8d725b99513ef57886a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 26 Aug 2023 00:18:08 +0800 Subject: [PATCH 09/81] Migrate prompt to code to use TCA --- .../GraphicalUserInterfaceController.swift | 11 +- .../Service/GUI/WidgetDataSource.swift | 80 +-- Core/Sources/Service/ScheduledCleaner.swift | 18 +- .../WindowBaseCommandHandler.swift | 78 +-- .../PresentInWindowSuggestionPresenter.swift | 14 - .../FeatureReducers/PanelFeature.swift | 132 ++--- .../FeatureReducers/PromptToCode.swift | 302 ++++++++++ .../FeatureReducers/PromptToCodeGroup.swift | 144 +++++ .../FeatureReducers/SharedPanelFeature.swift | 52 +- .../SuggestionPanelFeature.swift | 2 +- .../Providers/SuggestionProvider.swift | 7 +- .../SuggestionWidget/SharedPanelView.swift | 59 +- .../PromptToCodePanel.swift | 519 +++++++++++------- .../SuggestionPanelView.swift | 15 +- .../SuggestionWidgetController.swift | 15 +- .../SuggestionWidgetDataSource.swift | 5 - 16 files changed, 953 insertions(+), 500 deletions(-) create mode 100644 Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift create mode 100644 Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 0fe8896d..7dd37baa 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -24,6 +24,11 @@ struct GUI: ReducerProtocol { set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } } + var promptToCodeGroup: PromptToCodeGroup.State { + get { suggestionWidgetState.panelState.content.promptToCodeGroup } + set { suggestionWidgetState.panelState.content.promptToCodeGroup = newValue } + } + #if canImport(ChatTabPersistent) var persistentState: ChatTabPersistent.State { get { @@ -44,6 +49,10 @@ struct GUI: ReducerProtocol { case suggestionWidget(WidgetFeature.Action) + static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { + .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) + } + #if canImport(ChatTabPersistent) case persistent(ChatTabPersistent.Action) #endif @@ -267,7 +276,7 @@ public final class GraphicalUserInterfaceController { } } } - + func start() { store.send(.start) } diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 9727b139..a6517f51 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -9,81 +9,7 @@ import SuggestionModel import SuggestionWidget @MainActor -final class WidgetDataSource { - final class PromptToCode { - let promptToCodeService: PromptToCodeService - let provider: PromptToCodeProvider - public init( - promptToCodeService: PromptToCodeService, - provider: PromptToCodeProvider - ) { - self.promptToCodeService = promptToCodeService - self.provider = provider - } - } - - private(set) var promptToCode: PromptToCode? - - init() {} - - @discardableResult - func createPromptToCode( - for url: URL, - projectURL: URL, - selectedCode: String, - allCode: String, - selectionRange: CursorRange, - language: CodeLanguage, - identSize: Int = 4, - usesTabsForIndentation: Bool = false, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool?, - name: String? - ) async -> PromptToCodeService { - let build = { - let service = PromptToCodeService( - code: selectedCode, - selectionRange: selectionRange, - language: language, - identSize: identSize, - usesTabsForIndentation: usesTabsForIndentation, - projectRootURL: projectURL, - fileURL: url, - allCode: allCode, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescriptionRequirement - ) - let provider = PromptToCodeProvider( - service: service, - name: name, - onClosePromptToCode: { [weak self] in - self?.removePromptToCode() - let presenter = PresentInWindowSuggestionPresenter() - presenter.closePromptToCode(fileURL: url) - if let app = ActiveApplicationMonitor.shared.previousApp, app.isXcode { - Task { @MainActor in - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() - } - } - } - ) - return PromptToCode(promptToCodeService: service, provider: provider) - } - - let newPromptToCode = build() - promptToCode = newPromptToCode - return newPromptToCode.promptToCodeService - } - - func removePromptToCode() { - promptToCode = nil - } - - func cleanup(for url: URL) { -// removePromptToCode(for: url) - } -} +final class WidgetDataSource {} extension WidgetDataSource: SuggestionWidgetDataSource { func suggestionForFile(at url: URL) async -> SuggestionProvider? { @@ -138,9 +64,5 @@ extension WidgetDataSource: SuggestionWidgetDataSource { } return nil } - - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { - return promptToCode?.provider - } } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 1f971e05..35a86543 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -17,7 +17,7 @@ public final class ScheduledCleaner { self.workspacePool = workspacePool self.guiController = guiController } - + func start() { // occasionally cleanup workspaces. Task { @ServiceActor in @@ -58,9 +58,13 @@ public final class ScheduledCleaner { for (url, workspace) in workspacePool.workspaces { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") - for url in workspace.filespaces.keys { - await guiController.widgetDataSource.cleanup(for: url) - } + _ = await Task { @MainActor in + guiController.viewStore.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: Array( + workspace.filespaces.keys + ))) + ) + }.result await workspace.cleanUp(availableTabs: []) workspacePool.removeWorkspace(url: url) } else { @@ -74,7 +78,11 @@ public final class ScheduledCleaner { availableTabs: tabs ) { Logger.service.info("Remove idle filespace") - await guiController.widgetDataSource.cleanup(for: url) + _ = await Task { @MainActor in + guiController.viewStore.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: [url])) + ) + }.result } } // cleanup workspace diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 4490e0c4..e42a3676 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -190,20 +190,24 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - let dataSource = Service.shared.guiController.widgetDataSource + let viewStore = Service.shared.guiController.viewStore - if let service = await dataSource.promptToCode?.promptToCodeService { - let rangeStart = service.selectionRange?.start ?? editor.cursorPosition + if let promptToCode = viewStore.state.promptToCodeGroup.activePromptToCode { + if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL { + return nil + } + + let rangeStart = promptToCode.selectionRange?.start ?? editor.cursorPosition let suggestion = CodeSuggestion( - text: service.code, + text: promptToCode.code, position: rangeStart, uuid: UUID().uuidString, - range: service.selectionRange ?? .init( + range: promptToCode.selectionRange ?? .init( start: editor.cursorPosition, end: editor.cursorPosition ), - displayText: service.code + displayText: promptToCode.code ) injector.acceptSuggestion( @@ -213,16 +217,19 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { extraInfo: &extraInfo ) - if service.isContinuous { - service.selectionRange = .init( - start: rangeStart, - end: cursorPosition + _ = await Task { @MainActor [cursorPosition] in + viewStore.send( + .promptToCodeGroup(.updatePromptToCodeRange( + id: promptToCode.id, + range: .init(start: rangeStart, end: cursorPosition) + )) ) - presenter.presentPromptToCode(fileURL: fileURL) - } else { - await dataSource.removePromptToCode() - presenter.closePromptToCode(fileURL: fileURL) - } + viewStore.send( + .promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous( + id: promptToCode.id + )) + ) + }.result return .init( content: String(lines.joined(separator: "")), @@ -348,7 +355,7 @@ extension WindowBaseCommandHandler { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, _) = try await Service.shared.workspacePool + let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else { presenter.presentErrorMessage("Prompt to code is disabled for this project") @@ -391,26 +398,25 @@ extension WindowBaseCommandHandler { ) }() as (String, CursorRange) - let dataSource = Service.shared.guiController.widgetDataSource - - let promptToCode = await dataSource.createPromptToCode( - for: fileURL, - projectURL: workspace.projectRootURL, - selectedCode: code, - allCode: editor.content, - selectionRange: selection, - language: codeLanguage, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescription, - name: name - ) - - promptToCode.isContinuous = isContinuous - if let prompt, !prompt.isEmpty { - Task { try await promptToCode.modifyCode(prompt: prompt) } - } - - presenter.presentPromptToCode(fileURL: fileURL) + let viewStore = Service.shared.guiController.viewStore + + _ = await Task { @MainActor in + viewStore.send(.promptToCodeGroup(.createPromptToCode(.init( + code: code, + selectionRange: selection, + language: codeLanguage, + identSize: filespace.codeMetadata.indentSize ?? 4, + usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, + documentURL: fileURL, + projectRootURL: workspace.projectRootURL, + allCode: editor.content, + isContinuous: isContinuous, + commandName: name, + defaultPrompt: prompt ?? "", + extraSystemPrompt: extraSystemPrompt, + generateDescriptionRequirement: generateDescription + )))) + }.result } func executeSingleRoundDialog( diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 9215551f..e2568f9f 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -55,19 +55,5 @@ struct PresentInWindowSuggestionPresenter { controller.presentChatRoom() } } - - func presentPromptToCode(fileURL: URL) { - Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.presentPromptToCode() - } - } - - func closePromptToCode(fileURL: URL) { - Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.discardPromptToCode() - } - } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 5c2387c4..61a9d9a7 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -4,11 +4,11 @@ import Foundation public struct PanelFeature: ReducerProtocol { public struct State: Equatable { - var content: SharedPanelFeature.Content? { - get { sharedPanelState.content ?? suggestionPanelState.content } + public var content: SharedPanelFeature.Content { + get { sharedPanelState.content } set { sharedPanelState.content = newValue - suggestionPanelState.content = newValue + suggestionPanelState.content = newValue.suggestion } } @@ -23,10 +23,11 @@ public struct PanelFeature: ReducerProtocol { public enum Action: Equatable { case presentSuggestion + case presentSuggestionProvider(SuggestionProvider, displayContent: Bool) case presentError(String) - case presentPromptToCode - case presentPanelContent(SharedPanelFeature.Content, shouldDisplay: Bool) - case discardPanelContent + case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) + case displayPanelContent + case discardSuggestion case removeDisplayedContent case switchToAnotherEditorAndUpdateContent @@ -54,93 +55,79 @@ public struct PanelFeature: ReducerProtocol { guard let provider = await fetchSuggestionProvider( fileURL: xcodeInspector.activeDocumentURL ) else { return } - let content = SharedPanelFeature.Content.suggestion(provider) - await send(.presentPanelContent(content, shouldDisplay: true)) - }.animation(.easeInOut(duration: 0.2)) + await send(.presentSuggestionProvider(provider, displayContent: true)) + } + + case let .presentSuggestionProvider(provider, displayContent): + state.content.suggestion = provider + if displayContent { + return .run { send in + await send(.displayPanelContent) + }.animation(.easeInOut(duration: 0.2)) + } + return .none case let .presentError(errorDescription): + state.content.error = errorDescription return .run { send in - let content = SharedPanelFeature.Content.error(errorDescription) - await send(.presentPanelContent(content, shouldDisplay: true)) + await send(.displayPanelContent) }.animation(.easeInOut(duration: 0.2)) - case .presentPromptToCode: + case let .presentPromptToCode(initialState): return .run { send in - guard let provider = await fetchPromptToCodeProvider( - fileURL: xcodeInspector.activeDocumentURL - ) else { return } - let content = SharedPanelFeature.Content.promptToCode(provider) - await send(.presentPanelContent(content, shouldDisplay: true)) - - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - await NSApplication.shared.activate(ignoringOtherApps: true) - await windows.sharedPanelWindow.makeKey() - }.animation(.easeInOut(duration: 0.2)) - - case let .presentPanelContent(content, shouldDisplay): - state.content = content - - guard shouldDisplay else { return .none } + await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode(initialState)))) + } - switch content { - case .suggestion: - switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { - case .nearbyTextCursor: - state.suggestionPanelState.isPanelDisplayed = true - case .floatingWidget: - state.sharedPanelState.isPanelDisplayed = true - } - case .error: - state.sharedPanelState.isPanelDisplayed = true - case .promptToCode: + case .displayPanelContent: + if !state.sharedPanelState.isEmpty { state.sharedPanelState.isPanelDisplayed = true } + if state.suggestionPanelState.content != nil { + state.suggestionPanelState.isPanelDisplayed = true + } + return .none - case .discardPanelContent: - return .run { send in - let fileURL = xcodeInspector.activeDocumentURL - if let provider = await fetchPromptToCodeProvider(fileURL: fileURL) { - await send(.presentPanelContent( - .promptToCode(provider), - shouldDisplay: false - )) - } else if let provider = await fetchSuggestionProvider(fileURL: fileURL) { - await send(.presentPanelContent( - .suggestion(provider), - shouldDisplay: false - )) - } else { - await send(.removeDisplayedContent) - } - }.animation(.easeInOut(duration: 0.2)) + case .discardSuggestion: + state.content.suggestion = nil + return .none case .switchToAnotherEditorAndUpdateContent: + state.content.error = nil return .run { send in let fileURL = xcodeInspector.activeDocumentURL - if let provider = await fetchPromptToCodeProvider(fileURL: fileURL) { - await send(.presentPanelContent( - .promptToCode(provider), - shouldDisplay: false - )) - } else if let provider = await fetchSuggestionProvider(fileURL: fileURL) { - await send(.presentPanelContent( - .suggestion(provider), - shouldDisplay: false - )) - } else { - await send(.removeDisplayedContent) + if let suggestion = await fetchSuggestionProvider(fileURL: fileURL) { + await send(.presentSuggestionProvider(suggestion, displayContent: false)) } + + await send(.sharedPanel( + .promptToCodeGroup( + .updateActivePromptToCode(documentURL: fileURL) + ) + )) } case .removeDisplayedContent: - state.content = nil +// state.content = nil return .none + case .sharedPanel(.promptToCodeGroup(.createPromptToCode)): + let hasPromptToCode = state.content.promptToCode != nil + return .run { send in + await send(.displayPanelContent) + + if hasPromptToCode { + // looks like we need a delay. + try await Task.sleep(nanoseconds: 150_000_000) + await NSApplication.shared.activate(ignoringOtherApps: true) + await windows.sharedPanelWindow.makeKey() + } + }.animation(.easeInOut(duration: 0.2)) + case .sharedPanel: return .none + case .suggestionPanel: return .none } @@ -153,12 +140,5 @@ public struct PanelFeature: ReducerProtocol { .suggestionForFile(at: fileURL) else { return nil } return provider } - - func fetchPromptToCodeProvider(fileURL: URL) async -> PromptToCodeProvider? { - guard let provider = await suggestionWidgetControllerDependency - .suggestionWidgetDataSource? - .promptToCodeForFile(at: fileURL) else { return nil } - return provider - } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift new file mode 100644 index 00000000..2e1a08cf --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -0,0 +1,302 @@ +import AppKit +import ComposableArchitecture +import Dependencies +import Foundation +import SuggestionModel + +protocol PromptToCodeService { + func modifyCode( + code: String, + language: CodeLanguage, + indentSize: Int, + usesTabsForIndentation: Bool, + requirement: String, + projectRootURL: URL, + fileURL: URL, + allCode: String, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> + + func stopResponding() +} + +struct PreviewPromptToCodeService: PromptToCodeService { + func modifyCode( + code: String, + language: CodeLanguage, + indentSize: Int, + usesTabsForIndentation: Bool, + requirement: String, + projectRootURL: URL, + fileURL: URL, + allCode: String, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + return AsyncThrowingStream { continuation in + continuation.finish() + } + } + + func stopResponding() {} +} + +struct PromptToCodeServiceDependencyKey: DependencyKey { + static let liveValue: PromptToCodeService = PreviewPromptToCodeService() + static let previewValue: PromptToCodeService = PreviewPromptToCodeService() +} + +struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { + static let liveValue: () -> PromptToCodeService = { PreviewPromptToCodeService() } + static let previewValue: () -> PromptToCodeService = { PreviewPromptToCodeService() } +} + +struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { + static let liveValue: () -> Void = {} + static let previewValue: () -> Void = { print("Accept Prompt to Code") } +} + +extension DependencyValues { + var promptToCodeService: PromptToCodeService { + get { self[PromptToCodeServiceDependencyKey.self] } + set { self[PromptToCodeServiceDependencyKey.self] = newValue } + } + + var promptToCodeServiceFactory: () -> PromptToCodeService { + get { self[PromptToCodeServiceFactoryDependencyKey.self] } + set { self[PromptToCodeServiceFactoryDependencyKey.self] = newValue } + } + + var promptToCodeAcceptHandler: () -> Void { + get { self[PromptToCodeAcceptHandlerDependencyKey.self] } + set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue } + } +} + +public struct PromptToCode: ReducerProtocol { + public struct State: Equatable, Identifiable { + public indirect enum HistoryNode: Equatable { + case empty + case node(code: String, description: String, previous: HistoryNode) + + mutating func enqueue(code: String, description: String) { + let current = self + self = .node(code: code, description: description, previous: current) + } + + mutating func pop() -> (code: String, description: String)? { + switch self { + case .empty: + return nil + case let .node(code, description, previous): + self = previous + return (code, description) + } + } + } + + public var id: URL { documentURL } + public var history: HistoryNode + public var code: String + public var isResponding: Bool + public var description: String + public var error: String? + public var selectionRange: CursorRange? + public var language: CodeLanguage + public var indentSize: Int + public var usesTabsForIndentation: Bool + public var projectRootURL: URL + public var documentURL: URL + public var allCode: String + public var extraSystemPrompt: String? + public var generateDescriptionRequirement: Bool? + public var commandName: String? + @BindingState public var prompt: String + @BindingState public var isContinuous: Bool + @BindingState public var isAttachedToSelectionRange: Bool + + public var canRevert: Bool { history != .empty } + + public init( + code: String, + prompt: String, + language: CodeLanguage, + indentSize: Int, + usesTabsForIndentation: Bool, + projectRootURL: URL, + documentURL: URL, + allCode: String, + commandName: String? = nil, + description: String = "", + isResponding: Bool = false, + isAttachedToSelectionRange: Bool = true, + error: String? = nil, + history: HistoryNode = .empty, + isContinuous: Bool = false, + selectionRange: CursorRange? = nil, + extraSystemPrompt: String? = nil, + generateDescriptionRequirement: Bool? = nil + ) { + self.history = history + self.code = code + self.prompt = prompt + self.isResponding = isResponding + self.description = description + self.error = error + self.isContinuous = isContinuous + self.selectionRange = selectionRange + self.language = language + self.indentSize = indentSize + self.usesTabsForIndentation = usesTabsForIndentation + self.projectRootURL = projectRootURL + self.documentURL = documentURL + self.allCode = allCode + self.extraSystemPrompt = extraSystemPrompt + self.generateDescriptionRequirement = generateDescriptionRequirement + self.isAttachedToSelectionRange = isAttachedToSelectionRange + self.commandName = commandName + } + } + + public enum Action: Equatable, BindableAction { + case binding(BindingAction) + case selectionRangeToggleTapped + case modifyCodeButtonTapped + case revertButtonTapped + case stopRespondingButtonTapped + case modifyCodeFinished + case modifyCodeTrunkReceived(code: String, description: String) + case modifyCodeFailed(error: String) + case modifyCodeCancelled + case cancelButtonTapped + case acceptButtonTapped + case copyCodeButtonTapped + case appendNewLineToPromptButtonTapped + } + + @Dependency(\.promptToCodeService) var promptToCodeService + @Dependency(\.promptToCodeAcceptHandler) var promptToCodeAcceptHandler + + enum CancellationKey: Hashable { + case modifyCode(State.ID) + } + + public var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .selectionRangeToggleTapped: + state.isAttachedToSelectionRange.toggle() + return .none + + case .modifyCodeButtonTapped: + guard !state.isResponding else { return .none } + let copiedState = state + state.history.enqueue(code: state.code, description: state.description) + state.isResponding = true + state.code = "" + state.description = "" + state.error = nil + + return .run { send in + do { + let stream = try await promptToCodeService.modifyCode( + code: copiedState.code, + language: copiedState.language, + indentSize: copiedState.indentSize, + usesTabsForIndentation: copiedState.usesTabsForIndentation, + requirement: copiedState.prompt, + projectRootURL: copiedState.projectRootURL, + fileURL: copiedState.documentURL, + allCode: copiedState.allCode, + extraSystemPrompt: copiedState.extraSystemPrompt, + generateDescriptionRequirement: copiedState + .generateDescriptionRequirement + ) + for try await fragment in stream { + try Task.checkCancellation() + await send(.modifyCodeTrunkReceived( + code: fragment.code, + description: fragment.description + )) + } + try Task.checkCancellation() + await send(.modifyCodeFinished) + } catch is CancellationError { + try Task.checkCancellation() + await send(.modifyCodeCancelled) + } catch { + try Task.checkCancellation() + if (error as NSError).code == NSURLErrorCancelled { + await send(.modifyCodeCancelled) + return + } + + await send(.modifyCodeFailed(error: error.localizedDescription)) + } + }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true) + + case .revertButtonTapped: + guard let (code, description) = state.history.pop() else { return .none } + state.code = code + state.description = description + return .none + + case .stopRespondingButtonTapped: + state.isResponding = false + promptToCodeService.stopResponding() + return .none + + case let .modifyCodeTrunkReceived(code, description): + state.code = code + state.description = description + return .none + + case .modifyCodeFinished: + state.isResponding = false + if state.code.isEmpty, state.description.isEmpty { + // if both code and description are empty, we treat it as failed + return .run { send in + await send(.revertButtonTapped) + } + } + + return .none + + case let .modifyCodeFailed(error): + state.error = error + state.isResponding = false + return .run { send in + await send(.revertButtonTapped) + } + + case .modifyCodeCancelled: + state.isResponding = false + return .none + + case .cancelButtonTapped: + promptToCodeService.stopResponding() + return .none + + case .acceptButtonTapped: + promptToCodeAcceptHandler() + return .none + + case .copyCodeButtonTapped: + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(state.code, forType: .string) + return .none + + case .appendNewLineToPromptButtonTapped: + state.prompt += "\n" + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift new file mode 100644 index 00000000..d7ca23ee --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -0,0 +1,144 @@ +import ComposableArchitecture +import Foundation +import SuggestionModel + +public struct PromptToCodeGroup: ReducerProtocol { + public struct State: Equatable { + public var promptToCodes: IdentifiedArrayOf = [] + public var activeDocumentURL: PromptToCode.State.ID? + public var activePromptToCode: PromptToCode.State? { + get { + if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) { + return detached + } + guard let id = activeDocumentURL else { return nil } + return promptToCodes[id: id] + } + set { activeDocumentURL = newValue?.id } + } + } + + public struct PromptToCodeInitialState: Equatable { + public var code: String + public var selectionRange: CursorRange? + public var language: CodeLanguage + public var identSize: Int + public var usesTabsForIndentation: Bool + public var documentURL: URL + public var projectRootURL: URL + public var allCode: String + public var isContinuous: Bool + public var commandName: String? + public var defaultPrompt: String + public var extraSystemPrompt: String? + public var generateDescriptionRequirement: Bool? + + public init( + code: String, + selectionRange: CursorRange?, + language: CodeLanguage, + identSize: Int, + usesTabsForIndentation: Bool, + documentURL: URL, + projectRootURL: URL, + allCode: String, + isContinuous: Bool, + commandName: String?, + defaultPrompt: String, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) { + self.code = code + self.selectionRange = selectionRange + self.language = language + self.identSize = identSize + self.usesTabsForIndentation = usesTabsForIndentation + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.allCode = allCode + self.isContinuous = isContinuous + self.commandName = commandName + self.defaultPrompt = defaultPrompt + self.extraSystemPrompt = extraSystemPrompt + self.generateDescriptionRequirement = generateDescriptionRequirement + } + } + + public enum Action: Equatable { + case createPromptToCode(PromptToCodeInitialState) + case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange) + case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID) + case updateActivePromptToCode(documentURL: URL) + case discardExpiredPromptToCode(documentURLs: [URL]) + case promptToCode(PromptToCode.State.ID, PromptToCode.Action) + } + + @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory + + public var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case let .createPromptToCode(s): + let newPromptToCode = PromptToCode.State( + code: s.code, + prompt: s.defaultPrompt, + language: s.language, + indentSize: s.identSize, + usesTabsForIndentation: s.usesTabsForIndentation, + projectRootURL: s.projectRootURL, + documentURL: s.documentURL, + allCode: s.allCode, + commandName: s.commandName, + isContinuous: s.isContinuous, + selectionRange: s.selectionRange, + extraSystemPrompt: s.extraSystemPrompt, + generateDescriptionRequirement: s.generateDescriptionRequirement + ) + // insert at 0 so it has high priority then the other detached prompt to codes + state.promptToCodes.insert(newPromptToCode, at: 0) + return .run { send in + if !newPromptToCode.prompt.isEmpty { + await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped)) + } + }.cancellable( + id: PromptToCode.CancellationKey.modifyCode(newPromptToCode.id), + cancelInFlight: true + ) + + case let .updatePromptToCodeRange(id, range): + if let p = state.promptToCodes[id: id], p.isAttachedToSelectionRange { + state.promptToCodes[id: id]?.selectionRange = range + } + return .none + + case let .discardAcceptedPromptToCodeIfNotContinuous(id): + state.promptToCodes.removeAll { $0.id == id && !$0.isContinuous } + return .none + + case let .updateActivePromptToCode(documentURL): + state.activeDocumentURL = documentURL + return .none + + case let .discardExpiredPromptToCode(documentURLs): + for url in documentURLs { + state.promptToCodes.remove(id: url) + } + return .none + + case let .promptToCode(id, action): + switch action { + case .cancelButtonTapped: + state.promptToCodes.remove(id: id) + return .none + default: + return .none + } + } + } + .forEach(\.promptToCodes, action: /Action.promptToCode, element: { + PromptToCode() + .dependency(\.promptToCodeService, promptToCodeServiceFactory()) + }) + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift index 3ce2f637..32657955 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift @@ -4,51 +4,53 @@ import Preferences import SwiftUI public struct SharedPanelFeature: ReducerProtocol { - public enum Content: Equatable { - case suggestion(SuggestionProvider) - case promptToCode(PromptToCodeProvider) - case error(String) - - var contentHash: String { - switch self { - case let .error(e): - return "error: \(e)" - case let .suggestion(provider): - return "suggestion: \(provider.code.hashValue)" - case let .promptToCode(provider): - return "provider: \(provider.id)" - } - } - - public static func == (lhs: Content, rhs: Content) -> Bool { - lhs.contentHash == rhs.contentHash - } + public struct Content: Equatable { + public var promptToCodeGroup = PromptToCodeGroup.State() + var suggestion: SuggestionProvider? + public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode } + var error: String? } public struct State: Equatable { - var content: Content? + var content: Content = .init() var colorScheme: ColorScheme = .light var alignTopToAnchor = false var isPanelDisplayed: Bool = false + var isEmpty: Bool { + if content.error != nil { return false } + if content.promptToCode != nil { return false } + if content.suggestion != nil, + UserDefaults.shared + .value(for: \.suggestionPresentationMode) == .floatingWidget { return false } + return true + } + var opacity: Double { guard isPanelDisplayed else { return 0 } - guard content != nil else { return 0 } + guard !isEmpty else { return 0 } return 1 } } public enum Action: Equatable { - case closeButtonTapped + case errorMessageCloseButtonTapped + case promptToCodeGroup(PromptToCodeGroup.Action) } public var body: some ReducerProtocol { + Scope(state: \.content.promptToCodeGroup, action: /Action.promptToCodeGroup) { + PromptToCodeGroup() + } + Reduce { state, action in switch action { - case .closeButtonTapped: - state.content = nil - state.isPanelDisplayed = false + case .errorMessageCloseButtonTapped: + state.content.error = nil + return .none + case .promptToCodeGroup: return .none } } } } + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift index b253d69a..dbd4034e 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -4,7 +4,7 @@ import SwiftUI public struct SuggestionPanelFeature: ReducerProtocol { public struct State: Equatable { - var content: SharedPanelFeature.Content? + var content: SuggestionProvider? var colorScheme: ColorScheme = .light var alignTopToAnchor = false var isPanelDisplayed: Bool = false diff --git a/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift index 559540c7..b821d4a8 100644 --- a/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/SuggestionProvider.swift @@ -1,7 +1,11 @@ import Foundation import SwiftUI -public final class SuggestionProvider: ObservableObject { +public final class SuggestionProvider: ObservableObject, Equatable { + public static func == (lhs: SuggestionProvider, rhs: SuggestionProvider) -> Bool { + lhs.code == rhs.code && lhs.language == rhs.language + } + @Published public var code: String = "" @Published public var language: String = "" @Published public var startLineIndex: Int = 0 @@ -41,3 +45,4 @@ public final class SuggestionProvider: ObservableObject { func rejectSuggestion() { onRejectSuggestionTapped() } func acceptSuggestion() { onAcceptSuggestionTapped() } } + diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index 48d4f744..cf4bac14 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -27,7 +27,6 @@ struct SharedPanelView: View { var isPanelDisplayed: Bool var opacity: Double var colorScheme: ColorScheme - var contentHash: String var alignTopToAnchor: Bool } @@ -38,42 +37,44 @@ struct SharedPanelView: View { isPanelDisplayed: $0.isPanelDisplayed, opacity: $0.opacity, colorScheme: $0.colorScheme, - contentHash: $0.content?.contentHash ?? "", alignTopToAnchor: $0.alignTopToAnchor ) } ) { viewStore in VStack(spacing: 0) { - if !viewStore.alignTopToAnchor { + if !viewStore.state.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) } - IfLetStore(store.scope(state: \.content, action: { $0 })) { store in - WithViewStore(store) { viewStore in - ZStack(alignment: .topLeading) { - switch viewStore.state { - case let .suggestion(suggestion): - switch suggestionPresentationMode { - case .nearbyTextCursor: - EmptyView() - case .floatingWidget: - CodeBlockSuggestionPanel(suggestion: suggestion) - } - case let .promptToCode(provider): - PromptToCodePanel(provider: provider) - case let .error(description): - ErrorPanel(description: description) { - viewStore.send( - .closeButtonTapped, - animation: .easeInOut(duration: 0.2) - ) + WithViewStore(store, observe: { $0.content }) { viewStore in + ZStack(alignment: .topLeading) { + if let error = viewStore.state.error { + ErrorPanel(description: error) { + viewStore.send( + .errorMessageCloseButtonTapped, + animation: .easeInOut(duration: 0.2) + ) + } + } else if let promptToCode = viewStore.state.promptToCode { + PromptToCodePanel(store: store.scope( + state: { _ in promptToCode }, + action: { + SharedPanelFeature.Action + .promptToCodeGroup(.promptToCode(promptToCode.id, $0)) } + )) + } else if let suggestion = viewStore.state.suggestion { + switch suggestionPresentationMode { + case .nearbyTextCursor: + EmptyView() + case .floatingWidget: + CodeBlockSuggestionPanel(suggestion: suggestion) } } - .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) - .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) } .allowsHitTesting(viewStore.isPanelDisplayed) .frame(maxWidth: .infinity) @@ -119,11 +120,11 @@ struct CommandButtonStyle: ButtonStyle { // MARK: - Previews -struct SuggestionPanelView_Error_Preview: PreviewProvider { +struct SharedPanelView_Error_Preview: PreviewProvider { static var previews: some View { SharedPanelView(store: .init( initialState: .init( - content: .error("This is an error\nerror"), + content: .init(error: "This is an error\nerror"), colorScheme: .light, isPanelDisplayed: true ), @@ -133,12 +134,12 @@ struct SuggestionPanelView_Error_Preview: PreviewProvider { } } -struct SuggestionPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { +struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { static var previews: some View { SharedPanelView(store: .init( initialState: .init( - content: .suggestion( - SuggestionProvider( + content: .init( + suggestion: .init( code: """ - (void)addSubview:(UIView *)view { [self addSubview:view]; diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 65d8904a..7bae1aae 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -1,23 +1,25 @@ +import ComposableArchitecture import MarkdownUI import SharedUIComponents +import SuggestionModel import SwiftUI struct PromptToCodePanel: View { - @ObservedObject var provider: PromptToCodeProvider + let store: StoreOf var body: some View { VStack(spacing: 0) { - TopBar(provider: provider) + TopBar(store: store) - Content(provider: provider) + Content(store: store) .overlay(alignment: .bottom) { - ActionBar(provider: provider) + ActionBar(store: store) .padding(.bottom, 8) } Divider() - Toolbar(provider: provider) + Toolbar(store: store) } .background(.ultraThickMaterial) .xcodeStyleFrame() @@ -26,18 +28,31 @@ struct PromptToCodePanel: View { extension PromptToCodePanel { struct TopBar: View { - @ObservedObject var provider: PromptToCodeProvider + let store: StoreOf + + struct AttachButtonState: Equatable { + var isAttachedToSelectionRange: Bool + var selectionRange: CursorRange? + } var body: some View { HStack { Button(action: { - provider.toggleAttachOrDetachToCode() + store.send(.selectionRangeToggleTapped) }) { - let attachedToRange = provider.attachedToRange - let isAttached = attachedToRange != nil - let color: Color = isAttached ? .indigo : .secondary.opacity(0.6) - HStack(spacing: 4) { - Image(systemName: isAttached ? "bandage" : "character.cursor.ibeam") + WithViewStore( + store, + observe: { AttachButtonState( + isAttachedToSelectionRange: $0.isAttachedToSelectionRange, + selectionRange: $0.selectionRange + ) } + ) { viewStore in + let isAttached = viewStore.state.isAttachedToSelectionRange + let color: Color = isAttached ? .indigo : .secondary.opacity(0.6) + HStack(spacing: 4) { + Image( + systemName: isAttached ? "bandage" : "character.cursor.ibeam" + ) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) @@ -45,36 +60,43 @@ extension PromptToCodePanel { .foregroundColor(.white) .background( color, - in: RoundedRectangle(cornerRadius: 4, style: .continuous) + in: RoundedRectangle( + cornerRadius: 4, + style: .continuous + ) ) - Text(attachedToRange?.description ?? "text cursor") - .foregroundColor(.primary) - } - .padding(2) - .padding(.trailing, 4) - .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke( - color, - lineWidth: 1 - ) - } - .background { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(color.opacity(0.2)) + let text: String = { + if isAttached, let range = viewStore.state.selectionRange { + return range.description + } + return "text cursor" + }() + Text(text).foregroundColor(.primary) + } + .padding(2) + .padding(.trailing, 4) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(color, lineWidth: 1) + } + .background { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(color.opacity(0.2)) + } + .padding(2) } - .padding(2) } - .buttonStyle(.plain) .keyboardShortcut("j", modifiers: [.command]) + .buttonStyle(.plain) Spacer() - if !provider.code.isEmpty { - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(provider.code, forType: .string) + WithViewStore(store, observe: { $0.code }) { viewStore in + if !viewStore.state.isEmpty { + CopyButton { + viewStore.send(.copyCodeButtonTapped) + } } } } @@ -83,17 +105,75 @@ extension PromptToCodePanel { } struct ActionBar: View { - @ObservedObject var provider: PromptToCodeProvider + let store: StoreOf + + struct ActionState: Equatable { + var isResponding: Bool + var isCodeEmpty: Bool + var isDescriptionEmpty: Bool + @BindingViewState var isContinuous: Bool + var isRespondingButCodeIsReady: Bool { + isResponding + && !isCodeEmpty + && !isDescriptionEmpty + } + } var body: some View { HStack { - if provider.isResponding { - Button(action: { - provider.stopResponding() - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop") + WithViewStore(store, observe: { $0.isResponding }) { viewStore in + if viewStore.state { + Button(action: { + viewStore.send(.stopRespondingButtonTapped) + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + + WithViewStore(store, observe: { + ActionState( + isResponding: $0.isResponding, + isCodeEmpty: $0.code.isEmpty, + isDescriptionEmpty: $0.description.isEmpty, + isContinuous: $0.$isContinuous + ) + }) { viewStore in + if !viewStore.state.isResponding || viewStore.state.isRespondingButCodeIsReady { + HStack { + Toggle("Continuous Mode", isOn: viewStore.$isContinuous) + .toggleStyle(.checkbox) + + Button(action: { + viewStore.send(.cancelButtonTapped) + }) { + Text("Cancel") + } + .buttonStyle(CommandButtonStyle(color: .gray)) + .keyboardShortcut("w", modifiers: [.command]) + + if !viewStore.state.isCodeEmpty { + Button(action: { + viewStore.send(.acceptButtonTapped) + }) { + Text("Accept(⌘ + ⏎)") + } + .buttonStyle(CommandButtonStyle(color: .indigo)) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + } } .padding(8) .background( @@ -105,124 +185,105 @@ extension PromptToCodePanel { .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } } - .buttonStyle(.plain) - } - - let isRespondingButCodeIsReady = provider.isResponding - && !provider.code.isEmpty - && !provider.description.isEmpty - - if !provider.isResponding || isRespondingButCodeIsReady { - HStack { - Toggle( - "Continuous Mode", - isOn: .init( - get: { provider.isContinuous }, - set: { _ in provider.toggleContinuous() } - ) - ) - .toggleStyle(.checkbox) - - Button(action: { - provider.cancel() - }) { - Text("Cancel") - } - .buttonStyle(CommandButtonStyle(color: .gray)) - .keyboardShortcut("w", modifiers: [.command]) - - if !provider.code.isEmpty { - Button(action: { - provider.acceptSuggestion() - }) { - Text("Accept(⌘ + ⏎)") - } - .buttonStyle(CommandButtonStyle(color: .indigo)) - .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) - } - } - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 6, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } } } } } struct Content: View { - @ObservedObject var provider: PromptToCodeProvider + let store: StoreOf @Environment(\.colorScheme) var colorScheme @AppStorage(\.suggestionCodeFontSize) var fontSize + struct CodeContent: Equatable { + var code: String + var language: String + var startLineIndex: Int + var firstLinePrecedingSpaceCount: Int + var isResponding: Bool + } + var body: some View { CustomScrollView { VStack(spacing: 0) { Spacer(minLength: 60) - if !provider.errorMessage.isEmpty { - Text(provider.errorMessage) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Color.red, - in: RoundedRectangle(cornerRadius: 4, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) - } - .scaleEffect(x: 1, y: -1, anchor: .center) + WithViewStore(store, observe: { $0.error }) { viewStore in + if let errorMessage = viewStore.state, !errorMessage.isEmpty { + Text(errorMessage) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Color.red, + in: RoundedRectangle(cornerRadius: 4, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + } + .scaleEffect(x: 1, y: -1, anchor: .center) + } } - if !provider.description.isEmpty { - Markdown(provider.description) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - }) - .padding() - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) + WithViewStore(store, observe: { $0.description }) { viewStore in + if !viewStore.state.isEmpty { + Markdown(viewStore.state) + .textSelection(.enabled) + .markdownTheme(.gitHub.text { + BackgroundColor(Color.clear) + }) + .padding() + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } } - if provider.code.isEmpty { - Text( - provider.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." + WithViewStore(store, observe: { + CodeContent( + code: $0.code, + language: $0.language.rawValue, + startLineIndex: $0.selectionRange?.start.line ?? 0, + firstLinePrecedingSpaceCount: $0.selectionRange?.start + .character ?? 0, + isResponding: $0.isResponding ) - .foregroundColor(.secondary) - .padding() - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } else { - CodeBlock( - code: provider.code, - language: provider.language, - startLineIndex: provider.startLineIndex, - colorScheme: colorScheme, - firstLinePrecedingSpaceCount: provider.startLineColumn, - fontSize: fontSize - ) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) - } - - if let name = provider.name { - Text(name) - .font(.footnote) + }) { viewStore in + if viewStore.state.code.isEmpty { + Text( + viewStore.state.isResponding + ? "Thinking..." + : "Enter your requirement to generate code." + ) .foregroundColor(.secondary) - .padding(.top, 12) + .padding() + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } else { + CodeBlock( + code: viewStore.state.code, + language: viewStore.state.language, + startLineIndex: viewStore.state.startLineIndex, + colorScheme: colorScheme, + firstLinePrecedingSpaceCount: viewStore.state + .firstLinePrecedingSpaceCount, + fontSize: fontSize + ) + .frame(maxWidth: .infinity) .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + + WithViewStore(store, observe: { $0.commandName }) { viewStore in + if let name = viewStore.state { + Text(name) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.top, 12) + .scaleEffect(x: 1, y: -1, anchor: .center) + } } } } @@ -231,60 +292,25 @@ extension PromptToCodePanel { } struct Toolbar: View { - @ObservedObject var provider: PromptToCodeProvider + let store: StoreOf @FocusState var isInputAreaFocused: Bool + struct RevertButtonState: Equatable { + var isResponding: Bool + var canRevert: Bool + } + + struct Prompt: Equatable { + @BindingViewState var prompt: String + } + var body: some View { HStack { - Button(action: { - provider.revert() - }) { - Group { - Image(systemName: "arrow.uturn.backward") - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle() - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - .disabled(provider.isResponding || !provider.canRevert) + revertButton HStack(spacing: 0) { - ZStack(alignment: .center) { - // a hack to support dynamic height of TextEditor - Text(provider.requirement.isEmpty ? "Hi" : provider.requirement).opacity(0) - .font(.system(size: 14)) - .frame(maxWidth: .infinity, maxHeight: 400) - .padding(.top, 1) - .padding(.bottom, 2) - .padding(.horizontal, 4) - - CustomTextEditor( - text: $provider.requirement, - font: .systemFont(ofSize: 14), - onSubmit: { provider.sendRequirement() } - ) - .padding(.top, 1) - .padding(.bottom, -1) - } - .focused($isInputAreaFocused) - .padding(8) - .fixedSize(horizontal: false, vertical: true) - - Button(action: { - provider.sendRequirement() - }) { - Image(systemName: "paperplane.fill") - .padding(8) - } - .buttonStyle(.plain) - .disabled(provider.isResponding) - .keyboardShortcut(KeyEquivalent.return, modifiers: []) + inputField + sendButton } .frame(maxWidth: .infinity) .background { @@ -296,13 +322,17 @@ extension PromptToCodePanel { .stroke(Color(nsColor: .controlColor), lineWidth: 1) } .background { - Button(action: { - provider.requirement += "\n" - }) { + Button(action: { store.send(.appendNewLineToPromptButtonTapped) }) { EmptyView() } .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) } + .background { + Button(action: { isInputAreaFocused = true }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + } } .onAppear { isInputAreaFocused = true @@ -310,6 +340,70 @@ extension PromptToCodePanel { .padding(8) .background(.ultraThickMaterial) } + + var revertButton: some View { + WithViewStore(store, observe: { + RevertButtonState(isResponding: $0.isResponding, canRevert: $0.canRevert) + }) { viewStore in + Button(action: { + viewStore.send(.revertButtonTapped) + }) { + Group { + Image(systemName: "arrow.uturn.backward") + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle() + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(viewStore.state.isResponding || !viewStore.state.canRevert) + } + } + + var inputField: some View { + WithViewStore(store, observe: { Prompt(prompt: $0.$prompt) }) { viewStore in + ZStack(alignment: .center) { + // a hack to support dynamic height of TextEditor + Text(viewStore.state.prompt.isEmpty ? "Hi" : viewStore.state.prompt) + .opacity(0) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, maxHeight: 400) + .padding(.top, 1) + .padding(.bottom, 2) + .padding(.horizontal, 4) + + CustomTextEditor( + text: viewStore.$prompt, + font: .systemFont(ofSize: 14), + onSubmit: { viewStore.send(.modifyCodeButtonTapped) } + ) + .padding(.top, 1) + .padding(.bottom, -1) + } + } + .focused($isInputAreaFocused) + .padding(8) + .fixedSize(horizontal: false, vertical: true) + } + + var sendButton: some View { + WithViewStore(store, observe: { $0.isResponding }) { viewStore in + Button(action: { + viewStore.send(.modifyCodeButtonTapped) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(viewStore.state) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + } + } } } @@ -317,7 +411,7 @@ extension PromptToCodePanel { struct PromptToCodePanel_Preview: PreviewProvider { static var previews: some View { - PromptToCodePanel(provider: PromptToCodeProvider( + PromptToCodePanel(store: .init(initialState: .init( code: """ ForEach(0.. SuggestionProvider? - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? } struct MockWidgetDataSource: SuggestionWidgetDataSource { @@ -21,9 +20,5 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { currentSuggestionIndex: 0 ) } - - func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? { - return nil - } } From a5e09357de4433a4f747df38b84e9c492d290784 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 26 Aug 2023 15:44:38 +0800 Subject: [PATCH 10/81] Hook up the service --- Core/Package.swift | 2 + ....swift => OpenAIPromptToCodeService.swift} | 12 +- .../PreviewPromptToCodeService.swift | 52 ++++++ .../PromptToCodeService.swift | 158 ------------------ .../PromptToCodeServiceType.swift | 43 +++++ Core/Sources/Service/GUI/ChatTabFactory.swift | 15 +- .../GUI/PromptToCodeProvider+Service.swift | 89 ---------- .../FeatureReducers/PromptToCode.swift | 62 +------ .../FeatureReducers/PromptToCodeGroup.swift | 1 + .../Providers/PromptToCodeProvider.swift | 93 ----------- 10 files changed, 117 insertions(+), 410 deletions(-) rename Core/Sources/PromptToCodeService/{OpenAIPromptToCodeAPI.swift => OpenAIPromptToCodeService.swift} (97%) create mode 100644 Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift delete mode 100644 Core/Sources/PromptToCodeService/PromptToCodeService.swift create mode 100644 Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift delete mode 100644 Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift delete mode 100644 Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift diff --git a/Core/Package.swift b/Core/Package.swift index 2f635ef8..35407a4d 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -161,6 +161,7 @@ let package = Package( .product(name: "SuggestionModel", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget(name: "PromptToCodeServiceTests", dependencies: ["PromptToCodeService"]), @@ -227,6 +228,7 @@ let package = Package( .target( name: "SuggestionWidget", dependencies: [ + "PromptToCodeService", "ChatGPTChatTab", .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift similarity index 97% rename from Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift rename to Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index bcd54a28..871920dd 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeAPI.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -3,16 +3,16 @@ import OpenAIService import Preferences import SuggestionModel -final class OpenAIPromptToCodeAPI: PromptToCodeAPI { +public final class OpenAIPromptToCodeService: PromptToCodeServiceType { var service: (any ChatGPTServiceType)? + + public init() {} - func stopResponding() { - Task { - await service?.stopReceivingMessage() - } + public func stopResponding() { + Task { await service?.stopReceivingMessage() } } - func modifyCode( + public func modifyCode( code: String, language: CodeLanguage, indentSize: Int, diff --git a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift new file mode 100644 index 00000000..eef1c37e --- /dev/null +++ b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift @@ -0,0 +1,52 @@ +import Foundation +import SuggestionModel + +public final class PreviewPromptToCodeService: PromptToCodeServiceType { + public init() {} + + public func modifyCode( + code: String, + language: CodeLanguage, + indentSize: Int, + usesTabsForIndentation: Bool, + requirement: String, + projectRootURL: URL, + fileURL: URL, + allCode: String, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + return AsyncThrowingStream { continuation in + Task { + let code = """ + struct Cat { + var name: String + } + + print("Hello world!") + """ + let description = "I have created a struct `Cat`." + var resultCode = "" + var resultDescription = "" + do { + for character in code { + try await Task.sleep(nanoseconds: 50_000_000) + resultCode.append(character) + continuation.yield((resultCode, resultDescription)) + } + for character in description { + try await Task.sleep(nanoseconds: 50_000_000) + resultDescription.append(character) + continuation.yield((resultCode, resultDescription)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + public func stopResponding() {} +} + diff --git a/Core/Sources/PromptToCodeService/PromptToCodeService.swift b/Core/Sources/PromptToCodeService/PromptToCodeService.swift deleted file mode 100644 index a48747e0..00000000 --- a/Core/Sources/PromptToCodeService/PromptToCodeService.swift +++ /dev/null @@ -1,158 +0,0 @@ -import Foundation -import OpenAIService -import SuggestionModel -import XcodeInspector - -public final class PromptToCodeService: ObservableObject { - var designatedPromptToCodeAPI: PromptToCodeAPI? - var promptToCodeAPI: PromptToCodeAPI { - if let designatedPromptToCodeAPI { - return designatedPromptToCodeAPI - } - - return OpenAIPromptToCodeAPI() - } - - var runningAPI: PromptToCodeAPI? - - public indirect enum HistoryNode: Equatable { - case empty - case node(code: String, description: String, previous: HistoryNode) - - mutating func enqueue(code: String, description: String) { - let current = self - self = .node(code: code, description: description, previous: current) - } - - mutating func pop() -> (code: String, description: String)? { - switch self { - case .empty: - return nil - case let .node(code, description, previous): - self = previous - return (code, description) - } - } - } - - @Published public var history: HistoryNode - @Published public var code: String - @Published public var isResponding: Bool = false - @Published public var description: String = "" - @Published public var isContinuous = false - @Published public var selectionRange: CursorRange? - @Published public var isAttachedToSelectionRange: Bool - public var canRevert: Bool { history != .empty } - public var language: CodeLanguage - public var indentSize: Int - public var usesTabsForIndentation: Bool - public var projectRootURL: URL - public var fileURL: URL - public var allCode: String - public var extraSystemPrompt: String? - public var generateDescriptionRequirement: Bool? - - public init( - code: String, - selectionRange: CursorRange?, - language: CodeLanguage, - identSize: Int, - usesTabsForIndentation: Bool, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String? = nil, - generateDescriptionRequirement: Bool? - ) { - self.code = code - self.selectionRange = selectionRange - self.language = language - indentSize = identSize - self.usesTabsForIndentation = usesTabsForIndentation - self.projectRootURL = projectRootURL - self.fileURL = fileURL - self.allCode = allCode - history = .empty - isAttachedToSelectionRange = true - self.extraSystemPrompt = extraSystemPrompt - self.generateDescriptionRequirement = generateDescriptionRequirement - } - - public func modifyCode(prompt: String) async throws { - let api = promptToCodeAPI - runningAPI = api - isResponding = true - let toBeModified = code - history.enqueue(code: code, description: description) - code = "" - description = "" - defer { isResponding = false } - do { - let stream = try await api.modifyCode( - code: toBeModified, - language: language, - indentSize: indentSize, - usesTabsForIndentation: usesTabsForIndentation, - requirement: prompt, - projectRootURL: isAttachedToSelectionRange - ? projectRootURL - : XcodeInspector.shared.activeProjectURL, - fileURL: isAttachedToSelectionRange - ? fileURL - : XcodeInspector.shared.activeDocumentURL, - allCode: allCode, - extraSystemPrompt: extraSystemPrompt, - generateDescriptionRequirement: generateDescriptionRequirement - ) - for try await fragment in stream { - code = fragment.code - description = fragment.description - } - if code.isEmpty, description.isEmpty { - revert() - } - } catch is CancellationError { - return - } catch { - if (error as NSError).code == NSURLErrorCancelled { - return - } - - revert() - throw error - } - } - - public func revert() { - guard let (code, description) = history.pop() else { return } - self.code = code - self.description = description - } - - public func stopResponding() { - runningAPI?.stopResponding() - isResponding = false - } - - public func toggleAttachOrDetachToCode() { - isAttachedToSelectionRange.toggle() - } -} - -protocol PromptToCodeAPI { - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> - - func stopResponding() -} - diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift new file mode 100644 index 00000000..7199f402 --- /dev/null +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -0,0 +1,43 @@ +import Dependencies +import Foundation +import SuggestionModel + +public protocol PromptToCodeServiceType { + func modifyCode( + code: String, + language: CodeLanguage, + indentSize: Int, + usesTabsForIndentation: Bool, + requirement: String, + projectRootURL: URL, + fileURL: URL, + allCode: String, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> + + func stopResponding() +} + +public struct PromptToCodeServiceDependencyKey: DependencyKey { + public static let liveValue: PromptToCodeServiceType = PreviewPromptToCodeService() + public static let previewValue: PromptToCodeServiceType = PreviewPromptToCodeService() +} + +public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { + public static let liveValue: () -> PromptToCodeServiceType = { OpenAIPromptToCodeService() } + public static let previewValue: () -> PromptToCodeServiceType = { PreviewPromptToCodeService() } +} + +public extension DependencyValues { + var promptToCodeService: PromptToCodeServiceType { + get { self[PromptToCodeServiceDependencyKey.self] } + set { self[PromptToCodeServiceDependencyKey.self] = newValue } + } + + var promptToCodeServiceFactory: () -> PromptToCodeServiceType { + get { self[PromptToCodeServiceFactoryDependencyKey.self] } + set { self[PromptToCodeServiceFactoryDependencyKey.self] = newValue } + } +} + diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift index 843133bc..55a9f59a 100644 --- a/Core/Sources/Service/GUI/ChatTabFactory.swift +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -83,20 +83,25 @@ enum ChatTabFactory { prompt: prompt ) case let .promptToCode(extraSystemPrompt, instruction, _, _): - let service = PromptToCodeService( + let service = OpenAIPromptToCodeService() + + let result = try await service.modifyCode( code: prompt, - selectionRange: .outOfScope, language: .plaintext, - identSize: 4, + indentSize: 4, usesTabsForIndentation: true, + requirement: instruction ?? "Modify content.", projectRootURL: .init(fileURLWithPath: "/"), fileURL: .init(fileURLWithPath: "/"), allCode: prompt, extraSystemPrompt: extraSystemPrompt, generateDescriptionRequirement: false ) - try await service.modifyCode(prompt: instruction ?? "Modify content.") - return service.code + var code = "" + for try await (newCode, _) in result { + code = newCode + } + return code } } ) diff --git a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift b/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift deleted file mode 100644 index ec0d9c17..00000000 --- a/Core/Sources/Service/GUI/PromptToCodeProvider+Service.swift +++ /dev/null @@ -1,89 +0,0 @@ -import ActiveApplicationMonitor -import Combine -import PromptToCodeService -import SuggestionWidget - -extension PromptToCodeProvider { - convenience init( - service: PromptToCodeService, - name: String?, - onClosePromptToCode: @escaping () -> Void - ) { - self.init( - code: service.code, - language: service.language.rawValue, - description: "", - attachedToRange: service.selectionRange, - name: name - ) - - var cancellables = Set() - - service.$code.sink(receiveValue: set(\.code)).store(in: &cancellables) - service.$isResponding.sink(receiveValue: set(\.isResponding)).store(in: &cancellables) - service.$description.sink(receiveValue: set(\.description)).store(in: &cancellables) - service.$isContinuous.sink(receiveValue: set(\.isContinuous)).store(in: &cancellables) - service.$history.map { $0 != .empty } - .sink(receiveValue: set(\.canRevert)).store(in: &cancellables) - service.$selectionRange.sink(receiveValue: set(\.attachedToRange)).store(in: &cancellables) - - onCancelTapped = { [cancellables] in - _ = cancellables - service.stopResponding() - onClosePromptToCode() - } - - onRevertTapped = { - service.revert() - } - - onRequirementSent = { [weak self] requirement in - Task { [weak self] in - do { - try await service.modifyCode(prompt: requirement) - } catch is CancellationError { - return - } catch { - Task { @MainActor [weak self] in - self?.errorMessage = error.localizedDescription - } - } - } - } - - onStopRespondingTap = { - service.stopResponding() - } - - onAcceptSuggestionTapped = { [weak self] in - Task { [weak self] in - let handler = PseudoCommandHandler() - await handler.acceptPromptToCode() - if let app = ActiveApplicationMonitor.shared.previousApp, - app.isXcode, - !(self?.isContinuous ?? false) - { - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() - } - } - } - - onContinuousToggleClick = { - service.isContinuous.toggle() - } - - onToggleAttachOrDetachToCode = { - service.toggleAttachOrDetachToCode() - } - } - - func set(_ keyPath: WritableKeyPath) -> (T) -> Void { - return { [weak self] value in - Task { @MainActor [weak self] in - self?[keyPath: keyPath] = value - } - } - } -} - diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index 2e1a08cf..daab8243 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -2,72 +2,15 @@ import AppKit import ComposableArchitecture import Dependencies import Foundation +import PromptToCodeService import SuggestionModel -protocol PromptToCodeService { - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> - - func stopResponding() -} - -struct PreviewPromptToCodeService: PromptToCodeService { - func modifyCode( - code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, - requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { - return AsyncThrowingStream { continuation in - continuation.finish() - } - } - - func stopResponding() {} -} - -struct PromptToCodeServiceDependencyKey: DependencyKey { - static let liveValue: PromptToCodeService = PreviewPromptToCodeService() - static let previewValue: PromptToCodeService = PreviewPromptToCodeService() -} - -struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { - static let liveValue: () -> PromptToCodeService = { PreviewPromptToCodeService() } - static let previewValue: () -> PromptToCodeService = { PreviewPromptToCodeService() } -} - struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { static let liveValue: () -> Void = {} static let previewValue: () -> Void = { print("Accept Prompt to Code") } } extension DependencyValues { - var promptToCodeService: PromptToCodeService { - get { self[PromptToCodeServiceDependencyKey.self] } - set { self[PromptToCodeServiceDependencyKey.self] = newValue } - } - - var promptToCodeServiceFactory: () -> PromptToCodeService { - get { self[PromptToCodeServiceFactoryDependencyKey.self] } - set { self[PromptToCodeServiceFactoryDependencyKey.self] = newValue } - } - var promptToCodeAcceptHandler: () -> Void { get { self[PromptToCodeAcceptHandlerDependencyKey.self] } set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue } @@ -202,6 +145,7 @@ public struct PromptToCode: ReducerProtocol { state.code = "" state.description = "" state.error = nil + state.prompt = "" return .run { send in do { @@ -291,7 +235,7 @@ public struct PromptToCode: ReducerProtocol { NSPasteboard.general.clearContents() NSPasteboard.general.setString(state.code, forType: .string) return .none - + case .appendNewLineToPromptButtonTapped: state.prompt += "\n" return .none diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index d7ca23ee..5cc19724 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import Foundation +import PromptToCodeService import SuggestionModel public struct PromptToCodeGroup: ReducerProtocol { diff --git a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift b/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift deleted file mode 100644 index b70547fc..00000000 --- a/Core/Sources/SuggestionWidget/Providers/PromptToCodeProvider.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -import SuggestionModel -import SwiftUI - -public final class PromptToCodeProvider: ObservableObject { - let id = UUID() - let name: String? - - @Published public var code: String - @Published public var language: String - @Published public var description: String - @Published public var isResponding: Bool - public var startLineIndex: Int { attachedToRange?.start.line ?? 0 } - public var startLineColumn: Int { attachedToRange?.start.character ?? 0 } - @Published public var attachedToRange: CursorRange? - @Published public var requirement: String - @Published public var errorMessage: String - @Published public var canRevert: Bool - @Published public var isContinuous: Bool - - public var onRevertTapped: () -> Void - public var onStopRespondingTap: () -> Void - public var onCancelTapped: () -> Void - public var onAcceptSuggestionTapped: () -> Void - public var onRequirementSent: (String) -> Void - public var onContinuousToggleClick: () -> Void - public var onToggleAttachOrDetachToCode: () -> Void - - public init( - code: String = "", - language: String = "", - description: String = "", - isResponding: Bool = false, - attachedToRange: CursorRange? = nil, - requirement: String = "", - errorMessage: String = "", - canRevert: Bool = false, - isContinuous: Bool = false, - name: String? = nil, - onRevertTapped: @escaping () -> Void = {}, - onStopRespondingTap: @escaping () -> Void = {}, - onCancelTapped: @escaping () -> Void = {}, - onAcceptSuggestionTapped: @escaping () -> Void = {}, - onRequirementSent: @escaping (String) -> Void = { _ in }, - onContinuousToggleClick: @escaping () -> Void = {}, - onToggleAttachOrDetachToCode: @escaping () -> Void = {} - ) { - self.code = code - self.language = language - self.description = description - self.isResponding = isResponding - self.attachedToRange = attachedToRange - self.requirement = requirement - self.errorMessage = errorMessage - self.canRevert = canRevert - self.isContinuous = isContinuous - self.name = name - self.onRevertTapped = onRevertTapped - self.onStopRespondingTap = onStopRespondingTap - self.onCancelTapped = onCancelTapped - self.onAcceptSuggestionTapped = onAcceptSuggestionTapped - self.onRequirementSent = onRequirementSent - self.onContinuousToggleClick = onContinuousToggleClick - self.onToggleAttachOrDetachToCode = onToggleAttachOrDetachToCode - } - - func revert() { - onRevertTapped() - errorMessage = "" - } - - func stopResponding() { - onStopRespondingTap() - errorMessage = "" - } - - func cancel() { onCancelTapped() } - - func sendRequirement() { - guard !isResponding else { return } - guard !requirement.isEmpty else { return } - onRequirementSent(requirement) - requirement = "" - errorMessage = "" - } - - func acceptSuggestion() { onAcceptSuggestionTapped() } - - func toggleContinuous() { onContinuousToggleClick() } - - func toggleAttachOrDetachToCode() { onToggleAttachOrDetachToCode() } -} - From 07b1c96f301a76e7cef105c24f6f4e2e8e0a8120 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 26 Aug 2023 16:00:41 +0800 Subject: [PATCH 11/81] Setup promptToCodeAcceptHandler --- .../GUI/GraphicalUserInterfaceController.swift | 14 ++++++++++++++ .../FeatureReducers/PromptToCode.swift | 17 +++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 7dd37baa..4f7eaa18 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -1,3 +1,4 @@ +import ActiveApplicationMonitor import AppKit import ChatGPTChatTab import ChatTab @@ -224,6 +225,19 @@ public final class GraphicalUserInterfaceController { dependencies.suggestionWidgetUserDefaultsObservers = .init() dependencies.chatTabPool = chatTabPool dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection + dependencies.promptToCodeAcceptHandler = { promptToCode in + Task { + let handler = PseudoCommandHandler() + await handler.acceptPromptToCode() + if let app = ActiveApplicationMonitor.shared.previousApp, + app.isXcode, + !promptToCode.isContinuous + { + try await Task.sleep(nanoseconds: 200_000_000) + app.activate() + } + } + } #if canImport(ChatTabPersistent) && canImport(ProChatTabs) dependencies.restoreChatTabInPool = { diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index daab8243..60360697 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -5,13 +5,18 @@ import Foundation import PromptToCodeService import SuggestionModel -struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { - static let liveValue: () -> Void = {} - static let previewValue: () -> Void = { print("Accept Prompt to Code") } +public struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { + public static let liveValue: (PromptToCode.State) -> Void = { _ in + assertionFailure("Please provide a handler") + } + + public static let previewValue: (PromptToCode.State) -> Void = { _ in + print("Accept Prompt to Code") + } } -extension DependencyValues { - var promptToCodeAcceptHandler: () -> Void { +public extension DependencyValues { + var promptToCodeAcceptHandler: (PromptToCode.State) -> Void { get { self[PromptToCodeAcceptHandlerDependencyKey.self] } set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue } } @@ -228,7 +233,7 @@ public struct PromptToCode: ReducerProtocol { return .none case .acceptButtonTapped: - promptToCodeAcceptHandler() + promptToCodeAcceptHandler(state) return .none case .copyCodeButtonTapped: From 0baa394320e9a40e67be75ddc5ae69811438d45e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 26 Aug 2023 16:15:07 +0800 Subject: [PATCH 12/81] Fix accepting when detached --- .../WindowBaseCommandHandler.swift | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index e42a3676..f7cd2227 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -197,16 +197,25 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return nil } - let rangeStart = promptToCode.selectionRange?.start ?? editor.cursorPosition + let range = { + if promptToCode.isAttachedToSelectionRange, + let range = promptToCode.selectionRange + { + return range + } + return editor.selections.first.map { + CursorRange(start: $0.start, end: $0.end) + } ?? CursorRange( + start: editor.cursorPosition, + end: editor.cursorPosition + ) + }() let suggestion = CodeSuggestion( text: promptToCode.code, - position: rangeStart, + position: range.start, uuid: UUID().uuidString, - range: promptToCode.selectionRange ?? .init( - start: editor.cursorPosition, - end: editor.cursorPosition - ), + range: range, displayText: promptToCode.code ) @@ -217,11 +226,11 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { extraInfo: &extraInfo ) - _ = await Task { @MainActor [cursorPosition] in + _ = await Task { @MainActor [cursorPosition] in viewStore.send( .promptToCodeGroup(.updatePromptToCodeRange( id: promptToCode.id, - range: .init(start: rangeStart, end: cursorPosition) + range: .init(start: range.start, end: cursorPosition) )) ) viewStore.send( @@ -233,7 +242,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return .init( content: String(lines.joined(separator: "")), - newSelection: .init(start: rangeStart, end: cursorPosition), + newSelection: .init(start: range.start, end: cursorPosition), modifications: extraInfo.modifications ) } From 94020285d29a0751e79da13fc4212615c09bf7a4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 26 Aug 2023 17:15:50 +0800 Subject: [PATCH 13/81] Disable text field when responding --- .../FeatureReducers/PromptToCode.swift | 2 +- .../PromptToCodePanel.swift | 21 ++++++++----------- .../SharedUIComponents/CustomTextEditor.swift | 4 ++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index 60360697..7b8beba5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -150,7 +150,6 @@ public struct PromptToCode: ReducerProtocol { state.code = "" state.description = "" state.error = nil - state.prompt = "" return .run { send in do { @@ -207,6 +206,7 @@ public struct PromptToCode: ReducerProtocol { return .none case .modifyCodeFinished: + state.prompt = "" state.isResponding = false if state.code.isEmpty, state.description.isEmpty { // if both code and description are empty, we treat it as failed diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 7bae1aae..f4568c04 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -275,16 +275,6 @@ extension PromptToCodePanel { .scaleEffect(x: 1, y: -1, anchor: .center) } } - - WithViewStore(store, observe: { $0.commandName }) { viewStore in - if let name = viewStore.state { - Text(name) - .font(.footnote) - .foregroundColor(.secondary) - .padding(.top, 12) - .scaleEffect(x: 1, y: -1, anchor: .center) - } - } } } .scaleEffect(x: 1, y: -1, anchor: .center) @@ -300,8 +290,9 @@ extension PromptToCodePanel { var canRevert: Bool } - struct Prompt: Equatable { + struct InputFieldState: Equatable { @BindingViewState var prompt: String + var isResponding: Bool } var body: some View { @@ -366,7 +357,10 @@ extension PromptToCodePanel { } var inputField: some View { - WithViewStore(store, observe: { Prompt(prompt: $0.$prompt) }) { viewStore in + WithViewStore( + store, + observe: { InputFieldState(prompt: $0.$prompt, isResponding: $0.isResponding) } + ) { viewStore in ZStack(alignment: .center) { // a hack to support dynamic height of TextEditor Text(viewStore.state.prompt.isEmpty ? "Hi" : viewStore.state.prompt) @@ -380,10 +374,13 @@ extension PromptToCodePanel { CustomTextEditor( text: viewStore.$prompt, font: .systemFont(ofSize: 14), + isEditable: !viewStore.state.isResponding, onSubmit: { viewStore.send(.modifyCodeButtonTapped) } ) .padding(.top, 1) .padding(.bottom, -1) + .opacity(viewStore.state.isResponding ? 0.5 : 1) + .disabled(viewStore.state.isResponding) } } .focused($isInputAreaFocused) diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift index 609bd02b..9ce33e6b 100644 --- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -7,18 +7,21 @@ public struct CustomTextEditor: NSViewRepresentable { @Binding public var text: String public let font: NSFont + public let isEditable: Bool public let onSubmit: () -> Void public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] public init( text: Binding, font: NSFont, + isEditable: Bool = true, onSubmit: @escaping () -> Void, completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) -> [String] = { _, _, _ in [] } ) { _text = text self.font = font + self.isEditable = isEditable self.onSubmit = onSubmit self.completions = completions } @@ -41,6 +44,7 @@ public struct CustomTextEditor: NSViewRepresentable { public func updateNSView(_ nsView: NSScrollView, context: Context) { context.coordinator.completions = completions let textView = (context.coordinator.theTextView.documentView as! NSTextView) + textView.isEditable = isEditable guard textView.string != text else { return } textView.string = text textView.undoManager?.removeAllActions() From e8b8fbdd72588ec784b9a67670403ec63f7545f2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 26 Aug 2023 17:16:00 +0800 Subject: [PATCH 14/81] Add animation to toggle --- .../SuggestionPanelContent/PromptToCodePanel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index f4568c04..6363bff9 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -38,7 +38,9 @@ extension PromptToCodePanel { var body: some View { HStack { Button(action: { - store.send(.selectionRangeToggleTapped) + withAnimation(.linear(duration: 0.1)) { + store.send(.selectionRangeToggleTapped) + } }) { WithViewStore( store, From b75cbcf36eecba51199ca9218858d296426401fc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 26 Aug 2023 18:45:19 +0800 Subject: [PATCH 15/81] Update --- .../xcshareddata/xcschemes/Copilot for Xcode.xcscheme | 2 +- .../SuggestionWidget/FeatureReducers/PanelFeature.swift | 4 +++- Version.xcconfig | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme index 1185dc66..1f9b8f1f 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme @@ -76,7 +76,7 @@ buildConfiguration = "Debug"> diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 61a9d9a7..df6fbdb7 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -109,7 +109,9 @@ public struct PanelFeature: ReducerProtocol { } case .removeDisplayedContent: -// state.content = nil + state.content.error = nil + state.content.promptToCodeGroup.activePromptToCode = nil + state.content.suggestion = nil return .none case .sharedPanel(.promptToCodeGroup(.createPromptToCode)): diff --git a/Version.xcconfig b/Version.xcconfig index f9aa5722..d9e3141c 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,2 @@ APP_VERSION = 0.22.2 -APP_BUILD = 232 +APP_BUILD = 233 From ecb962cacc04173f2419e61ab483f0cf3cc0c427 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 28 Aug 2023 20:51:39 +0800 Subject: [PATCH 16/81] Support command+l to focus on chat tab input field --- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index 761ea855..147c2446 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -398,6 +398,13 @@ struct ChatPanelInputArea: View { EmptyView() } .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + + Button(action: { + isInputAreaFocused = true + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) } } From 2f0be3f979139d197c36edd306503aa95e2dcc00 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 28 Aug 2023 20:51:58 +0800 Subject: [PATCH 17/81] Fix keyboard shortcut conflicts in chat tabs --- Core/Sources/SuggestionWidget/ChatWindowView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 247f8f00..b69cb619 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -333,8 +333,10 @@ struct ChatTabContainer: View { } else { ForEach(viewStore.state.tabInfo) { tabInfo in if let tab = chatTabPool.getTab(of: tabInfo.id) { + let isActive = tab.id == viewStore.state.selectedTabId tab.body - .opacity(tab.id == viewStore.state.selectedTabId ? 1 : 0) + .opacity(isActive ? 1 : 0) + .disabled(!isActive) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { EmptyView() From 34ecb5d4811a399335ff0bd1203622e34b265815 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 28 Aug 2023 20:57:58 +0800 Subject: [PATCH 18/81] Add a tiny reject/close button to compact suggestion panel --- .../SuggestionPanelContent/CodeBlockSuggestionPanel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 6904ed5f..c43c396d 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -72,6 +72,12 @@ struct CodeBlockSuggestionPanel: View { }.buttonStyle(.plain) Spacer() + + Button(action: { + suggestion.rejectSuggestion() + }) { + Image(systemName: "xmark") + }.buttonStyle(.plain) } .padding(4) .font(.caption) From 91980c0d197d2752a7671a0c17280b99483035e6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 28 Aug 2023 21:06:35 +0800 Subject: [PATCH 19/81] Support Codeium enterprise --- .../CodeiumService/CodeiumAuthService.swift | 9 ++++- .../CodeiumLanguageServer.swift | 14 ++++++- .../HostApp/AccountSettings/CodeiumView.swift | 38 +++++++++++++++---- Tool/Sources/Preferences/Keys.swift | 25 +++++++++--- 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/Core/Sources/CodeiumService/CodeiumAuthService.swift b/Core/Sources/CodeiumService/CodeiumAuthService.swift index 0d2f3765..9141a572 100644 --- a/Core/Sources/CodeiumService/CodeiumAuthService.swift +++ b/Core/Sources/CodeiumService/CodeiumAuthService.swift @@ -34,7 +34,14 @@ public final class CodeiumAuthService { } func generate(token: String) async throws -> String { - var request = URLRequest(url: URL(string: "https://api.codeium.com/register_user/")!) + var registerUserUrl = URL(string: "https://api.codeium.com/register_user/") + let apiUrl = UserDefaults.shared.value(for: \.codeiumApiUrl) + if UserDefaults.shared.value(for: \.codeiumEnterpriseMode), apiUrl != "" { + registerUserUrl = + URL(string: apiUrl + "/exa.api_server_pb.ApiServerService/RegisterUser") + } + + var request = URLRequest(url: registerUserUrl!) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") let requestBody = GenerateKeyRequestBody(firebase_id_token: token) diff --git a/Core/Sources/CodeiumService/CodeiumLanguageServer.swift b/Core/Sources/CodeiumService/CodeiumLanguageServer.swift index cd099943..ad0916e8 100644 --- a/Core/Sources/CodeiumService/CodeiumLanguageServer.swift +++ b/Core/Sources/CodeiumService/CodeiumLanguageServer.swift @@ -42,13 +42,23 @@ final class CodeiumLanguageServer { process.executableURL = languageServerExecutableURL + let isEnterpriseMode = UserDefaults.shared.value(for: \.codeiumEnterpriseMode) + var apiServerUrl = "https://server.codeium.com" + if isEnterpriseMode, UserDefaults.shared.value(for: \.codeiumApiUrl) != "" { + apiServerUrl = UserDefaults.shared.value(for: \.codeiumApiUrl) + } + process.arguments = [ "--api_server_url", - "https://server.codeium.com", + apiServerUrl, "--manager_dir", managerDirectoryURL.path, ] + if isEnterpriseMode { + process.arguments?.append("--enterprise_mode") + } + process.currentDirectoryURL = supportURL process.terminationHandler = { [weak self] task in @@ -120,7 +130,7 @@ final class CodeiumLanguageServer { self.port = port launchHandler?() } - + func terminate() { process.terminationHandler = nil if process.isRunning { diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift index 53e1f76e..2ca683c4 100644 --- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift +++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift @@ -10,6 +10,9 @@ struct CodeiumView: View { @Published var installationStatus: CodeiumInstallationManager.InstallationStatus @Published var installationStep: CodeiumInstallationManager.InstallationStep? @AppStorage(\.codeiumVerboseLog) var codeiumVerboseLog + @AppStorage(\.codeiumEnterpriseMode) var codeiumEnterpriseMode + @AppStorage(\.codeiumPortalUrl) var codeiumPortalUrl + @AppStorage(\.codeiumApiUrl) var codeiumApiUrl init() { isSignedIn = codeiumAuthService.isSignedIn @@ -28,6 +31,13 @@ struct CodeiumView: View { } func generateAuthURL() -> URL { + if codeiumEnterpriseMode && (codeiumPortalUrl != "") { + return URL( + string: codeiumPortalUrl + + "/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query" + )! + } + return URL( string: "https://www.codeium.com/profile?response_type=token&redirect_uri=show-auth-token&state=\(UUID().uuidString)&scope=openid%20profile%20email&redirect_parameters_type=query" )! @@ -149,10 +159,10 @@ struct CodeiumView: View { updateButton } } - + if viewModel.isSignedIn { Text("Status: Signed In") - + Button(action: { Task { do { @@ -166,7 +176,7 @@ struct CodeiumView: View { } } else { Text("Status: Not Signed In") - + Button(action: { isSignInPanelPresented = true }) { @@ -197,9 +207,23 @@ struct CodeiumView: View { } } } - + + Divider() + + Form { + Toggle("Codeium Enterprise Mode", isOn: $viewModel.codeiumEnterpriseMode) + TextField("Codeium Portal URL", text: $viewModel.codeiumPortalUrl) + TextField("Codeium API URL", text: $viewModel.codeiumApiUrl) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) + } + Divider() - + Form { Toggle("Verbose Log", isOn: $viewModel.codeiumVerboseLog) } @@ -233,13 +257,13 @@ struct CodeiumSignInView: View { HStack { Spacer() - + Button(action: { isPresented = false }) { Text("Cancel") } - + Button(action: { isGeneratingKey = true Task { diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 57a4490a..81c7ce12 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -189,6 +189,18 @@ public extension UserDefaultPreferenceKeys { var codeiumVerboseLog: PreferenceKey { .init(defaultValue: false, key: "CodeiumVerboseLog") } + + var codeiumEnterpriseMode: PreferenceKey { + .init(defaultValue: false, key: "CodeiumEnterpriseMode") + } + + var codeiumPortalUrl: PreferenceKey { + .init(defaultValue: "", key: "CodeiumPortalUrl") + } + + var codeiumApiUrl: PreferenceKey { + .init(defaultValue: "", key: "CodeiumApiUrl") + } } // MARK: - Prompt to Code @@ -217,7 +229,7 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionToggle: PreferenceKey { .init(defaultValue: true, key: "RealtimeSuggestionToggle") } - + var suggestionDisplayCompactMode: PreferenceKey { .init(defaultValue: false, key: "SuggestionDisplayCompactMode") } @@ -249,7 +261,7 @@ public extension UserDefaultPreferenceKeys { var realtimeSuggestionDebounce: PreferenceKey { .init(defaultValue: 0, key: "RealtimeSuggestionDebounce") } - + var acceptSuggestionWithTab: PreferenceKey { .init(defaultValue: false, key: "AcceptSuggestionWithTab") } @@ -409,13 +421,16 @@ public extension UserDefaultPreferenceKeys { var enableXcodeInspectorDebugMenu: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-EnableXcodeInspectorDebugMenu") } - + var disableFunctionCalling: FeatureFlag { .init(defaultValue: false, key: "FeatureFlag-DisableFunctionCalling") } - + var disableGitHubCopilotSettingsAutoRefreshOnAppear: FeatureFlag { - .init(defaultValue: false, key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear") + .init( + defaultValue: false, + key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear" + ) } } From b504b9b9c9161d24cee0e24cf051ec56cf07f22c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 28 Aug 2023 22:01:55 +0800 Subject: [PATCH 20/81] Update chat tab to build it's own tab item --- .../ChatGPTChatTab/ChatContextMenu.swift | 20 ++++++---- .../ChatGPTChatTab/ChatGPTChatTab.swift | 2 +- .../SuggestionWidget/ChatWindowView.swift | 37 ++++++++++--------- Tool/Sources/ChatTab/ChatTab.swift | 10 ++--- 4 files changed, 38 insertions(+), 31 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index 238f1b01..c8c816f9 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -7,15 +7,21 @@ struct ChatContextMenu: View { @AppStorage(\.customCommands) var customCommands var body: some View { - Group { - currentSystemPrompt - currentExtraSystemPrompt - resetPrompt + Text(chat.title) + .contextMenu { + menu + } + } + + @ViewBuilder + var menu: some View { + currentSystemPrompt + currentExtraSystemPrompt + resetPrompt - Divider() + Divider() - customCommandMenu - } + customCommandMenu } @ViewBuilder diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 85413186..890af792 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -41,7 +41,7 @@ public class ChatGPTChatTab: ChatTab { ChatPanel(chat: provider) } - public func buildMenu() -> any View { + public func buildTabItem() -> any View { ChatContextMenu(chat: provider) } diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index b69cb619..b727b2eb 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -175,18 +175,16 @@ struct ChatTabBar: View { ScrollView(.horizontal) { HStack(spacing: 0) { ForEach(viewStore.state.tabInfo, id: \.id) { info in - ChatTabBarButton( - store: store, - info: info, - isSelected: info.id == viewStore.state.selectedTabId - ) - .id(info.id) - .contextMenu { - if let tab = chatTabPool.getTab(of: info.id) { - tab.menu - } else { - EmptyView() - } + if let tab = chatTabPool.getTab(of: info.id) { + ChatTabBarButton( + store: store, + info: info, + content: { tab.tabItem }, + isSelected: info.id == viewStore.state.selectedTabId + ) + .id(info.id) + } else { + EmptyView() } } } @@ -263,9 +261,10 @@ struct ChatTabBar: View { } } -struct ChatTabBarButton: View { +struct ChatTabBarButton: View { let store: StoreOf let info: ChatTabInfo + let content: () -> Content let isSelected: Bool @State var isHovered: Bool = false @@ -274,7 +273,7 @@ struct ChatTabBarButton: View { Button(action: { store.send(.tabClicked(id: info.id)) }) { - Text(info.title) + content() .font(.callout) .lineLimit(1) .frame(maxWidth: 120) @@ -372,10 +371,12 @@ class FakeChatTab: ChatTab { } } - func buildMenu() -> any View { - Text("Menu Item") - Text("Menu Item") - Text("Menu Item") + func buildTabItem() -> any View { + Text("Fake") + .contextMenu { + Text("Menu Item") + Text("Menu Item") + } } func buildView() -> any View { diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 4103f0e4..71de2627 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -25,7 +25,7 @@ public protocol ChatTabType { func buildView() -> any View /// Build the menu for this chat tab. @ViewBuilder - func buildMenu() -> any View + func buildTabItem() -> any View /// The name of this chat tab type. static var name: String { get } /// Available builders for this chat tab. @@ -84,10 +84,10 @@ open class BaseChatTab { /// The menu for this chat tab. @ViewBuilder - public var menu: some View { + public var tabItem: some View { let id = "ChatTabMenu\(id)" if let tab = self as? (any ChatTabType) { - ContentView(buildView: tab.buildMenu).id(id) + ContentView(buildView: tab.buildTabItem).id(id) .onAppear { Task { @MainActor in self.startIfNotStarted() } } @@ -162,8 +162,8 @@ public class EmptyChatTab: ChatTab { .background(Color.blue) } - public func buildMenu() -> any View { - EmptyView() + public func buildTabItem() -> any View { + Text("Empty-\(id)") } public func restorableState() async -> Data { From 22c805630aa6415d34a7b3379159f315327ca023 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 28 Aug 2023 22:02:29 +0800 Subject: [PATCH 21/81] Support editing bookmark --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 790fd58d..d385e16f 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 790fd58df73b593ed683e0986b5d3424dd4e684a +Subproject commit d385e16fa7e75515069d1e463a68243e19c3a1a9 From 45581f81c63d9084a84f829258cb5f2d644c0b7f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 28 Aug 2023 22:08:27 +0800 Subject: [PATCH 22/81] Support reordering bookmarks --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index d385e16f..ceb42ac6 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit d385e16fa7e75515069d1e463a68243e19c3a1a9 +Subproject commit ceb42ac650c8898aab7c937d967b34b13fb1cfb9 From dcc56b4a47b4e43b17ae688c1abd2efeb26bc2e9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 29 Aug 2023 17:01:59 +0800 Subject: [PATCH 23/81] Split chat tab item and menu --- .../ChatGPTChatTab/ChatContextMenu.swift | 16 +-- .../ChatGPTChatTab/ChatGPTChatTab.swift | 4 + .../SuggestionWidget/ChatWindowView.swift | 102 +++++------------- .../SuggestionWidget/ModuleDependency.swift | 5 +- Tool/Sources/ChatTab/ChatTab.swift | 25 ++++- 5 files changed, 63 insertions(+), 89 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index c8c816f9..6ead6a06 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -2,19 +2,19 @@ import AppKit import SharedUIComponents import SwiftUI +struct ChatTabItemView: View { + @ObservedObject var chat: ChatProvider + + var body: some View { + Text(chat.title) + } +} + struct ChatContextMenu: View { @ObservedObject var chat: ChatProvider @AppStorage(\.customCommands) var customCommands var body: some View { - Text(chat.title) - .contextMenu { - menu - } - } - - @ViewBuilder - var menu: some View { currentSystemPrompt currentExtraSystemPrompt resetPrompt diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 890af792..8c72e72b 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -42,6 +42,10 @@ public class ChatGPTChatTab: ChatTab { } public func buildTabItem() -> any View { + ChatTabItemView(chat: provider) + } + + public func buildMenu() -> any View { ChatContextMenu(chat: provider) } diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index b727b2eb..0b610bae 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -182,6 +182,9 @@ struct ChatTabBar: View { content: { tab.tabItem }, isSelected: info.id == viewStore.state.selectedTabId ) + .contextMenu { + tab.menu + } .id(info.id) } else { EmptyView() @@ -359,62 +362,8 @@ struct CreateOtherChatTabMenuStyle: MenuStyle { } } -class FakeChatTab: ChatTab { - static var name: String { "Fake" } - static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { [Builder()] } - - struct Builder: ChatTabBuilder { - var title: String = "Title" - - func build(store: StoreOf) async -> (any ChatTab)? { - return FakeChatTab(store: store) - } - } - - func buildTabItem() -> any View { - Text("Fake") - .contextMenu { - Text("Menu Item") - Text("Menu Item") - } - } - - func buildView() -> any View { - ChatPanel( - chat: .init( - history: [ - .init(id: "1", role: .assistant, text: "Hello World"), - ], - isReceivingMessage: false - ), - typedMessage: "Hello World!" - ) - } - - func restorableState() async -> Data { - return Data() - } - - static func restore( - from data: Data, - externalDependency: () - ) async throws -> any ChatTabBuilder { - return Builder() - } - - convenience init(id: String, title: String) { - self.init(store: .init( - initialState: .init(id: id, title: title), - reducer: ChatTabItem() - )) - } - - func start() {} -} - struct ChatWindowView_Previews: PreviewProvider { static let pool = ChatTabPool([ - "1": FakeChatTab(id: "1", title: "Hello I am a chatbot"), "2": EmptyChatTab(id: "2"), "3": EmptyChatTab(id: "3"), "4": EmptyChatTab(id: "4"), @@ -423,30 +372,31 @@ struct ChatWindowView_Previews: PreviewProvider { "7": EmptyChatTab(id: "7"), ]) - static var previews: some View { - ChatWindowView( - store: .init( - initialState: .init( - chatTabGroup: .init( - tabInfo: [ - .init(id: "1", title: "Fake"), - .init(id: "2", title: "Empty-2"), - .init(id: "3", title: "Empty-3"), - .init(id: "4", title: "Empty-4"), - .init(id: "5", title: "Empty-5"), - .init(id: "6", title: "Empty-6"), - .init(id: "7", title: "Empty-7"), - ], - selectedTabId: "1" - ), - isPanelDisplayed: true + static func createStore() -> StoreOf { + StoreOf( + initialState: .init( + chatTabGroup: .init( + tabInfo: [ + .init(id: "2", title: "Empty-2"), + .init(id: "3", title: "Empty-3"), + .init(id: "4", title: "Empty-4"), + .init(id: "5", title: "Empty-5"), + .init(id: "6", title: "Empty-6"), + .init(id: "7", title: "Empty-7"), + ], + selectedTabId: "2" ), - reducer: ChatPanelFeature() - ) + isPanelDisplayed: true + ), + reducer: ChatPanelFeature() ) - .xcodeStyleFrame() - .padding() - .environment(\.chatTabPool, pool) + } + + static var previews: some View { + ChatWindowView(store: createStore()) + .xcodeStyleFrame() + .padding() + .environment(\.chatTabPool, pool) } } diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index 304b4620..0a38ee6e 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -75,9 +75,7 @@ struct ActiveApplicationMonitorKey: DependencyKey { } struct ChatTabBuilderCollectionKey: DependencyKey { - static let liveValue: () -> [ChatTabBuilderCollection] = { - [.folder(title: "A", kinds: FakeChatTab.chatBuilders().map(ChatTabKind.init))] - } + static let liveValue: () -> [ChatTabBuilderCollection] = { [] } } struct ActivatePreviouslyActiveXcodeKey: DependencyKey { @@ -135,3 +133,4 @@ extension DependencyValues { set { self[ActivateExtensionServiceKey.self] = newValue } } } + diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 71de2627..fc2fe82f 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -23,9 +23,12 @@ public protocol ChatTabType { /// Build the view for this chat tab. @ViewBuilder func buildView() -> any View - /// Build the menu for this chat tab. + /// Build the tabItem for this chat tab. @ViewBuilder func buildTabItem() -> any View + /// Build the menu for this chat tab. + @ViewBuilder + func buildMenu() -> any View /// The name of this chat tab type. static var name: String { get } /// Available builders for this chat tab. @@ -82,7 +85,7 @@ open class BaseChatTab { } } - /// The menu for this chat tab. + /// The tab item for this chat tab. @ViewBuilder public var tabItem: some View { let id = "ChatTabMenu\(id)" @@ -95,6 +98,20 @@ open class BaseChatTab { EmptyView().id(id) } } + + /// The tab item for this chat tab. + @ViewBuilder + public var menu: some View { + let id = "ChatTabMenu\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildMenu).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } @MainActor func startIfNotStarted() { @@ -165,6 +182,10 @@ public class EmptyChatTab: ChatTab { public func buildTabItem() -> any View { Text("Empty-\(id)") } + + public func buildMenu() -> any View { + Text("Empty-\(id)") + } public func restorableState() async -> Data { return Data() From 4a589e7bbdd490fa9692d99539a35c8a7047fb7f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 29 Aug 2023 17:03:43 +0800 Subject: [PATCH 24/81] Update browser tab --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index ceb42ac6..b5d94157 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit ceb42ac650c8898aab7c937d967b34b13fb1cfb9 +Subproject commit b5d94157e6340af49d473bc6a60839211692a3cc From b9de17703142446196a81c19cad386eae5c1e82d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 29 Aug 2023 17:04:13 +0800 Subject: [PATCH 25/81] Fix warnings --- .../SuggestionWidget/FeatureReducers/WidgetFeature.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 42537284..2c09dbb4 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -53,7 +53,7 @@ public struct WidgetFeature: ReducerProtocol { return true } if panelState.sharedPanelState.isPanelDisplayed, - panelState.sharedPanelState.content != nil + !panelState.sharedPanelState.isEmpty { return true } @@ -65,7 +65,7 @@ public struct WidgetFeature: ReducerProtocol { return false }(), isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty - && panelState.sharedPanelState.content == nil, + && panelState.sharedPanelState.isEmpty, isChatPanelDetached: chatPanelState.chatPanelInASeparateWindow, isChatOpen: chatPanelState.isPanelDisplayed, animationProgress: circularWidgetState.animationProgress @@ -311,7 +311,7 @@ public struct WidgetFeature: ReducerProtocol { let documentURL = state.focusingDocumentURL - return .run { send in + return .run { [app] send in await send(.observeEditorChange) let notifications = AXNotificationStream( From 87c2339ca7f0b5eed9eca2a600783a913737eb3c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 1 Sep 2023 23:45:15 +0800 Subject: [PATCH 26/81] Make RealtimeSuggestionController an actor --- .../RealtimeSuggestionController.swift | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 84a2ed5f..63ed9a38 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -12,7 +12,7 @@ import QuartzCore import Workspace import XcodeInspector -public class RealtimeSuggestionController { +public actor RealtimeSuggestionController { let eventObserver: CGEventObserverType = CGEventObserver(eventsOfInterest: [.keyDown]) private var task: Task? private var inflightPrefetchTask: Task? @@ -23,12 +23,13 @@ public class RealtimeSuggestionController { private var sourceEditor: SourceEditor? init() {} - + + nonisolated func start() { Task { [weak self] in if let app = ActiveApplicationMonitor.shared.activeXcode { - self?.handleXcodeChanged(app) - self?.startHIDObservation() + await self?.handleXcodeChanged(app) + await self?.startHIDObservation() } var previousApp = ActiveApplicationMonitor.shared.activeXcode for await app in ActiveApplicationMonitor.shared.createStream() { @@ -37,13 +38,13 @@ public class RealtimeSuggestionController { defer { previousApp = app } if let app = ActiveApplicationMonitor.shared.activeXcode, app != previousApp { - self.handleXcodeChanged(app) + await self.handleXcodeChanged(app) } if ActiveApplicationMonitor.shared.activeXcode != nil { - startHIDObservation() + await startHIDObservation() } else { - stopHIDObservation() + await stopHIDObservation() } } } @@ -85,7 +86,7 @@ public class RealtimeSuggestionController { for await _ in notifications { guard let self else { return } try Task.checkCancellation() - self.handleFocusElementChange() + await self.handleFocusElementChange() } } } @@ -112,7 +113,7 @@ public class RealtimeSuggestionController { editorObservationTask = Task { [weak self] in let fileURL = try await Environment.fetchCurrentFileURL() - if let sourceEditor = self?.sourceEditor { + if let sourceEditor = await self?.sourceEditor { await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor @@ -132,10 +133,10 @@ public class RealtimeSuggestionController { switch notification.name { case kAXValueChangedNotification: await cancelInFlightTasks() - self.triggerPrefetchDebounced() + await self.triggerPrefetchDebounced() await self.notifyEditingFileChange(editor: focusElement) case kAXSelectedTextChangedNotification: - guard let sourceEditor else { continue } + guard let sourceEditor = await sourceEditor else { continue } let fileURL = XcodeInspector.shared.activeDocumentURL await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, From b639bbc9186ff0ef2b3a3af551dc01ea1a689610 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 2 Sep 2023 16:16:06 +0800 Subject: [PATCH 27/81] Update --- Version.xcconfig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index c5391dee..c54c75c3 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,3 @@ APP_VERSION = 0.22.3 -APP_BUILD = 233 +APP_BUILD = 234 + From c6a870a3d2feeffe2276dbf478cf982c544ae5c2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 1 Sep 2023 23:32:07 +0800 Subject: [PATCH 28/81] Migrate extension service to use new way to setup model --- .../xcshareddata/swiftpm/Package.resolved | 9 + Core/Package.swift | 7 + .../SearchFunction.swift | 3 + .../ServiceUpdateMigration/MigrateTo135.swift | 47 +++++ .../ServiceUpdateMigration/MigrateTo230.swift | 109 ++++++++++ .../ServiceUpdateMigrator.swift | 49 +---- ...nknownLanguageFocusedCodeFinderTests.swift | 1 + .../ExtractCodeFromChatGPTTests.swift | 10 +- .../MigrateTo230Tests.swift | 192 ++++++++++++++++++ TestPlan.xctestplan | 7 + Tool/Package.swift | 11 +- Tool/Sources/AIModel/ChatModel.swift | 79 +++++++ Tool/Sources/AIModel/EmbeddingModel.swift | 75 +++++++ Tool/Sources/Keychain/Keychain.swift | 91 ++++++++- .../OpenAIService/ChatGPTService.swift | 21 +- .../Sources/OpenAIService/CompletionAPI.swift | 14 +- .../OpenAIService/CompletionStreamAPI.swift | 14 +- .../Configuration/ChatGPTConfiguration.swift | 32 +-- .../EmbeddingConfiguration.swift | 31 +-- .../UserPreferenceChatGPTConfiguration.swift | 63 ++---- ...UserPreferenceEmbeddingConfiguration.swift | 65 ++---- .../OpenAIService/EmbeddingService.swift | 14 +- Tool/Sources/Preferences/AppStorage.swift | 141 ++++++++++++- Tool/Sources/Preferences/Keys.swift | 111 +++++++--- Tool/Sources/Preferences/UserDefaults.swift | 85 +++++++- 25 files changed, 1017 insertions(+), 264 deletions(-) create mode 100644 Core/Sources/ServiceUpdateMigration/MigrateTo135.swift create mode 100644 Core/Sources/ServiceUpdateMigration/MigrateTo230.swift create mode 100644 Core/Tests/ServiceUpdateMigrationTests/MigrateTo230Tests.swift create mode 100644 Tool/Sources/AIModel/ChatModel.swift create mode 100644 Tool/Sources/AIModel/EmbeddingModel.swift diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9d688e1b..99ca18ea 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "1.2.0" } }, + { + "identity" : "codablewrappers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GottaGetSwifty/CodableWrappers", + "state" : { + "revision" : "4eb46a4c656333e8514db8aad204445741de7d40", + "version" : "2.0.7" + } + }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", diff --git a/Core/Package.swift b/Core/Package.swift index c89c4f4f..7abdf0b6 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -261,6 +261,13 @@ let package = Package( dependencies: [ "GitHubCopilotService", .product(name: "Preferences", package: "Tool"), + .product(name: "Keychain", package: "Tool"), + ] + ), + .testTarget( + name: "ServiceUpdateMigrationTests", + dependencies: [ + "ServiceUpdateMigration", ] ), .target( diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 8bb360dd..00208529 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -72,6 +72,9 @@ struct SearchFunction: ChatGPTFunction { subscriptionKey: UserDefaults.shared.value(for: \.bingSearchSubscriptionKey), searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) ) + + #warning("request chat service to pass in the token length") + let result = try await bingSearch.search( query: arguments.query, numberOfResult: UserDefaults.shared.value(for: \.chatGPTMaxToken) > 5000 ? 5 : 3, diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift new file mode 100644 index 00000000..40b58c94 --- /dev/null +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo135.swift @@ -0,0 +1,47 @@ +import Foundation +import GitHubCopilotService + +func migrateFromLowerThanOrEqualToVersion135() throws { + // 0. Create the application support folder if it doesn't exist + + let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() + + // 1. Move the undefined folder in application support into a sub folder called `GitHub + // Copilot/support` + + let undefinedFolderURL = urls.applicationSupportURL.appendingPathComponent("undefined") + var isUndefinedADirectory: ObjCBool = false + let isUndefinedExisted = FileManager.default.fileExists( + atPath: undefinedFolderURL.path, + isDirectory: &isUndefinedADirectory + ) + if isUndefinedExisted, isUndefinedADirectory.boolValue { + try FileManager.default.moveItem( + at: undefinedFolderURL, + to: urls.supportURL.appendingPathComponent("undefined") + ) + } + + // 2. Copy the GitHub copilot language service to `GitHub Copilot/executable` + + let copilotFolderURL = urls.executableURL.appendingPathComponent("copilot") + var copilotIsFolder: ObjCBool = false + let executable = Bundle.main.resourceURL?.appendingPathComponent("copilot") + if let executable, + FileManager.default.fileExists(atPath: executable.path, isDirectory: &copilotIsFolder), + !FileManager.default.fileExists(atPath: copilotFolderURL.path) + { + try FileManager.default.copyItem( + at: executable, + to: urls.executableURL.appendingPathComponent("copilot") + ) + } + + // 3. Use chmod to change the permission of the executable to 755 + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: copilotFolderURL.path + ) +} + diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo230.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo230.swift new file mode 100644 index 00000000..1da30e42 --- /dev/null +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo230.swift @@ -0,0 +1,109 @@ +import AIModel +import Foundation +import Keychain +import Preferences + +func migrateTo230( + defaults: UserDefaults = .shared, + keychain: KeychainType = Keychain(scope: "apikey") +) throws { + let key = UserDefaultPreferenceKeys().embeddingModels.key + if defaults.value(forKey: key) != nil { return } + + let chatModelOpenAIId = UUID().uuidString + let chatModelAzureOpenAIId = UUID().uuidString + let embeddingModelOpenAIId = UUID().uuidString + let embeddingModelAzureOpenAIId = UUID().uuidString + + let openAIAPIKeyName = "OpenAI" + let openAIAPIKey = defaults.deprecatedValue(for: \.openAIAPIKey) + if !openAIAPIKey.isEmpty { + try keychain.update(openAIAPIKey, key: openAIAPIKeyName) + } + + let azureOpenAIAPIKeyName = "Azure OpenAI" + let azureOpenAIAPIKey = defaults.deprecatedValue(for: \.azureOpenAIAPIKey) + if !azureOpenAIAPIKey.isEmpty { + try keychain.update(azureOpenAIAPIKey, key: azureOpenAIAPIKeyName) + } + + defaults.setupDefaultValue(for: \.chatModels, defaultValue: { + let openAIModel = ChatGPTModel(rawValue: defaults.deprecatedValue(for: \.chatGPTModel)) + + let openAI = ChatModel( + id: chatModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? defaults + .deprecatedValue(for: \.chatGPTMaxToken), + modelName: openAIModel?.rawValue ?? defaults + .deprecatedValue(for: \.chatGPTModel) + ) + ) + let azureOpenAI = ChatModel( + id: chatModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: defaults.deprecatedValue(for: \.chatGPTMaxToken), + modelName: defaults + .deprecatedValue(for: \.azureChatGPTDeployment) + ) + ) + + return [openAI, azureOpenAI] + }()) + + defaults.setupDefaultValue(for: \.defaultChatFeatureChatModelId, defaultValue: { + if defaults.deprecatedValue(for: \.chatFeatureProvider) == .azureOpenAI { + return chatModelAzureOpenAIId + } + return chatModelOpenAIId + }()) + + defaults.setupDefaultValue(for: \.embeddingModels, defaultValue: { + let openAIModel = OpenAIEmbeddingModel( + rawValue: defaults.deprecatedValue(for: \.embeddingModel) + ) + + let openAI = EmbeddingModel( + id: embeddingModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? 8191, + modelName: openAIModel?.rawValue ?? defaults.deprecatedValue(for: \.embeddingModel) + ) + ) + + let azureOpenAI = EmbeddingModel( + id: embeddingModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: 8191, + modelName: defaults + .deprecatedValue(for: \.azureEmbeddingDeployment) + ) + ) + + return [openAI, azureOpenAI] + }()) + + defaults.setupDefaultValue(for: \.defaultChatFeatureEmbeddingModelId, defaultValue: { + if defaults.deprecatedValue(for: \.embeddingFeatureProvider) == .azureOpenAI { + return embeddingModelAzureOpenAIId + } + return embeddingModelOpenAIId + }()) +} + diff --git a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift index b47a4feb..021c7d15 100644 --- a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift +++ b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift @@ -1,6 +1,5 @@ import Configs import Foundation -import GitHubCopilotService import Preferences extension UserDefaultPreferenceKeys { @@ -27,50 +26,8 @@ public struct ServiceUpdateMigrator { if old <= 135 { try migrateFromLowerThanOrEqualToVersion135() } + if old <= 229 { + try migrateTo230() + } } } - -func migrateFromLowerThanOrEqualToVersion135() throws { - // 0. Create the application support folder if it doesn't exist - - let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() - - // 1. Move the undefined folder in application support into a sub folder called `GitHub - // Copilot/support` - - let undefinedFolderURL = urls.applicationSupportURL.appendingPathComponent("undefined") - var isUndefinedADirectory: ObjCBool = false - let isUndefinedExisted = FileManager.default.fileExists( - atPath: undefinedFolderURL.path, - isDirectory: &isUndefinedADirectory - ) - if isUndefinedExisted, isUndefinedADirectory.boolValue { - try FileManager.default.moveItem( - at: undefinedFolderURL, - to: urls.supportURL.appendingPathComponent("undefined") - ) - } - - // 2. Copy the GitHub copilot language service to `GitHub Copilot/executable` - - let copilotFolderURL = urls.executableURL.appendingPathComponent("copilot") - var copilotIsFolder: ObjCBool = false - let executable = Bundle.main.resourceURL?.appendingPathComponent("copilot") - if let executable, - FileManager.default.fileExists(atPath: executable.path, isDirectory: &copilotIsFolder), - !FileManager.default.fileExists(atPath: copilotFolderURL.path) - { - try FileManager.default.copyItem( - at: executable, - to: urls.executableURL.appendingPathComponent("copilot") - ) - } - - // 3. Use chmod to change the permission of the executable to 755 - - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: copilotFolderURL.path - ) -} - diff --git a/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift b/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift index 90aa7dd9..2e840f5f 100644 --- a/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift +++ b/Core/Tests/ActiveDocumentChatContextCollectorTests/UnknownLanguageFocusedCodeFinderTests.swift @@ -1,5 +1,6 @@ import XCTest import Foundation +import FocusedCodeFinder @testable import ActiveDocumentChatContextCollector diff --git a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift index 3f5c6970..27bb7075 100644 --- a/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift +++ b/Core/Tests/PromptToCodeServiceTests/ExtractCodeFromChatGPTTests.swift @@ -3,7 +3,7 @@ import XCTest final class ExtractCodeFromChatGPTTests: XCTestCase { func test_extract_from_no_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ hello world! """) @@ -13,7 +13,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_incomplete_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ```swift func foo() {} @@ -24,7 +24,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_complete_code_block() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ```swift func foo() {} @@ -40,7 +40,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_incomplete_code_block_without_language() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ``` func foo() {} @@ -51,7 +51,7 @@ final class ExtractCodeFromChatGPTTests: XCTestCase { } func test_extract_from_code_block_without_language() { - let api = OpenAIPromptToCodeAPI() + let api = OpenAIPromptToCodeService() let result = api.extractCodeAndDescription(from: """ ``` func foo() {} diff --git a/Core/Tests/ServiceUpdateMigrationTests/MigrateTo230Tests.swift b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo230Tests.swift new file mode 100644 index 00000000..2113e2a8 --- /dev/null +++ b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo230Tests.swift @@ -0,0 +1,192 @@ +import Foundation +import Keychain +import XCTest + +@testable import ServiceUpdateMigration + +final class MigrateTo230Tests: XCTestCase { + let userDefaults = UserDefaults(suiteName: "MigrateTo230Tests")! + + override func tearDown() async throws { + userDefaults.removePersistentDomain(forName: "MigrateTo230Tests") + } + + func test_migrateTo230_no_data_to_migrate() async throws { + let keychain = FakeKeyChain() + + try migrateTo230(defaults: userDefaults, keychain: keychain) + + XCTAssertTrue(try keychain.getAll().isEmpty, "No api key to migrate") + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + for chatModel in chatModels { + switch chatModel.format { + case .openAI: + XCTAssertEqual(chatModel.name, "OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "", + maxTokens: 4096, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + )) + case .azureOpenAI: + XCTAssertEqual(chatModel.name, "Azure OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "", + maxTokens: 4000, + supportsFunctionCalling: true, + modelName: "" + )) + default: + XCTFail() + } + } + + for embeddingModel in embeddingModels { + switch embeddingModel.format { + case .openAI: + XCTAssertEqual(embeddingModel.name, "OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "", + maxTokens: 8191, + modelName: "text-embedding-ada-002" + )) + case .azureOpenAI: + XCTAssertEqual(embeddingModel.name, "Azure OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "", + maxTokens: 8191, + modelName: "" + )) + default: + XCTFail() + } + } + } + + func test_migrateTo230_migrate_data_use_openAI() async throws { + let keychain = FakeKeyChain() + + userDefaults.set("Key1", forKey: "OpenAIAPIKey") + userDefaults.set("openai.com", forKey: "OpenAIBaseURL") + userDefaults.set("gpt-500", forKey: "ChatGPTModel") + userDefaults.set(200, forKey: "ChatGPTMaxToken") + userDefaults.set("embedding-200", forKey: "OpenAIEmbeddingModel") + userDefaults.set("Key2", forKey: "AzureOpenAIAPIKey") + userDefaults.set("azure.com", forKey: "AzureOpenAIBaseURL") + userDefaults.set("gpt-800", forKey: "AzureChatGPTDeployment") + userDefaults.set("embedding-800", forKey: "AzureEmbeddingDeployment") + userDefaults.set("openAI", forKey: "ChatFeatureProvider") + userDefaults.set("openAI", forKey: "EmbeddingFeatureProvider") + + try migrateTo230(defaults: userDefaults, keychain: keychain) + + XCTAssertEqual(try keychain.getAll(), [ + "OpenAI": "Key1", + "Azure OpenAI": "Key2", + ]) + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + XCTAssertEqual(chatModels.count, 2) + XCTAssertEqual(embeddingModels.count, 2) + + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureChatModelId), + chatModels.first(where: { $0.format == .openAI })?.id + ) + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureEmbeddingModelId), + embeddingModels.first(where: { $0.format == .openAI })?.id + ) + + for chatModel in chatModels { + switch chatModel.format { + case .openAI: + XCTAssertEqual(chatModel.name, "OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "openai.com", + maxTokens: 200, + supportsFunctionCalling: true, + modelName: "gpt-500" + )) + case .azureOpenAI: + XCTAssertEqual(chatModel.name, "Azure OpenAI") + XCTAssertEqual(chatModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "azure.com", + maxTokens: 200, + supportsFunctionCalling: true, + modelName: "gpt-800" + )) + default: + XCTFail() + } + } + + for embeddingModel in embeddingModels { + switch embeddingModel.format { + case .openAI: + XCTAssertEqual(embeddingModel.name, "OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "OpenAI", + baseURL: "openai.com", + maxTokens: 8191, + modelName: "embedding-200" + )) + case .azureOpenAI: + XCTAssertEqual(embeddingModel.name, "Azure OpenAI") + XCTAssertEqual(embeddingModel.info, .init( + apiKeyName: "Azure OpenAI", + baseURL: "azure.com", + maxTokens: 8191, + modelName: "embedding-800" + )) + default: + XCTFail() + } + } + } + + func test_migrateTo230_migrate_data_use_azureOpenAI() async throws { + let keychain = FakeKeyChain() + + userDefaults.set("Key1", forKey: "OpenAIAPIKey") + userDefaults.set("openai.com", forKey: "OpenAIBaseURL") + userDefaults.set("gpt-500", forKey: "ChatGPTModel") + userDefaults.set(200, forKey: "ChatGPTMaxToken") + userDefaults.set("embedding-200", forKey: "OpenAIEmbeddingModel") + userDefaults.set("Key2", forKey: "AzureOpenAIAPIKey") + userDefaults.set("azure.com", forKey: "AzureOpenAIBaseURL") + userDefaults.set("gpt-800", forKey: "AzureChatGPTDeployment") + userDefaults.set("embedding-800", forKey: "AzureEmbeddingDeployment") + userDefaults.set("azureOpenAI", forKey: "ChatFeatureProvider") + userDefaults.set("azureOpenAI", forKey: "EmbeddingFeatureProvider") + + try migrateTo230(defaults: userDefaults, keychain: keychain) + + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + + XCTAssertEqual(chatModels.count, 2) + XCTAssertEqual(embeddingModels.count, 2) + + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureChatModelId), + chatModels.first(where: { $0.format == .azureOpenAI })?.id + ) + XCTAssertEqual( + userDefaults.value(for: \.defaultChatFeatureEmbeddingModelId), + embeddingModels.first(where: { $0.format == .azureOpenAI })?.id + ) + } +} + diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index d45389dd..bc08db6d 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -126,6 +126,13 @@ "identifier" : "ChatTabPersistentTests", "name" : "ChatTabPersistentTests" } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index c1b00ade..db4a449c 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -49,6 +49,7 @@ let package = Package( from: "0.55.0" ), .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), + .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), // TreeSitter .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", from: "0.7.1"), @@ -63,7 +64,7 @@ let package = Package( .target(name: "Configs"), - .target(name: "Preferences", dependencies: ["Configs"]), + .target(name: "Preferences", dependencies: ["Configs", "AIModel"]), .target(name: "Terminal"), @@ -122,6 +123,13 @@ let package = Package( ] ), + .target( + name: "AIModel", + dependencies: [ + .product(name: "CodableWrappers", package: "CodableWrappers"), + ] + ), + .testTarget( name: "SuggestionModelTests", dependencies: ["SuggestionModel"] @@ -219,6 +227,7 @@ let package = Package( "Logger", "Preferences", "TokenEncoder", + "Keychain", .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift new file mode 100644 index 00000000..f0a9e3c5 --- /dev/null +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -0,0 +1,79 @@ +import CodableWrappers +import Foundation + +public struct ChatModel: Codable, Equatable { + 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 { + case openAI + case openAIFormat + case azureOpenAI + } + + public struct Info: Codable, Equatable { + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var supportsFunctionCalling: Bool + @FallbackDecoding + public var modelName: String + public var azureOpenAIDeploymentName: String { + get { modelName } + set { modelName = newValue } + } + + public init( + apiKeyName: String = "", + baseURL: String = "", + maxTokens: Int = 4000, + supportsFunctionCalling: Bool = true, + modelName: String = "" + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.maxTokens = maxTokens + self.supportsFunctionCalling = supportsFunctionCalling + self.modelName = modelName + } + } + + public var endpoint: String { + switch format { + case .openAI, .openAIFormat: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } + return "\(baseURL)/v1/chat/completions" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.azureOpenAIDeploymentName + let version = "2023-07-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" + } + } +} + +public struct EmptyChatModelInfo: FallbackValueProvider { + public static var defaultValue: ChatModel.Info { .init() } +} + +public struct EmptyChatModelFormat: FallbackValueProvider { + public static var defaultValue: ChatModel.Format { .openAI } +} + diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift new file mode 100644 index 00000000..40cf5bd9 --- /dev/null +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -0,0 +1,75 @@ +import Foundation +import CodableWrappers + +public struct EmbeddingModel: Codable, Equatable { + 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 { + case openAI + case azureOpenAI + case openAIFormat + } + + public struct Info: Codable, Equatable { + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var modelName: String + public var azureOpenAIDeploymentName: String { + get { modelName } + set { modelName = newValue } + } + + public init( + apiKeyName: String = "", + baseURL: String = "", + maxTokens: Int = 8192, + modelName: String = "" + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.maxTokens = maxTokens + self.modelName = modelName + } + } + + public var endpoint: String { + switch format { + case .openAI, .openAIFormat: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/embeddings" } + return "\(baseURL)/v1/embeddings" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.azureOpenAIDeploymentName + let version = "2023-07-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/embeddings?api-version=\(version)" + } + } +} + + +public struct EmptyEmbeddingModelInfo: FallbackValueProvider { + public static var defaultValue: EmbeddingModel.Info { .init() } +} + +public struct EmptyEmbeddingModelFormat: FallbackValueProvider { + public static var defaultValue: EmbeddingModel.Format { .openAI } +} diff --git a/Tool/Sources/Keychain/Keychain.swift b/Tool/Sources/Keychain/Keychain.swift index 82b249dc..c9851f59 100644 --- a/Tool/Sources/Keychain/Keychain.swift +++ b/Tool/Sources/Keychain/Keychain.swift @@ -2,19 +2,52 @@ import Configs import Foundation import Security -public struct Keychain { +public protocol KeychainType { + func getAll() throws -> [String: String] + func update(_ value: String, key: String) throws + func get(_ key: String) throws -> String? + func remove(_ key: String) throws +} + +public final class FakeKeyChain: KeychainType { + var values: [String: String] = [:] + + public init() {} + + public func getAll() throws -> [String: String] { + values + } + + public func update(_ value: String, key: String) throws { + values[key] = value + } + + public func get(_ key: String) throws -> String? { + values[key] + } + + public func remove(_ key: String) throws { + values[key] = nil + } +} + +public struct Keychain: KeychainType { let service = keychainService let accessGroup = keychainAccessGroup + let scope: String public enum Error: Swift.Error { case failedToDeleteFromKeyChain case failedToUpdateOrSetItem } - public init() {} - + public init(scope: String = "") { + self.scope = scope + } + func query(_ key: String) -> [String: Any] { - [ + let key = scopeKey(key) + return [ kSecClass as String: kSecClassGenericPassword as String, kSecAttrService as String: service, kSecAttrAccessGroup as String: accessGroup, @@ -24,6 +57,7 @@ public struct Keychain { } func set(_ value: String, key: String) throws { + let key = scopeKey(key) let query = query(key).merging([ kSecValueData as String: value.data(using: .utf8) ?? Data(), ], uniquingKeysWith: { _, b in b }) @@ -38,6 +72,54 @@ public struct Keychain { } } + func scopeKey(_ key: String) -> String { + if scope.isEmpty { + return key + } + return "\(scope).\(key)" + } + + func escapeScope(_ key: String) -> String? { + if scope.isEmpty { + return key + } + if !key.hasPrefix("\(scope).") { return nil } + return key.replacingOccurrences(of: "\(scope).", with: "") + } + + public func getAll() throws -> [String : String] { + let query = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] as [String: Any] + + var result: AnyObject? + if SecItemCopyMatching(query as CFDictionary, &result) == noErr { + guard let items = result as? [[String: Any]] else { + return [:] + } + + var dict = [String: String]() + for item in items { + guard let keyData = item[kSecAttrAccount as String] as? Data, + let key = String(data: keyData, encoding: .utf8), + let valueData = item[kSecValueData as String] as? Data, + let value = String(data: valueData, encoding: .utf8), + let escapedKey = escapeScope(key) + else { + continue + } + dict[escapedKey] = value + } + } + + return [:] + } + public func update(_ value: String, key: String) throws { let query = query(key).merging([ kSecMatchLimit as String: kSecMatchLimitOne, @@ -87,3 +169,4 @@ public struct Keychain { throw Error.failedToDeleteFromKeyChain } } + diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 406dafe4..c9eaa96c 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -196,15 +196,17 @@ extension ChatGPTService { ) } let remainingTokens = await memory.remainingTokens + + let model = configuration.model let requestBody = CompletionRequestBody( - model: configuration.model, + model: model.info.modelName, messages: messages, temperature: configuration.temperature, stream: true, stop: configuration.stop.isEmpty ? nil : configuration.stop, max_tokens: maxTokenForReply( - model: configuration.model, + maxToken: model.info.maxTokens, remainingTokens: remainingTokens ), function_call: functionProvider.functionCallStrategy, @@ -219,7 +221,7 @@ extension ChatGPTService { let api = buildCompletionStreamAPI( configuration.apiKey, - configuration.featureProvider, + model, url, requestBody ) @@ -296,15 +298,17 @@ extension ChatGPTService { ) } let remainingTokens = await memory.remainingTokens + + let model = configuration.model let requestBody = CompletionRequestBody( - model: configuration.model, + model: model.info.modelName, messages: messages, temperature: configuration.temperature, stream: true, stop: configuration.stop.isEmpty ? nil : configuration.stop, max_tokens: maxTokenForReply( - model: configuration.model, + maxToken: model.info.maxTokens, remainingTokens: remainingTokens ), function_call: functionProvider.functionCallStrategy, @@ -319,7 +323,7 @@ extension ChatGPTService { let api = buildCompletionAPI( configuration.apiKey, - configuration.featureProvider, + model, url, requestBody ) @@ -467,9 +471,8 @@ extension ChatGPTService { } } -func maxTokenForReply(model: String, remainingTokens: Int?) -> Int? { +func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { guard let remainingTokens else { return nil } - guard let model = ChatGPTModel(rawValue: model) else { return remainingTokens } - return min(model.maxToken / 2, remainingTokens) + return min(maxToken / 2, remainingTokens) } diff --git a/Tool/Sources/OpenAIService/CompletionAPI.swift b/Tool/Sources/OpenAIService/CompletionAPI.swift index e092411f..842229fc 100644 --- a/Tool/Sources/OpenAIService/CompletionAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionAPI.swift @@ -1,7 +1,8 @@ +import AIModel import Foundation import Preferences -typealias CompletionAPIBuilder = (String, ChatFeatureProvider, URL, CompletionRequestBody) +typealias CompletionAPIBuilder = (String, ChatModel, URL, CompletionRequestBody) -> CompletionAPI protocol CompletionAPI { @@ -66,11 +67,11 @@ struct OpenAICompletionAPI: CompletionAPI { var apiKey: String var endpoint: URL var requestBody: CompletionRequestBody - var provider: ChatFeatureProvider + var model: ChatModel init( apiKey: String, - provider: ChatFeatureProvider, + model: ChatModel, endpoint: URL, requestBody: CompletionRequestBody ) { @@ -78,7 +79,7 @@ struct OpenAICompletionAPI: CompletionAPI { self.endpoint = endpoint self.requestBody = requestBody self.requestBody.stream = false - self.provider = provider + self.model = model } func callAsFunction() async throws -> CompletionResponseBody { @@ -88,9 +89,10 @@ struct OpenAICompletionAPI: CompletionAPI { request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { - if provider == .openAI { + switch model.format { + case .openAI, .openAIFormat: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } else { + case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") } } diff --git a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift index 6b39734b..0e30fcb4 100644 --- a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift @@ -1,8 +1,9 @@ import AsyncAlgorithms import Foundation import Preferences +import AIModel -typealias CompletionStreamAPIBuilder = (String, ChatFeatureProvider, URL, CompletionRequestBody) +typealias CompletionStreamAPIBuilder = (String, ChatModel, URL, CompletionRequestBody) -> CompletionStreamAPI protocol CompletionStreamAPI { @@ -155,11 +156,11 @@ struct OpenAICompletionStreamAPI: CompletionStreamAPI { var apiKey: String var endpoint: URL var requestBody: CompletionRequestBody - var provider: ChatFeatureProvider + var model: ChatModel init( apiKey: String, - provider: ChatFeatureProvider, + model: ChatModel, endpoint: URL, requestBody: CompletionRequestBody ) { @@ -167,7 +168,7 @@ struct OpenAICompletionStreamAPI: CompletionStreamAPI { self.endpoint = endpoint self.requestBody = requestBody self.requestBody.stream = true - self.provider = provider + self.model = model } func callAsFunction() async throws -> ( @@ -180,9 +181,10 @@ struct OpenAICompletionStreamAPI: CompletionStreamAPI { request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { - if provider == .openAI { + switch model.format { + case .openAI, .openAIFormat: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } else { + case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") } } diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index aa93a61e..66e1d1c1 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -1,11 +1,11 @@ import Foundation +import AIModel import Preferences +import Keychain public protocol ChatGPTConfiguration { - var featureProvider: ChatFeatureProvider { get } + var model: ChatModel { get } var temperature: Double { get } - var model: String { get } - var endpoint: String { get } var apiKey: String { get } var stop: [String] { get } var maxTokens: Int { get } @@ -14,28 +14,12 @@ public protocol ChatGPTConfiguration { } public extension ChatGPTConfiguration { - func endpoint(for provider: ChatFeatureProvider) -> String { - switch provider { - case .openAI: - let baseURL = UserDefaults.shared.value(for: \.openAIBaseURL) - if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } - return "\(baseURL)/v1/chat/completions" - case .azureOpenAI: - let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) - let deployment = UserDefaults.shared.value(for: \.azureChatGPTDeployment) - let version = "2023-07-01-preview" - if baseURL.isEmpty { return "" } - return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)" - } + var endpoint: String { + model.endpoint } - - func apiKey(for provider: ChatFeatureProvider) -> String { - switch provider { - case .openAI: - return UserDefaults.shared.value(for: \.openAIAPIKey) - case .azureOpenAI: - return UserDefaults.shared.value(for: \.azureOpenAIAPIKey) - } + + var apiKey: String { + (try? Keychain(scope: "apikey").get(model.info.apiKeyName)) ?? "" } func overriding( diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index 13c4ab21..4ccbe96b 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -1,37 +1,22 @@ +import AIModel import Foundation +import Keychain import Preferences public protocol EmbeddingConfiguration { - var featureProvider: EmbeddingFeatureProvider { get } + var model: EmbeddingModel { get } var endpoint: String { get } var apiKey: String { get } var maxToken: Int { get } - var model: String { get } } public extension EmbeddingConfiguration { - func endpoint(for provider: EmbeddingFeatureProvider) -> String { - switch provider { - case .openAI: - let baseURL = UserDefaults.shared.value(for: \.openAIBaseURL) - if baseURL.isEmpty { return "https://api.openai.com/v1/embeddings" } - return "\(baseURL)/v1/embeddings" - case .azureOpenAI: - let baseURL = UserDefaults.shared.value(for: \.azureOpenAIBaseURL) - let deployment = UserDefaults.shared.value(for: \.azureEmbeddingDeployment) - let version = "2023-07-01-preview" - if baseURL.isEmpty { return "" } - return "\(baseURL)/openai/deployments/\(deployment)/embeddings?api-version=\(version)" - } + var endpoint: String { + model.endpoint } - - func apiKey(for provider: EmbeddingFeatureProvider) -> String { - switch provider { - case .openAI: - return UserDefaults.shared.value(for: \.openAIAPIKey) - case .azureOpenAI: - return UserDefaults.shared.value(for: \.azureOpenAIAPIKey) - } + + var apiKey: String { + (try? Keychain(scope: "apikey").get(model.info.apiKeyName)) ?? "" } func overriding( diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index d4ce6ed2..4efb4311 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -1,31 +1,21 @@ +import AIModel import Foundation import Preferences public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { - public var featureProvider: ChatFeatureProvider { - UserDefaults.shared.value(for: \.chatFeatureProvider) - } - public var temperature: Double { min(max(0, UserDefaults.shared.value(for: \.chatGPTTemperature)), 2) } - public var model: String { - let value = UserDefaults.shared.value(for: \.chatGPTModel) - if value.isEmpty { return "gpt-3.5-turbo" } - return value - } - - public var endpoint: String { - endpoint(for: featureProvider) - } - - public var apiKey: String { - apiKey(for: featureProvider) + public var model: ChatModel { + let models = UserDefaults.shared.value(for: \.chatModels) + let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId) + return models.first { $0.id == id } + ?? models.first ?? .init(id: "", name: "", format: .openAI, info: .init()) } public var maxTokens: Int { - UserDefaults.shared.value(for: \.chatGPTMaxToken) + model.info.maxTokens } public var stop: [String] { @@ -45,11 +35,8 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public struct Overriding: Codable { - public var featureProvider: ChatFeatureProvider? public var temperature: Double? - public var model: String? - public var endPoint: String? - public var apiKey: String? + public var modelId: String? public var stop: [String]? public var maxTokens: Int? public var minimumReplyTokens: Int? @@ -57,23 +44,17 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public init( temperature: Double? = nil, - model: String? = nil, + modelId: String? = nil, stop: [String]? = nil, maxTokens: Int? = nil, minimumReplyTokens: Int? = nil, - featureProvider: ChatFeatureProvider? = nil, - endPoint: String? = nil, - apiKey: String? = nil, runFunctionsAutomatically: Bool? = nil ) { self.temperature = temperature - self.model = model + self.modelId = modelId self.stop = stop self.maxTokens = maxTokens self.minimumReplyTokens = minimumReplyTokens - self.featureProvider = featureProvider - self.endPoint = endPoint - self.apiKey = apiKey self.runFunctionsAutomatically = runFunctionsAutomatically } } @@ -89,28 +70,16 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { self.configuration = configuration } - public var featureProvider: ChatFeatureProvider { - overriding.featureProvider ?? configuration.featureProvider - } - public var temperature: Double { overriding.temperature ?? configuration.temperature } - public var model: String { - overriding.model ?? configuration.model - } - - public var endpoint: String { - overriding.endPoint - ?? overriding.featureProvider.map(endpoint(for:)) - ?? configuration.endpoint - } - - public var apiKey: String { - overriding.apiKey - ?? overriding.featureProvider.map(apiKey(for:)) - ?? configuration.apiKey + public var model: ChatModel { + let models = UserDefaults.shared.value(for: \.chatModels) + guard let id = overriding.modelId, + let model = models.first(where: { $0.id == id }) + else { return configuration.model } + return model } public var stop: [String] { diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift index 44a92d3a..d8bb980c 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift @@ -1,27 +1,19 @@ +import AIModel import Foundation import Preferences public struct UserPreferenceEmbeddingConfiguration: EmbeddingConfiguration { - public var featureProvider: EmbeddingFeatureProvider { - UserDefaults.shared.value(for: \.embeddingFeatureProvider) - } - - public var model: String { - OpenAIEmbeddingModel.textEmbeddingAda002.rawValue - } - - public var endpoint: String { - endpoint(for: featureProvider) - } - - public var apiKey: String { - apiKey(for: featureProvider) + public var model: EmbeddingModel { + let models = UserDefaults.shared.value(for: \.embeddingModels) + let id = UserDefaults.shared.value(for: \.defaultChatFeatureEmbeddingModelId) + return models.first { $0.id == id } + ?? models.first ?? .init(id: "", name: "", format: .openAI, info: .init()) } public var maxToken: Int { - OpenAIEmbeddingModel.textEmbeddingAda002.maxToken + model.info.maxTokens } - + public init() {} } @@ -29,23 +21,14 @@ public class OverridingEmbeddingConfiguration< Configuration: EmbeddingConfiguration >: EmbeddingConfiguration { public struct Overriding { - var featureProvider: EmbeddingFeatureProvider? - var model: String? - var endPoint: String? - var apiKey: String? + var modelId: String? var maxTokens: Int? public init( - model: String? = nil, - featureProvider: EmbeddingFeatureProvider? = nil, - endPoint: String? = nil, - apiKey: String? = nil, + modelId: String? = nil, maxTokens: Int? = nil ) { - self.model = model - self.featureProvider = featureProvider - self.endPoint = endPoint - self.apiKey = apiKey + self.modelId = modelId self.maxTokens = maxTokens } } @@ -54,30 +37,18 @@ public class OverridingEmbeddingConfiguration< public var overriding = Overriding() public init(overriding configuration: Configuration, with overrides: Overriding = .init()) { - self.overriding = overrides + overriding = overrides self.configuration = configuration } - public var featureProvider: EmbeddingFeatureProvider { - overriding.featureProvider ?? configuration.featureProvider - } - - public var model: String { - overriding.model ?? configuration.model + public var model: EmbeddingModel { + let models = UserDefaults.shared.value(for: \.embeddingModels) + guard let id = overriding.modelId, + let model = models.first(where: { $0.id == id }) + else { return configuration.model } + return model } - public var endpoint: String { - overriding.endPoint - ?? overriding.featureProvider.map(endpoint(for:)) - ?? configuration.endpoint - } - - public var apiKey: String { - overriding.apiKey - ?? overriding.featureProvider.map(apiKey(for:)) - ?? configuration.apiKey - } - public var maxToken: Int { overriding.maxTokens ?? configuration.maxToken } diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index f4f0a713..fa11a03d 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -44,17 +44,18 @@ public struct EmbeddingService { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + let model = configuration.model var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() request.httpBody = try encoder.encode(EmbeddingRequestBody( input: text, - model: configuration.model + model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !configuration.apiKey.isEmpty { - switch configuration.featureProvider { - case .openAI: + switch model.format { + case .openAI, .openAIFormat: request.setValue( "Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization" @@ -92,17 +93,18 @@ public struct EmbeddingService { guard let url = URL(string: configuration.endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + let model = configuration.model var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() request.httpBody = try encoder.encode(EmbeddingFromTokensRequestBody( input: tokens, - model: configuration.model + model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !configuration.apiKey.isEmpty { - switch configuration.featureProvider { - case .openAI: + switch model.format { + case .openAI, .openAIFormat: request.setValue( "Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization" diff --git a/Tool/Sources/Preferences/AppStorage.swift b/Tool/Sources/Preferences/AppStorage.swift index 73bcee5f..a5b3b214 100644 --- a/Tool/Sources/Preferences/AppStorage.swift +++ b/Tool/Sources/Preferences/AppStorage.swift @@ -109,17 +109,154 @@ public extension AppStorage where Value: ExpressibleByNilLiteral { public extension AppStorage { init( _ keyPath: KeyPath - ) where K.Value == Value, Value == R?, R : RawRepresentable, R.RawValue == String { + ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == String { let key = UserDefaultPreferenceKeys()[keyPath: keyPath] self.init(key.key, store: .shared) } init( _ keyPath: KeyPath - ) where K.Value == Value, Value == R?, R : RawRepresentable, R.RawValue == Int { + ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +// MARK: - Deprecated Key Accessor + +public extension AppStorage { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Bool { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Double { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == URL { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Data { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value: RawRepresentable, Value.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value: RawRepresentable, Value.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } +} + +public extension AppStorage where Value: ExpressibleByNilLiteral { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Bool? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == String? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Double? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Int? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == URL? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Data? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +public extension AppStorage { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == R?, R: RawRepresentable, R.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == R?, R: RawRepresentable, R.RawValue == Int { let key = UserDefaultPreferenceKeys()[keyPath: keyPath] self.init(key.key, store: .shared) } } #endif + diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 81c7ce12..450c0db0 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -1,3 +1,4 @@ +import AIModel import Foundation public protocol UserDefaultPreferenceKey { @@ -16,6 +17,16 @@ public struct PreferenceKey: UserDefaultPreferenceKey { } } +public struct DeprecatedPreferenceKey { + public let defaultValue: T + public let key: String + + public init(defaultValue: T, key: String) { + self.defaultValue = defaultValue + self.key = key + } +} + public struct FeatureFlag: UserDefaultPreferenceKey { public let defaultValue: Bool public let key: String @@ -82,40 +93,23 @@ public struct UserDefaultPreferenceKeys { // MARK: - OpenAI Account Settings public extension UserDefaultPreferenceKeys { - var openAIAPIKey: PreferenceKey { + var openAIAPIKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "OpenAIAPIKey") } - @available(*, deprecated, message: "Use `openAIBaseURL` instead.") - var chatGPTEndpoint: PreferenceKey { - .init(defaultValue: "", key: "ChatGPTEndpoint") - } - - var openAIBaseURL: PreferenceKey { + var openAIBaseURL: DeprecatedPreferenceKey { .init(defaultValue: "", key: "OpenAIBaseURL") } - var chatGPTModel: PreferenceKey { + var chatGPTModel: DeprecatedPreferenceKey { .init(defaultValue: ChatGPTModel.gpt35Turbo.rawValue, key: "ChatGPTModel") } - var chatGPTMaxToken: PreferenceKey { + var chatGPTMaxToken: DeprecatedPreferenceKey { .init(defaultValue: 4000, key: "ChatGPTMaxToken") } - var chatGPTLanguage: PreferenceKey { - .init(defaultValue: "", key: "ChatGPTLanguage") - } - - var chatGPTMaxMessageCount: PreferenceKey { - .init(defaultValue: 5, key: "ChatGPTMaxMessageCount") - } - - var chatGPTTemperature: PreferenceKey { - .init(defaultValue: 0.7, key: "ChatGPTTemperature") - } - - var embeddingModel: PreferenceKey { + var embeddingModel: DeprecatedPreferenceKey { .init( defaultValue: OpenAIEmbeddingModel.textEmbeddingAda002.rawValue, key: "OpenAIEmbeddingModel" @@ -126,19 +120,19 @@ public extension UserDefaultPreferenceKeys { // MARK: - Azure OpenAI Settings public extension UserDefaultPreferenceKeys { - var azureOpenAIAPIKey: PreferenceKey { + var azureOpenAIAPIKey: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureOpenAIAPIKey") } - var azureOpenAIBaseURL: PreferenceKey { + var azureOpenAIBaseURL: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureOpenAIBaseURL") } - var azureChatGPTDeployment: PreferenceKey { + var azureChatGPTDeployment: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureChatGPTDeployment") } - var azureEmbeddingDeployment: PreferenceKey { + var azureEmbeddingDeployment: DeprecatedPreferenceKey { .init(defaultValue: "", key: "AzureEmbeddingDeployment") } } @@ -203,6 +197,59 @@ public extension UserDefaultPreferenceKeys { } } +// MARK: - Chat Models + +public extension UserDefaultPreferenceKeys { + var chatModels: PreferenceKey<[ChatModel]> { + .init(defaultValue: [ + .init( + id: UUID().uuidString, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: "", + baseURL: "", + maxTokens: ChatGPTModel.gpt35Turbo.maxToken, + supportsFunctionCalling: true, + modelName: ChatGPTModel.gpt35Turbo.rawValue + ) + ) + ], key: "ChatModels") + } + + var chatGPTLanguage: PreferenceKey { + .init(defaultValue: "", key: "ChatGPTLanguage") + } + + var chatGPTMaxMessageCount: PreferenceKey { + .init(defaultValue: 5, key: "ChatGPTMaxMessageCount") + } + + var chatGPTTemperature: PreferenceKey { + .init(defaultValue: 0.7, key: "ChatGPTTemperature") + } +} + +// MARK: - Embedding Models + +public extension UserDefaultPreferenceKeys { + var embeddingModels: PreferenceKey<[EmbeddingModel]> { + .init(defaultValue: [ + .init( + id: UUID().uuidString, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: "", + baseURL: "", + maxTokens: OpenAIEmbeddingModel.textEmbeddingAda002.maxToken, + modelName: OpenAIEmbeddingModel.textEmbeddingAda002.rawValue + ) + ) + ], key: "EmbeddingModels") + } +} + // MARK: - Prompt to Code public extension UserDefaultPreferenceKeys { @@ -270,13 +317,21 @@ public extension UserDefaultPreferenceKeys { // MARK: - Chat public extension UserDefaultPreferenceKeys { - var chatFeatureProvider: PreferenceKey { + var chatFeatureProvider: DeprecatedPreferenceKey { .init(defaultValue: .openAI, key: "ChatFeatureProvider") } + + var defaultChatFeatureChatModelId: PreferenceKey { + .init(defaultValue: "", key: "DefaultChatFeatureChatModelId") + } - var embeddingFeatureProvider: PreferenceKey { + var embeddingFeatureProvider: DeprecatedPreferenceKey { .init(defaultValue: .openAI, key: "EmbeddingFeatureProvider") } + + var defaultChatFeatureEmbeddingModelId: PreferenceKey { + .init(defaultValue: "", key: "DefaultChatFeatureEmbeddingModelId") + } var chatFontSize: PreferenceKey { .init(defaultValue: 12, key: "ChatFontSize") diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 207edcc5..2204fd1a 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -1,5 +1,6 @@ -import Foundation +import AIModel import Configs +import Foundation public extension UserDefaults { static var shared = UserDefaults(suiteName: userDefaultSuiteName)! @@ -13,12 +14,8 @@ public extension UserDefaults { shared.setupDefaultValue(for: \.widgetColorScheme) shared.setupDefaultValue(for: \.customCommands) shared.setupDefaultValue(for: \.runNodeWith, defaultValue: .env) - shared.setupDefaultValue(for: \.openAIBaseURL, defaultValue: { - guard let url = URL(string: shared.value(for: \.chatGPTEndpoint)) else { return "" } - let scheme = url.scheme ?? "https" - guard let host = url.host else { return "" } - return "\(scheme)://\(host)" - }() as String) + shared.setupDefaultValue(for: \.chatModels) + shared.setupDefaultValue(for: \.embeddingModels) } } @@ -52,7 +49,7 @@ extension Array: RawRepresentable where Element: Codable { } public extension UserDefaults { - // MARK: - Normal Types + // MARK: Normal Types func value( for keyPath: KeyPath @@ -77,7 +74,7 @@ public extension UserDefaults { set(key.defaultValue, forKey: key.key) } } - + func setupDefaultValue( for keyPath: KeyPath, defaultValue: K.Value @@ -88,7 +85,7 @@ public extension UserDefaults { } } - // MARK: - Raw Representable + // MARK: Raw Representable func value( for keyPath: KeyPath @@ -146,3 +143,71 @@ public extension UserDefaults { } } } + +// MARK: - Deprecated Key Accessor + +public extension UserDefaults { + // MARK: Normal Types + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + return (value(forKey: key.key) as? K) ?? key.defaultValue + } + + // MARK: Raw Representable + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? Int else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } +} + +public extension UserDefaults { + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + return (value(forKey: key.key) as? K) ?? key.defaultValue + } + + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } + + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? Int else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } +} + From bdb8ee05eb06ef410208a74e5283a0e209d8b009 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 1 Sep 2023 23:42:18 +0800 Subject: [PATCH 29/81] WIP --- .../HostApp/AccountSettings/AzureView.swift | 1 - .../HostApp/AccountSettings/ChatModel.swift | 16 ++++++++++++++++ .../HostApp/AccountSettings/EmbeddingModel.swift | 2 ++ .../HostApp/AccountSettings/OpenAIView.swift | 1 - 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 Core/Sources/HostApp/AccountSettings/ChatModel.swift create mode 100644 Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift diff --git a/Core/Sources/HostApp/AccountSettings/AzureView.swift b/Core/Sources/HostApp/AccountSettings/AzureView.swift index cfe34903..2d73a96f 100644 --- a/Core/Sources/HostApp/AccountSettings/AzureView.swift +++ b/Core/Sources/HostApp/AccountSettings/AzureView.swift @@ -48,7 +48,6 @@ struct AzureView: View { let reply = try await ChatGPTService( configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(featureProvider: .azureOpenAI)) ) .sendAndWait(content: "Hello", summary: nil) toast("ChatGPT replied: \(reply ?? "N/A")", .info) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModel.swift b/Core/Sources/HostApp/AccountSettings/ChatModel.swift new file mode 100644 index 00000000..ec186eb9 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModel.swift @@ -0,0 +1,16 @@ +import SwiftUI +import Keychain +import ComposableArchitecture +import AIModel + +struct ChatModelManagement: ReducerProtocol { + struct State: Equatable { + var models: [ChatModel] + } +} + +struct ChatModelView: View { + var body: some View { + + } +} diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift new file mode 100644 index 00000000..f2c917d6 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModel.swift @@ -0,0 +1,2 @@ +import SwiftUI +import Keychain diff --git a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift index ddb21afd..ef9a379d 100644 --- a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift +++ b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift @@ -53,7 +53,6 @@ struct OpenAIView: View { let reply = try await ChatGPTService( configuration: UserPreferenceChatGPTConfiguration() - .overriding(.init(featureProvider: .openAI)) ) .sendAndWait(content: "Hello", summary: nil) toast("ChatGPT replied: \(reply ?? "N/A")", .info) From 6efa86777321f1c3ffba6bec75ee91e409566c76 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 2 Sep 2023 14:25:38 +0800 Subject: [PATCH 30/81] Change migrate version --- .../{MigrateTo230.swift => MigrateTo240.swift} | 4 ++-- .../ServiceUpdateMigrator.swift | 4 ++-- ...o230Tests.swift => MigrateTo240Tests.swift} | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) rename Core/Sources/ServiceUpdateMigration/{MigrateTo230.swift => MigrateTo240.swift} (98%) rename Core/Tests/ServiceUpdateMigrationTests/{MigrateTo230Tests.swift => MigrateTo240Tests.swift} (93%) diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo230.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift similarity index 98% rename from Core/Sources/ServiceUpdateMigration/MigrateTo230.swift rename to Core/Sources/ServiceUpdateMigration/MigrateTo240.swift index 1da30e42..36b16fdd 100644 --- a/Core/Sources/ServiceUpdateMigration/MigrateTo230.swift +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift @@ -3,9 +3,9 @@ import Foundation import Keychain import Preferences -func migrateTo230( +func migrateTo240( defaults: UserDefaults = .shared, - keychain: KeychainType = Keychain(scope: "apikey") + keychain: KeychainType = Keychain.apiKey ) throws { let key = UserDefaultPreferenceKeys().embeddingModels.key if defaults.value(forKey: key) != nil { return } diff --git a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift index 021c7d15..ac6e3986 100644 --- a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift +++ b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift @@ -26,8 +26,8 @@ public struct ServiceUpdateMigrator { if old <= 135 { try migrateFromLowerThanOrEqualToVersion135() } - if old <= 229 { - try migrateTo230() + if old < 240 { + try migrateTo240() } } } diff --git a/Core/Tests/ServiceUpdateMigrationTests/MigrateTo230Tests.swift b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift similarity index 93% rename from Core/Tests/ServiceUpdateMigrationTests/MigrateTo230Tests.swift rename to Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift index 2113e2a8..52f0e3be 100644 --- a/Core/Tests/ServiceUpdateMigrationTests/MigrateTo230Tests.swift +++ b/Core/Tests/ServiceUpdateMigrationTests/MigrateTo240Tests.swift @@ -4,17 +4,17 @@ import XCTest @testable import ServiceUpdateMigration -final class MigrateTo230Tests: XCTestCase { - let userDefaults = UserDefaults(suiteName: "MigrateTo230Tests")! +final class MigrateTo240Tests: XCTestCase { + let userDefaults = UserDefaults(suiteName: "MigrateTo240Tests")! override func tearDown() async throws { - userDefaults.removePersistentDomain(forName: "MigrateTo230Tests") + userDefaults.removePersistentDomain(forName: "MigrateTo240Tests") } - func test_migrateTo230_no_data_to_migrate() async throws { + func test_migrateTo240_no_data_to_migrate() async throws { let keychain = FakeKeyChain() - try migrateTo230(defaults: userDefaults, keychain: keychain) + try migrateTo240(defaults: userDefaults, keychain: keychain) XCTAssertTrue(try keychain.getAll().isEmpty, "No api key to migrate") @@ -70,7 +70,7 @@ final class MigrateTo230Tests: XCTestCase { } } - func test_migrateTo230_migrate_data_use_openAI() async throws { + func test_migrateTo240_migrate_data_use_openAI() async throws { let keychain = FakeKeyChain() userDefaults.set("Key1", forKey: "OpenAIAPIKey") @@ -85,7 +85,7 @@ final class MigrateTo230Tests: XCTestCase { userDefaults.set("openAI", forKey: "ChatFeatureProvider") userDefaults.set("openAI", forKey: "EmbeddingFeatureProvider") - try migrateTo230(defaults: userDefaults, keychain: keychain) + try migrateTo240(defaults: userDefaults, keychain: keychain) XCTAssertEqual(try keychain.getAll(), [ "OpenAI": "Key1", @@ -156,7 +156,7 @@ final class MigrateTo230Tests: XCTestCase { } } - func test_migrateTo230_migrate_data_use_azureOpenAI() async throws { + func test_migrateTo240_migrate_data_use_azureOpenAI() async throws { let keychain = FakeKeyChain() userDefaults.set("Key1", forKey: "OpenAIAPIKey") @@ -171,7 +171,7 @@ final class MigrateTo230Tests: XCTestCase { userDefaults.set("azureOpenAI", forKey: "ChatFeatureProvider") userDefaults.set("azureOpenAI", forKey: "EmbeddingFeatureProvider") - try migrateTo230(defaults: userDefaults, keychain: keychain) + try migrateTo240(defaults: userDefaults, keychain: keychain) let chatModels = userDefaults.value(for: \.chatModels) let embeddingModels = userDefaults.value(for: \.embeddingModels) From 1c6af3f036e9f75da084a461f01d584cb8adfc7e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 2 Sep 2023 16:24:41 +0800 Subject: [PATCH 31/81] Add apiKey Keychain --- Tool/Sources/Keychain/Keychain.swift | 4 ++++ .../OpenAIService/Configuration/ChatGPTConfiguration.swift | 2 +- .../OpenAIService/Configuration/EmbeddingConfiguration.swift | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/Keychain/Keychain.swift b/Tool/Sources/Keychain/Keychain.swift index c9851f59..0dfe7266 100644 --- a/Tool/Sources/Keychain/Keychain.swift +++ b/Tool/Sources/Keychain/Keychain.swift @@ -35,6 +35,10 @@ public struct Keychain: KeychainType { let service = keychainService let accessGroup = keychainAccessGroup let scope: String + + public static var apiKey: Keychain { + Keychain(scope: "apiKey") + } public enum Error: Swift.Error { case failedToDeleteFromKeyChain diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index 66e1d1c1..503b8e8f 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -19,7 +19,7 @@ public extension ChatGPTConfiguration { } var apiKey: String { - (try? Keychain(scope: "apikey").get(model.info.apiKeyName)) ?? "" + (try? Keychain.apiKey.get(model.info.apiKeyName)) ?? "" } func overriding( diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index 4ccbe96b..8229ed16 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -16,7 +16,7 @@ public extension EmbeddingConfiguration { } var apiKey: String { - (try? Keychain(scope: "apikey").get(model.info.apiKeyName)) ?? "" + (try? Keychain.apiKey.get(model.info.apiKeyName)) ?? "" } func overriding( From 012d188c9999caa956df322f7eade55c4aa5e510 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 3 Sep 2023 01:06:12 +0800 Subject: [PATCH 32/81] Add chat model management view --- .../AccountSettings/APIKeySubmission.swift | 32 ++ .../HostApp/AccountSettings/ChatModel.swift | 16 - .../ChatModelManagement/ChatModelEdit.swift | 217 ++++++++ .../ChatModelManagement.swift | 126 +++++ .../ChatModelManagementView.swift | 521 ++++++++++++++++++ .../HostApp/AccountSettings/CopilotView.swift | 1 + .../ServiceUpdateMigration/MigrateTo240.swift | 6 +- Tool/Sources/AIModel/ChatModel.swift | 8 +- .../Sources/OpenAIService/CompletionAPI.swift | 2 +- .../OpenAIService/CompletionStreamAPI.swift | 2 +- .../UserPreferenceChatGPTConfiguration.swift | 4 + 11 files changed, 911 insertions(+), 24 deletions(-) create mode 100644 Core/Sources/HostApp/AccountSettings/APIKeySubmission.swift delete mode 100644 Core/Sources/HostApp/AccountSettings/ChatModel.swift create mode 100644 Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift create mode 100644 Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift create mode 100644 Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift diff --git a/Core/Sources/HostApp/AccountSettings/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeySubmission.swift new file mode 100644 index 00000000..8c146a70 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeySubmission.swift @@ -0,0 +1,32 @@ +import ComposableArchitecture +import Foundation + +struct APIKeySubmission: ReducerProtocol { + struct State: Equatable { + @BindingState var name: String = "" + @BindingState var key: String = "" + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case saveButtonClicked + case cancelButtonClicked + } + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .saveButtonClicked: + return .none + + case .cancelButtonClicked: + return .none + + case .binding: + return .none + } + } + } +} diff --git a/Core/Sources/HostApp/AccountSettings/ChatModel.swift b/Core/Sources/HostApp/AccountSettings/ChatModel.swift deleted file mode 100644 index ec186eb9..00000000 --- a/Core/Sources/HostApp/AccountSettings/ChatModel.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI -import Keychain -import ComposableArchitecture -import AIModel - -struct ChatModelManagement: ReducerProtocol { - struct State: Equatable { - var models: [ChatModel] - } -} - -struct ChatModelView: View { - var body: some View { - - } -} diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift new file mode 100644 index 00000000..6e1023ae --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -0,0 +1,217 @@ +import AIModel +import ComposableArchitecture +import Dependencies +import Keychain +import OpenAIService +import Preferences +import SwiftUI + +struct APIKeyKeychainDependencyKey: DependencyKey { + static var liveValue: KeychainType = Keychain.apiKey + static var previewValue: KeychainType = FakeKeyChain() + static var testValue: KeychainType = FakeKeyChain() +} + +extension DependencyValues { + var apiKeyKeychain: KeychainType { + get { self[APIKeyKeychainDependencyKey.self] } + set { self[APIKeyKeychainDependencyKey.self] = newValue } + } +} + +struct ChatModelEdit: ReducerProtocol { + struct State: Equatable, Identifiable { + var id: String + @BindingState var name: String + @BindingState var format: ChatModel.Format + @BindingState var apiKeyName: String = "" + @BindingState var baseURL: String = "" + @BindingState var maxTokens: Int = 4000 + @BindingState var supportsFunctionCalling: Bool = true + @BindingState var modelName: String = "" + var availableModelNames: [String] = [] + var availableAPIKeys: [String] = [] + var isTesting = false + var suggestedMaxTokens: Int? + @PresentationState var apiKeySubmission: APIKeySubmission.State? + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case cancelButtonClicked + case refreshAvailableModelNames + case refreshAvailableAPIKeys + case testButtonClicked + case testSucceeded(String) + case testFailed(String) + case createAPIKeyButtonClicked + case checkSuggestedMaxTokens + case apiKeySubmission(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .appear: + return .merge([ + .run { await $0(.refreshAvailableAPIKeys) }, + .run { await $0(.refreshAvailableModelNames) }, + .run { await $0(.checkSuggestedMaxTokens) }, + ]) + + case .saveButtonClicked: + return .none + + case .cancelButtonClicked: + return .none + + case .testButtonClicked: + guard !state.isTesting else { return .none } + state.isTesting = true + let model = ChatModel( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL, + maxTokens: state.maxTokens, + supportsFunctionCalling: state.supportsFunctionCalling, + modelName: state.modelName + ) + ) + return .run { send in + do { + let reply = + try await ChatGPTService( + configuration: UserPreferenceChatGPTConfiguration() + .overriding { + $0.model = model + } + ).sendAndWait(content: "Hello") + await send(.testSucceeded(reply ?? "No Message")) + } catch { + await send(.testFailed(error.localizedDescription)) + } + } + + case let .testSucceeded(message): + state.isTesting = false + toast(message, .info) + return .none + + case let .testFailed(message): + state.isTesting = false + toast(message, .error) + return .none + + case .refreshAvailableModelNames: + if state.format == .openAI { + state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue) + } + + return .none + + case .refreshAvailableAPIKeys: + do { + let pairs = try keychain.getAll() + state.availableAPIKeys = Array(pairs.keys) + } catch { + toast(error.localizedDescription, .error) + } + + return .none + + case .createAPIKeyButtonClicked: + state.apiKeySubmission = .init() + return .none + + case .apiKeySubmission(.presented(.saveButtonClicked)): + if let key = state.apiKeySubmission { + do { + try keychain.update(key.name, key: key.key) + } catch { + toast(error.localizedDescription, .error) + } + } + state.apiKeySubmission = nil + return .none + + case .apiKeySubmission(.presented(.cancelButtonClicked)): + state.apiKeySubmission = nil + return .none + + case .apiKeySubmission: + return .none + + case .checkSuggestedMaxTokens: + guard state.format == .openAI, + let knownModel = ChatGPTModel(rawValue: state.modelName) + else { + state.suggestedMaxTokens = nil + return .none + } + state.suggestedMaxTokens = knownModel.maxToken + return .none + + case .binding(\.$format): + return .merge([ + .run { await $0(.refreshAvailableAPIKeys) }, + .run { await $0(.refreshAvailableModelNames) }, + .run { await $0(.checkSuggestedMaxTokens) }, + ]) + + case .binding(\.$modelName): + return .run { send in + await send(.checkSuggestedMaxTokens) + } + + case .binding: + return .none + } + } + .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) { + APIKeySubmission() + } + } +} + +extension ChatModelEdit.State { + init(model: ChatModel) { + self.init( + id: model.id, + name: model.name, + format: model.format, + apiKeyName: model.info.apiKeyName, + baseURL: model.info.baseURL, + maxTokens: model.info.maxTokens, + supportsFunctionCalling: model.info.supportsFunctionCalling, + modelName: model.info.modelName + ) + } +} + +extension ChatModel { + init(state: ChatModelEdit.State) { + self.init( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL, + maxTokens: state.maxTokens, + supportsFunctionCalling: state.supportsFunctionCalling, + modelName: state.modelName + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift new file mode 100644 index 00000000..349decaf --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -0,0 +1,126 @@ +import AIModel +import ComposableArchitecture +import Keychain +import Preferences +import SwiftUI + +struct ChatModelManagement: ReducerProtocol { + struct State: Equatable { + var models: IdentifiedArray + @PresentationState var editingModel: ChatModelEdit.State? + } + + enum Action: Equatable { + case appear + case createModel + case removeModel(id: String) + case selectModel(id: String) + case duplicateModel(id: String) + case moveModel(from: IndexSet, to: Int) + case chatModelItem(PresentationAction) + } + + @Dependency(\.toast) var toast + var userDefaults: UserDefaults = .shared + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + state.models = .init( + userDefaults.value(for: \.chatModels), + id: \.id, + uniquingIDsWith: { a, _ in a } + ) + + return .none + + case .createModel: + state.editingModel = .init( + id: UUID().uuidString, + name: "New Model", + format: .openAI + ) + return .none + + case let .removeModel(id): + state.models.remove(id: id) + persist(state) + return .none + + case let .selectModel(id): + guard let model = state.models[id: id] else { return .none } + state.editingModel = .init(model: model) + return .none + + case let .duplicateModel(id): + guard var model = state.models[id: id] else { return .none } + model.id = UUID().uuidString + model.name += " (Copy)" + + if let index = state.models.index(id: id) { + state.models.insert(model, at: index + 1) + } else { + state.models.append(model) + } + persist(state) + return .none + + case let .moveModel(from, to): + state.models.move(fromOffsets: from, toOffset: to) + persist(state) + return .none + + case .chatModelItem(.presented(.saveButtonClicked)): + guard let editingModel = state.editingModel, validateModel(editingModel) + else { return .none } + + if let index = state.models + .firstIndex(where: { $0.id == editingModel.id }) + { + state.models[index] = .init(state: editingModel) + } else { + state.models.append(.init(state: editingModel)) + } + persist(state) + return .run { send in + await send(.chatModelItem(.dismiss)) + } + + case .chatModelItem(.presented(.cancelButtonClicked)): + return .run { send in + await send(.chatModelItem(.dismiss)) + } + + case .chatModelItem: + return .none + } + }.ifLet(\.$editingModel, action: /Action.chatModelItem) { + ChatModelEdit() + } + } + + func persist(_ state: State) { + let models = state.models + userDefaults.set(Array(models), for: \.chatModels) + } + + func validateModel(_ chatModel: ChatModelEdit.State) -> Bool { + guard !chatModel.name.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + guard !chatModel.id.isEmpty else { + toast("Model ID cannot be empty", .error) + return false + } + + guard !chatModel.modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + return true + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift new file mode 100644 index 00000000..8f016956 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -0,0 +1,521 @@ +import AIModel +import ComposableArchitecture +import Preferences +import SwiftUI + +struct ChatModelManagementView: View { + let store: StoreOf + + var body: some View { + VStack { + HStack { + Spacer() + Button("Add Model") { + store.send(.createModel) + } + }.padding(4) + + ModelList(store: store) + .sheet(store: store.scope( + state: \.$editingModel, + action: ChatModelManagement.Action.chatModelItem + )) { store in + EditingPanel(store: store) + .frame(minWidth: 400) + } + } + .onAppear { + store.send(.appear) + } + } + + @MainActor + struct EditingPanel: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Form { + nameTextField + formatPicker + + WithViewStore(store, observe: { $0.format }) { viewStore in + switch viewStore.state { + case .openAI: + openAI + case .azureOpenAI: + azureOpenAI + case .openAICompatible: + openAICompatible + } + } + } + .padding() + + Divider() + + HStack { + WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack(spacing: 8) { + Button("Test") { + store.send(.testButtonClicked) + } + .disabled(viewStore.state) + + if viewStore.state { + ProgressView() + .controlSize(.small) + } + } + } + + Spacer() + + Button("Cancel") { + store.send(.cancelButtonClicked) + } + + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + } + + var nameTextField: some View { + WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in + TextField("Name", text: viewStore.$name) + } + } + + var formatPicker: some View { + WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in + Picker( + selection: viewStore.$format, + content: { + ForEach( + ChatModel.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) + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + } + + func baseURLTextField(prompt: Text?) -> some View { + WithViewStore(store, removeDuplicates: { $0.baseURL == $1.baseURL }) { viewStore in + TextField("Base URL", text: viewStore.$baseURL, prompt: prompt) + } + } + + var supportsFunctionCallingToggle: some View { + WithViewStore( + store, + removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling } + ) { viewStore in + Toggle( + "Supports Function Calling", + isOn: viewStore.$supportsFunctionCalling + ) + } + } + + struct MaxTokensTextField: Equatable { + @BindingViewState var maxTokens: Int + var suggestedMaxTokens: Int? + } + + var maxTokensTextField: some View { + WithViewStore( + store, + observe: { + MaxTokensTextField( + maxTokens: $0.$maxTokens, + suggestedMaxTokens: $0.suggestedMaxTokens + ) + } + ) { viewStore in + HStack { + let textFieldBinding = Binding( + get: { String(viewStore.state.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + viewStore.$maxTokens.wrappedValue = selectionMaxToken + } else { + viewStore.$maxTokens.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Max Token (Including Reply)") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: viewStore.$maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = viewStore.state.suggestedMaxTokens else { + return .primary + } + if viewStore.state.maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = viewStore.state.suggestedMaxTokens { + Text("Max: \(max)") + } + } + } + } + + struct APIKeyState: Equatable { + @BindingViewState var apiKeyName: String + var availableAPIKeys: [String] + } + + @ViewBuilder + var apiKeyNamePicker: some View { + HStack { + WithViewStore( + store, + observe: { + APIKeyState( + apiKeyName: $0.$apiKeyName, + availableAPIKeys: $0.availableAPIKeys + ) + } + ) { viewStore in + Picker( + selection: viewStore.$apiKeyName, + content: { + Text("No API Key").tag("") + if viewStore.state.availableAPIKeys.isEmpty { + Text("No API key found, please add a new one →") + } + ForEach(viewStore.state.availableAPIKeys, id: \.self) { name in + Text(name).tag(name) + } + + }, + label: { Text("API Key") } + ) + } + + Button(action: { store.send(.createAPIKeyButtonClicked) }) { + Text(Image(systemName: "plus")) + } + } + } + + @ViewBuilder + var openAI: some View { + baseURLTextField(prompt: Text("https://api.openai.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$modelName, + content: { + ForEach(ChatGPTModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + } + + maxTokensTextField + supportsFunctionCallingToggle + } + + @ViewBuilder + var azureOpenAI: some View { + baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Deployment Name", text: viewStore.$modelName) + } + + maxTokensTextField + supportsFunctionCallingToggle + } + + @ViewBuilder + var openAICompatible: some View { + baseURLTextField(prompt: Text("https://")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + } + + maxTokensTextField + supportsFunctionCallingToggle + } + } + + struct ModelList: View { + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + List { + ForEach(viewStore.state.models) { model in + let isSelected = viewStore.state.editingModel?.id == model.id + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") + + Button(action: { + viewStore.send(.selectModel(id: model.id)) + }) { + Cell(chatModel: model, isSelected: isSelected) + + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + Button("Duplicate") { + store.send(.duplicateModel(id: model.id)) + } + Button("Remove") { + store.send(.removeModel(id: model.id)) + } + } + } + } + .onMove(perform: { indices, newOffset in + viewStore.send(.moveModel(from: indices, to: newOffset)) + }) + } + .removeBackground() + .listStyle(.plain) + .listRowInsets(EdgeInsets()) + } + } + } + + struct Cell: View { + let chatModel: ChatModel + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text({ + switch chatModel.format { + case .openAI: return "OpenAI" + case .azureOpenAI: return "Azure OpenAI" + case .openAICompatible: return "OpenAI Compatible" + } + }() as String) + .foregroundColor(isSelected ? .white : .primary) + .font(.subheadline.bold()) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + RoundedRectangle(cornerRadius: 4) + .fill( + isSelected + ? .white.opacity(0.2) + : Color.primary.opacity(0.1) + ) + } + + Text(chatModel.name) + .font(.headline) + } + + HStack(spacing: 4) { + Text(chatModel.info.modelName) + + if !chatModel.info.baseURL.isEmpty { + Image(systemName: "line.diagonal") + Text(chatModel.info.baseURL) + } + + Image(systemName: "line.diagonal") + + Text("\(chatModel.info.maxTokens) tokens") + + Image(systemName: "line.diagonal") + + Text( + "function calling \(chatModel.info.supportsFunctionCalling ? Image(systemName: "checkmark.square") : Image(systemName: "xmark.square"))" + ) + } + .font(.subheadline) + .opacity(0.7) + .padding(.leading, 2) + } + Spacer() + } + .onHover(perform: { + isHovered = $0 + }) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill({ + switch (isSelected, isHovered) { + case (true, _): + return Color.accentColor + case (_, true): + return Color.primary.opacity(0.1) + case (_, false): + return Color.clear + } + }() as Color) + } + .foregroundColor(isSelected ? .white : .primary) + .animation(.easeInOut(duration: 0.1), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: isHovered) + } + } +} + +// MARK: - Previews + +class ChatModelManagementView_Previews: PreviewProvider { + static var previews: some View { + ChatModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: .init( + id: "3", + name: "Test Model 2", + format: .azureOpenAI, + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + reducer: ChatModelManagement( + userDefaults: UserDefaults(suiteName: "ChatModelManagementView_Previews")! + ) + ) + ) + } +} + +class ChatModelManagementView_Editing_Previews: PreviewProvider { + static var previews: some View { + ChatModelManagementView.EditingPanel( + store: .init( + initialState: .init( + id: "1", + name: "Test Model", + format: .openAI, + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + + ), + reducer: ChatModelEdit() + ) + ) + } +} + +class ChatModelManagementView_Cell_Previews: PreviewProvider { + static var previews: some View { + ChatModelManagementView.Cell(chatModel: .init( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), isSelected: false) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/CopilotView.swift b/Core/Sources/HostApp/AccountSettings/CopilotView.swift index bc53db71..eec77dff 100644 --- a/Core/Sources/HostApp/AccountSettings/CopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/CopilotView.swift @@ -326,6 +326,7 @@ struct CopilotView: View { } } } + .textFieldStyle(.roundedBorder) } func checkStatus() { diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift index 36b16fdd..feba89bd 100644 --- a/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift @@ -7,8 +7,8 @@ func migrateTo240( defaults: UserDefaults = .shared, keychain: KeychainType = Keychain.apiKey ) throws { - let key = UserDefaultPreferenceKeys().embeddingModels.key - if defaults.value(forKey: key) != nil { return } + let finishedMigrationKey = "MigrateTo240Finished" + if defaults.bool(forKey: finishedMigrationKey) { return } let chatModelOpenAIId = UUID().uuidString let chatModelAzureOpenAIId = UUID().uuidString @@ -105,5 +105,7 @@ func migrateTo240( } return embeddingModelOpenAIId }()) + + defaults.set(true, forKey: finishedMigrationKey) } diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index f0a9e3c5..d132b5dd 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -1,7 +1,7 @@ import CodableWrappers import Foundation -public struct ChatModel: Codable, Equatable { +public struct ChatModel: Codable, Equatable, Identifiable { public var id: String public var name: String @FallbackDecoding @@ -16,10 +16,10 @@ public struct ChatModel: Codable, Equatable { self.info = info } - public enum Format: String, Codable, Equatable { + public enum Format: String, Codable, Equatable, CaseIterable { case openAI - case openAIFormat case azureOpenAI + case openAICompatible } public struct Info: Codable, Equatable { @@ -55,7 +55,7 @@ public struct ChatModel: Codable, Equatable { public var endpoint: String { switch format { - case .openAI, .openAIFormat: + case .openAI, .openAICompatible: let baseURL = info.baseURL if baseURL.isEmpty { return "https://api.openai.com/v1/chat/completions" } return "\(baseURL)/v1/chat/completions" diff --git a/Tool/Sources/OpenAIService/CompletionAPI.swift b/Tool/Sources/OpenAIService/CompletionAPI.swift index 842229fc..479c57bb 100644 --- a/Tool/Sources/OpenAIService/CompletionAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionAPI.swift @@ -90,7 +90,7 @@ struct OpenAICompletionAPI: CompletionAPI { request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { switch model.format { - case .openAI, .openAIFormat: + case .openAI, .openAICompatible: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") diff --git a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift index 0e30fcb4..574988ef 100644 --- a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift @@ -182,7 +182,7 @@ struct OpenAICompletionStreamAPI: CompletionStreamAPI { request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { switch model.format { - case .openAI, .openAIFormat: + case .openAI, .openAICompatible: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index 4efb4311..bc499d6f 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -37,6 +37,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public struct Overriding: Codable { public var temperature: Double? public var modelId: String? + public var model: ChatModel? public var stop: [String]? public var maxTokens: Int? public var minimumReplyTokens: Int? @@ -45,6 +46,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public init( temperature: Double? = nil, modelId: String? = nil, + model: ChatModel? = nil, stop: [String]? = nil, maxTokens: Int? = nil, minimumReplyTokens: Int? = nil, @@ -52,6 +54,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { ) { self.temperature = temperature self.modelId = modelId + self.model = model self.stop = stop self.maxTokens = maxTokens self.minimumReplyTokens = minimumReplyTokens @@ -75,6 +78,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { } public var model: ChatModel { + if let model = overriding.model { return model } let models = UserDefaults.shared.value(for: \.chatModels) guard let id = overriding.modelId, let model = models.first(where: { $0.id == id }) From 7050a2e33fcfcd0202f3407dc630bea925fc980a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 3 Sep 2023 23:55:29 +0800 Subject: [PATCH 33/81] Add chat model and embedding model settings view --- .../APIKeyManagementView.swift | 139 +++++ .../APIKeyManagement/APIKeyManangement.swift | 80 +++ .../APIKeyManagement/APIKeyPicker.swift | 41 ++ .../APIKeyManagement/APIKeySelection.swift | 56 ++ .../APIKeySubmission.swift | 36 +- .../ChatModelManagement/ChatModelEdit.swift | 101 ++-- .../ChatModelEditView.swift | 273 ++++++++++ .../ChatModelManagement.swift | 55 +- .../ChatModelManagementView.swift | 477 +----------------- .../EmbeddingModelEdit.swift | 179 +++++++ .../EmbeddingModelEditView.swift | 257 ++++++++++ .../EmbeddingModelManagement.swift | 157 ++++++ .../EmbeddingModelManagementView.swift | 80 +++ .../AIModelManagementVIew.swift | 242 +++++++++ .../SharedModelManagement/BaseURLPicker.swift | 25 + .../BaseURLSelection.swift | 46 ++ .../CustomCommandView.swift | 2 + Core/Sources/HostApp/HostApp.swift | 57 +++ Core/Sources/HostApp/ServiceView.swift | 19 +- Tool/Sources/AIModel/EmbeddingModel.swift | 8 +- .../EmbeddingConfiguration.swift | 1 - ...UserPreferenceEmbeddingConfiguration.swift | 8 +- .../OpenAIService/EmbeddingService.swift | 4 +- Tool/Sources/Preferences/UserDefaults.swift | 13 +- 24 files changed, 1799 insertions(+), 557 deletions(-) create mode 100644 Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift create mode 100644 Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift create mode 100644 Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift create mode 100644 Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift rename Core/Sources/HostApp/AccountSettings/{ => APIKeyManagement}/APIKeySubmission.swift (50%) create mode 100644 Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift create mode 100644 Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift create mode 100644 Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift create mode 100644 Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift create mode 100644 Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift create mode 100644 Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift create mode 100644 Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift create mode 100644 Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift new file mode 100644 index 00000000..101c4e49 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManagementView.swift @@ -0,0 +1,139 @@ +import ComposableArchitecture +import SwiftUI + +struct APIKeyManagementView: View { + let store: StoreOf + + var body: some View { + VStack(spacing: 0) { + HStack { + Button(action: { + store.send(.closeButtonClicked) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("API Keys") + Spacer() + Button(action: { + store.send(.addButtonClicked) + }) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + } + .background(Color(nsColor: .separatorColor)) + + List { + WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in + ForEach(viewStore.state, id: \.self) { name in + HStack { + Text(name) + .contextMenu { + Button("Remove") { + viewStore.send(.deleteButtonClicked(name: name)) + } + } + Spacer() + + Button(action: { + viewStore.send(.deleteButtonClicked(name: name)) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + } + .removeBackground() + .overlay { + WithViewStore(store, observe: { $0.availableAPIKeyNames }) { viewStore in + if viewStore.state.isEmpty { + Text(""" + Empty + Add a new key by clicking the add button + """) + .multilineTextAlignment(.center) + .padding() + } + } + } + } + .focusable(false) + .frame(width: 300, height: 400) + .background(.thickMaterial) + .onAppear { + store.send(.appear) + } + .sheet(store: store.scope( + state: \.$apiKeySubmission, + action: APIKeyManagement.Action.apiKeySubmission + )) { store in + APIKeySubmissionView(store: store) + .frame(minWidth: 400) + } + } +} + +struct APIKeySubmissionView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Form { + WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in + TextField("Name", text: viewStore.$name) + } + WithViewStore(store, removeDuplicates: { $0.key == $1.key }) { viewStore in + SecureField("Key", text: viewStore.$key) + } + }.padding() + + Divider() + + HStack { + Spacer() + + Button("Cancel") { store.send(.cancelButtonClicked) } + .keyboardShortcut(.cancelAction) + + Button("Save", action: { store.send(.saveButtonClicked) }) + .keyboardShortcut(.defaultAction) + }.padding() + } + } + .textFieldStyle(.roundedBorder) + } +} + +class APIKeyManagementView_Preview: PreviewProvider { + static var previews: some View { + APIKeyManagementView( + store: .init( + initialState: .init( + availableAPIKeyNames: ["test1", "test2"] + ), + reducer: APIKeyManagement() + ) + ) + } +} + +class APIKeySubmissionView_Preview: PreviewProvider { + static var previews: some View { + APIKeySubmissionView( + store: .init( + initialState: .init(), + reducer: APIKeySubmission() + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift new file mode 100644 index 00000000..c9633dc4 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import Foundation + +struct APIKeyManagement: ReducerProtocol { + struct State: Equatable { + var availableAPIKeyNames: [String] = [] + @PresentationState var apiKeySubmission: APIKeySubmission.State? + } + + enum Action: Equatable { + case appear + case closeButtonClicked + case addButtonClicked + case deleteButtonClicked(name: String) + case refreshAvailableAPIKeyNames + + case apiKeySubmission(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + case .closeButtonClicked: + return .none + + case .addButtonClicked: + state.apiKeySubmission = .init() + + return .none + + case let .deleteButtonClicked(name): + do { + try keychain.remove(name) + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + } catch { + toast(error.localizedDescription, .error) + return .none + } + + case .refreshAvailableAPIKeyNames: + do { + let pairs = try keychain.getAll() + state.availableAPIKeyNames = Array(pairs.keys) + } catch { + toast(error.localizedDescription, .error) + } + + return .none + + case .apiKeySubmission(.presented(.saveFinished)): + state.apiKeySubmission = nil + return .run { send in + await send(.refreshAvailableAPIKeyNames) + } + + case .apiKeySubmission(.presented(.cancelButtonClicked)): + state.apiKeySubmission = nil + return .none + + case .apiKeySubmission: + return .none + } + } + .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) { + APIKeySubmission() + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift new file mode 100644 index 00000000..11e5fd47 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -0,0 +1,41 @@ +import ComposableArchitecture +import SwiftUI + +struct APIKeyPicker: View { + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + HStack { + Picker( + selection: viewStore.$apiKeyName, + content: { + Text("No API Key").tag("") + if viewStore.state.availableAPIKeyNames.isEmpty { + Text("No API key found, please add a new one →") + } + ForEach(viewStore.state.availableAPIKeyNames, id: \.self) { name in + Text(name).tag(name) + } + + }, + label: { Text("API Key") } + ) + } + + Button(action: { store.send(.manageAPIKeysButtonClicked) }) { + Text(Image(systemName: "key")) + } + .sheet(isPresented: viewStore.$isAPIKeyManagementPresented) { + APIKeyManagementView(store: store.scope( + state: \.apiKeyManagement, + action: APIKeySelection.Action.apiKeyManagement + )) + } + } + .onAppear { + store.send(.appear) + } + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift new file mode 100644 index 00000000..75e2d77c --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySelection.swift @@ -0,0 +1,56 @@ +import Foundation +import SwiftUI +import ComposableArchitecture + +struct APIKeySelection: ReducerProtocol { + struct State: Equatable { + @BindingState var apiKeyName: String = "" + var availableAPIKeyNames: [String] { + apiKeyManagement.availableAPIKeyNames + } + var apiKeyManagement: APIKeyManagement.State = .init() + @BindingState var isAPIKeyManagementPresented: Bool = false + } + + enum Action: Equatable, BindableAction { + case appear + case manageAPIKeysButtonClicked + + case binding(BindingAction) + case apiKeyManagement(APIKeyManagement.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + BindingReducer() + + Scope(state: \.apiKeyManagement, action: /Action.apiKeyManagement) { + APIKeyManagement() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.apiKeyManagement(.refreshAvailableAPIKeyNames)) + } + + case .manageAPIKeysButtonClicked: + state.isAPIKeyManagementPresented = true + return .none + + case .binding: + return .none + + case .apiKeyManagement(.closeButtonClicked): + state.isAPIKeyManagementPresented = false + return .none + + case .apiKeyManagement: + return .none + } + } + } +} diff --git a/Core/Sources/HostApp/AccountSettings/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift similarity index 50% rename from Core/Sources/HostApp/AccountSettings/APIKeySubmission.swift rename to Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift index 8c146a70..d27285a8 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeySubmission.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift @@ -6,27 +6,51 @@ struct APIKeySubmission: ReducerProtocol { @BindingState var name: String = "" @BindingState var key: String = "" } - + enum Action: Equatable, BindableAction { case binding(BindingAction) case saveButtonClicked case cancelButtonClicked + case saveFinished + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + enum E: Error, LocalizedError { + case nameIsEmpty + case keyIsEmpty } - + var body: some ReducerProtocol { BindingReducer() - + Reduce { state, action in switch action { case .saveButtonClicked: - return .none - + do { + guard !state.name.isEmpty else { throw E.nameIsEmpty } + guard !state.key.isEmpty else { throw E.keyIsEmpty } + + try keychain.update(state.name, key: state.key) + return .run { send in + await send(.saveFinished) + } + } catch { + toast(error.localizedDescription, .error) + return .none + } + case .cancelButtonClicked: return .none - + + case .saveFinished: + return .none + case .binding: return .none } } } } + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 6e1023ae..89da58a6 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -6,34 +6,22 @@ import OpenAIService import Preferences import SwiftUI -struct APIKeyKeychainDependencyKey: DependencyKey { - static var liveValue: KeychainType = Keychain.apiKey - static var previewValue: KeychainType = FakeKeyChain() - static var testValue: KeychainType = FakeKeyChain() -} - -extension DependencyValues { - var apiKeyKeychain: KeychainType { - get { self[APIKeyKeychainDependencyKey.self] } - set { self[APIKeyKeychainDependencyKey.self] = newValue } - } -} - struct ChatModelEdit: ReducerProtocol { struct State: Equatable, Identifiable { var id: String @BindingState var name: String @BindingState var format: ChatModel.Format - @BindingState var apiKeyName: String = "" - @BindingState var baseURL: String = "" @BindingState var maxTokens: Int = 4000 @BindingState var supportsFunctionCalling: Bool = true @BindingState var modelName: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } var availableModelNames: [String] = [] var availableAPIKeys: [String] = [] var isTesting = false var suggestedMaxTokens: Int? - @PresentationState var apiKeySubmission: APIKeySubmission.State? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() } enum Action: Equatable, BindableAction { @@ -42,13 +30,12 @@ struct ChatModelEdit: ReducerProtocol { case saveButtonClicked case cancelButtonClicked case refreshAvailableModelNames - case refreshAvailableAPIKeys case testButtonClicked case testSucceeded(String) case testFailed(String) - case createAPIKeyButtonClicked case checkSuggestedMaxTokens - case apiKeySubmission(PresentationAction) + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) } @Dependency(\.toast) var toast @@ -57,14 +44,21 @@ struct ChatModelEdit: ReducerProtocol { var body: some ReducerProtocol { BindingReducer() + Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + BaseURLSelection() + } + Reduce { state, action in switch action { case .appear: - return .merge([ - .run { await $0(.refreshAvailableAPIKeys) }, - .run { await $0(.refreshAvailableModelNames) }, - .run { await $0(.checkSuggestedMaxTokens) }, - ]) + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } case .saveButtonClicked: return .none @@ -119,38 +113,6 @@ struct ChatModelEdit: ReducerProtocol { return .none - case .refreshAvailableAPIKeys: - do { - let pairs = try keychain.getAll() - state.availableAPIKeys = Array(pairs.keys) - } catch { - toast(error.localizedDescription, .error) - } - - return .none - - case .createAPIKeyButtonClicked: - state.apiKeySubmission = .init() - return .none - - case .apiKeySubmission(.presented(.saveButtonClicked)): - if let key = state.apiKeySubmission { - do { - try keychain.update(key.name, key: key.key) - } catch { - toast(error.localizedDescription, .error) - } - } - state.apiKeySubmission = nil - return .none - - case .apiKeySubmission(.presented(.cancelButtonClicked)): - state.apiKeySubmission = nil - return .none - - case .apiKeySubmission: - return .none - case .checkSuggestedMaxTokens: guard state.format == .openAI, let knownModel = ChatGPTModel(rawValue: state.modelName) @@ -161,12 +123,17 @@ struct ChatModelEdit: ReducerProtocol { state.suggestedMaxTokens = knownModel.maxToken return .none + case .apiKeySelection: + return .none + + case .baseURLSelection: + return .none + case .binding(\.$format): - return .merge([ - .run { await $0(.refreshAvailableAPIKeys) }, - .run { await $0(.refreshAvailableModelNames) }, - .run { await $0(.checkSuggestedMaxTokens) }, - ]) + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } case .binding(\.$modelName): return .run { send in @@ -177,9 +144,6 @@ struct ChatModelEdit: ReducerProtocol { return .none } } - .ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) { - APIKeySubmission() - } } } @@ -189,11 +153,14 @@ extension ChatModelEdit.State { id: model.id, name: model.name, format: model.format, - apiKeyName: model.info.apiKeyName, - baseURL: model.info.baseURL, maxTokens: model.info.maxTokens, supportsFunctionCalling: model.info.supportsFunctionCalling, - modelName: model.info.modelName + modelName: model.info.modelName, + apiKeySelection: .init( + apiKeyName: model.info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: model.info.baseURL) ) } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift new file mode 100644 index 00000000..7f85a4e6 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -0,0 +1,273 @@ +import AIModel +import ComposableArchitecture +import Preferences +import SwiftUI + +@MainActor +struct ChatModelEditView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Form { + nameTextField + formatPicker + + WithViewStore(store, observe: { $0.format }) { viewStore in + switch viewStore.state { + case .openAI: + openAI + case .azureOpenAI: + azureOpenAI + case .openAICompatible: + openAICompatible + } + } + } + .padding() + + Divider() + + HStack { + WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack(spacing: 8) { + Button("Test") { + store.send(.testButtonClicked) + } + .disabled(viewStore.state) + + if viewStore.state { + ProgressView() + .controlSize(.small) + } + } + } + + Spacer() + + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) + + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + } + + var nameTextField: some View { + WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in + TextField("Name", text: viewStore.$name) + } + } + + var formatPicker: some View { + WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in + Picker( + selection: viewStore.$format, + content: { + ForEach( + ChatModel.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) + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + } + + func baseURLTextField(prompt: Text?) -> some View { + BaseURLPicker( + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: ChatModelEdit.Action.baseURLSelection + ) + ) + } + + var supportsFunctionCallingToggle: some View { + WithViewStore( + store, + removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling } + ) { viewStore in + Toggle( + "Supports Function Calling", + isOn: viewStore.$supportsFunctionCalling + ) + } + } + + struct MaxTokensTextField: Equatable { + @BindingViewState var maxTokens: Int + var suggestedMaxTokens: Int? + } + + var maxTokensTextField: some View { + WithViewStore( + store, + observe: { + MaxTokensTextField( + maxTokens: $0.$maxTokens, + suggestedMaxTokens: $0.suggestedMaxTokens + ) + } + ) { viewStore in + HStack { + let textFieldBinding = Binding( + get: { String(viewStore.state.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + viewStore.$maxTokens.wrappedValue = selectionMaxToken + } else { + viewStore.$maxTokens.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Max Tokens (Including Reply)") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: viewStore.$maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = viewStore.state.suggestedMaxTokens else { + return .primary + } + if viewStore.state.maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = viewStore.state.suggestedMaxTokens { + Text("Max: \(max)") + } + } + } + } + + struct APIKeyState: Equatable { + @BindingViewState var apiKeyName: String + var availableAPIKeys: [String] + } + + @ViewBuilder + var apiKeyNamePicker: some View { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: ChatModelEdit.Action.apiKeySelection + )) + } + + @ViewBuilder + var openAI: some View { + baseURLTextField(prompt: Text("https://api.openai.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$modelName, + content: { + ForEach(ChatGPTModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + } + + maxTokensTextField + supportsFunctionCallingToggle + } + + @ViewBuilder + var azureOpenAI: some View { + baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Deployment Name", text: viewStore.$modelName) + } + + maxTokensTextField + supportsFunctionCallingToggle + } + + @ViewBuilder + var openAICompatible: some View { + baseURLTextField(prompt: Text("https://")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + } + + maxTokensTextField + supportsFunctionCallingToggle + } +} + +class ChatModelManagementView_Editing_Previews: PreviewProvider { + static var previews: some View { + ChatModelEditView( + store: .init( + initialState: .init(model: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + )), + reducer: ChatModelEdit() + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift index 349decaf..182536c1 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -4,24 +4,59 @@ import Keychain import Preferences import SwiftUI -struct ChatModelManagement: ReducerProtocol { - struct State: Equatable { - var models: IdentifiedArray +extension ChatModel: ManageableAIModel { + var formatName: String { + switch format { + case .openAI: return "OpenAI" + case .azureOpenAI: return "Azure OpenAI" + case .openAICompatible: return "OpenAI Compatible" + } + } + + @ViewBuilder + var infoDescriptors: some View { + Text(info.modelName) + + if !info.baseURL.isEmpty { + Image(systemName: "line.diagonal") + Text(info.baseURL) + } + + Image(systemName: "line.diagonal") + + Text("\(info.maxTokens) tokens") + + Image(systemName: "line.diagonal") + + Text( + "function calling \(info.supportsFunctionCalling ? Image(systemName: "checkmark.square") : Image(systemName: "xmark.square"))" + ) + } +} + +struct ChatModelManagement: AIModelManagement { + typealias Model = ChatModel + + struct State: Equatable, AIModelManagementState { + typealias Model = ChatModel + var models: IdentifiedArrayOf = [] @PresentationState var editingModel: ChatModelEdit.State? + var selectedModelId: String? { editingModel?.id } } - enum Action: Equatable { + enum Action: Equatable, AIModelManagementAction { + typealias Model = ChatModel case appear case createModel - case removeModel(id: String) - case selectModel(id: String) - case duplicateModel(id: String) + case removeModel(id: Model.ID) + case selectModel(id: Model.ID) + case duplicateModel(id: Model.ID) case moveModel(from: IndexSet, to: Int) case chatModelItem(PresentationAction) } @Dependency(\.toast) var toast - var userDefaults: UserDefaults = .shared + @Dependency(\.userDefaults) var userDefaults var body: some ReducerProtocol { Reduce { state, action in @@ -58,7 +93,7 @@ struct ChatModelManagement: ReducerProtocol { guard var model = state.models[id: id] else { return .none } model.id = UUID().uuidString model.name += " (Copy)" - + if let index = state.models.index(id: id) { state.models.insert(model, at: index + 1) } else { @@ -115,7 +150,7 @@ struct ChatModelManagement: ReducerProtocol { toast("Model ID cannot be empty", .error) return false } - + guard !chatModel.modelName.isEmpty else { toast("Model name cannot be empty", .error) return false diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift index 8f016956..cac7184f 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagementView.swift @@ -1,419 +1,19 @@ import AIModel import ComposableArchitecture -import Preferences import SwiftUI struct ChatModelManagementView: View { let store: StoreOf var body: some View { - VStack { - HStack { - Spacer() - Button("Add Model") { - store.send(.createModel) - } - }.padding(4) - - ModelList(store: store) - .sheet(store: store.scope( - state: \.$editingModel, - action: ChatModelManagement.Action.chatModelItem - )) { store in - EditingPanel(store: store) - .frame(minWidth: 400) - } - } - .onAppear { - store.send(.appear) - } - } - - @MainActor - struct EditingPanel: View { - let store: StoreOf - - var body: some View { - ScrollView { - VStack(spacing: 0) { - Form { - nameTextField - formatPicker - - WithViewStore(store, observe: { $0.format }) { viewStore in - switch viewStore.state { - case .openAI: - openAI - case .azureOpenAI: - azureOpenAI - case .openAICompatible: - openAICompatible - } - } - } - .padding() - - Divider() - - HStack { - WithViewStore(store, observe: { $0.isTesting }) { viewStore in - HStack(spacing: 8) { - Button("Test") { - store.send(.testButtonClicked) - } - .disabled(viewStore.state) - - if viewStore.state { - ProgressView() - .controlSize(.small) - } - } - } - - Spacer() - - Button("Cancel") { - store.send(.cancelButtonClicked) - } - - Button(action: { store.send(.saveButtonClicked) }) { - Text("Save") - } - } - .padding() - } - } - .textFieldStyle(.roundedBorder) - .onAppear { - store.send(.appear) - } - } - - var nameTextField: some View { - WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in - TextField("Name", text: viewStore.$name) - } - } - - var formatPicker: some View { - WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in - Picker( - selection: viewStore.$format, - content: { - ForEach( - ChatModel.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) - } - } - }, - label: { Text("Format") } - ) - .pickerStyle(.segmented) - } - } - - func baseURLTextField(prompt: Text?) -> some View { - WithViewStore(store, removeDuplicates: { $0.baseURL == $1.baseURL }) { viewStore in - TextField("Base URL", text: viewStore.$baseURL, prompt: prompt) - } - } - - var supportsFunctionCallingToggle: some View { - WithViewStore( - store, - removeDuplicates: { $0.supportsFunctionCalling == $1.supportsFunctionCalling } - ) { viewStore in - Toggle( - "Supports Function Calling", - isOn: viewStore.$supportsFunctionCalling - ) - } - } - - struct MaxTokensTextField: Equatable { - @BindingViewState var maxTokens: Int - var suggestedMaxTokens: Int? - } - - var maxTokensTextField: some View { - WithViewStore( - store, - observe: { - MaxTokensTextField( - maxTokens: $0.$maxTokens, - suggestedMaxTokens: $0.suggestedMaxTokens - ) - } - ) { viewStore in - HStack { - let textFieldBinding = Binding( - get: { String(viewStore.state.maxTokens) }, - set: { - if let selectionMaxToken = Int($0) { - viewStore.$maxTokens.wrappedValue = selectionMaxToken - } else { - viewStore.$maxTokens.wrappedValue = 0 - } - } - ) - - TextField(text: textFieldBinding) { - Text("Max Token (Including Reply)") - .multilineTextAlignment(.trailing) - } - .overlay(alignment: .trailing) { - Stepper( - value: viewStore.$maxTokens, - in: 0...Int.max, - step: 100 - ) { - EmptyView() - } - } - .foregroundColor({ - guard let max = viewStore.state.suggestedMaxTokens else { - return .primary - } - if viewStore.state.maxTokens > max { - return .red - } - return .primary - }() as Color) - - if let max = viewStore.state.suggestedMaxTokens { - Text("Max: \(max)") - } - } - } - } - - struct APIKeyState: Equatable { - @BindingViewState var apiKeyName: String - var availableAPIKeys: [String] - } - - @ViewBuilder - var apiKeyNamePicker: some View { - HStack { - WithViewStore( - store, - observe: { - APIKeyState( - apiKeyName: $0.$apiKeyName, - availableAPIKeys: $0.availableAPIKeys - ) - } - ) { viewStore in - Picker( - selection: viewStore.$apiKeyName, - content: { - Text("No API Key").tag("") - if viewStore.state.availableAPIKeys.isEmpty { - Text("No API key found, please add a new one →") - } - ForEach(viewStore.state.availableAPIKeys, id: \.self) { name in - Text(name).tag(name) - } - - }, - label: { Text("API Key") } - ) - } - - Button(action: { store.send(.createAPIKeyButtonClicked) }) { - Text(Image(systemName: "plus")) - } - } - } - - @ViewBuilder - var openAI: some View { - baseURLTextField(prompt: Text("https://api.openai.com")) - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - .overlay(alignment: .trailing) { - Picker( - "", - selection: viewStore.$modelName, - content: { - ForEach(ChatGPTModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } - ) - .frame(width: 20) - } - } - - maxTokensTextField - supportsFunctionCallingToggle - } - - @ViewBuilder - var azureOpenAI: some View { - baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Deployment Name", text: viewStore.$modelName) - } - - maxTokensTextField - supportsFunctionCallingToggle - } - - @ViewBuilder - var openAICompatible: some View { - baseURLTextField(prompt: Text("https://")) - apiKeyNamePicker - - WithViewStore( - store, - removeDuplicates: { $0.modelName == $1.modelName } - ) { viewStore in - TextField("Model Name", text: viewStore.$modelName) - } - - maxTokensTextField - supportsFunctionCallingToggle - } - } - - struct ModelList: View { - let store: StoreOf - - var body: some View { - WithViewStore(store) { viewStore in - List { - ForEach(viewStore.state.models) { model in - let isSelected = viewStore.state.editingModel?.id == model.id - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal") - - Button(action: { - viewStore.send(.selectModel(id: model.id)) - }) { - Cell(chatModel: model, isSelected: isSelected) - - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button("Duplicate") { - store.send(.duplicateModel(id: model.id)) - } - Button("Remove") { - store.send(.removeModel(id: model.id)) - } - } - } - } - .onMove(perform: { indices, newOffset in - viewStore.send(.moveModel(from: indices, to: newOffset)) - }) - } - .removeBackground() - .listStyle(.plain) - .listRowInsets(EdgeInsets()) + AIModelManagementView(store: store) + .sheet(store: store.scope( + state: \.$editingModel, + action: ChatModelManagement.Action.chatModelItem + )) { store in + ChatModelEditView(store: store) + .frame(minWidth: 400) } - } - } - - struct Cell: View { - let chatModel: ChatModel - let isSelected: Bool - @State var isHovered: Bool = false - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack { - Text({ - switch chatModel.format { - case .openAI: return "OpenAI" - case .azureOpenAI: return "Azure OpenAI" - case .openAICompatible: return "OpenAI Compatible" - } - }() as String) - .foregroundColor(isSelected ? .white : .primary) - .font(.subheadline.bold()) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background { - RoundedRectangle(cornerRadius: 4) - .fill( - isSelected - ? .white.opacity(0.2) - : Color.primary.opacity(0.1) - ) - } - - Text(chatModel.name) - .font(.headline) - } - - HStack(spacing: 4) { - Text(chatModel.info.modelName) - - if !chatModel.info.baseURL.isEmpty { - Image(systemName: "line.diagonal") - Text(chatModel.info.baseURL) - } - - Image(systemName: "line.diagonal") - - Text("\(chatModel.info.maxTokens) tokens") - - Image(systemName: "line.diagonal") - - Text( - "function calling \(chatModel.info.supportsFunctionCalling ? Image(systemName: "checkmark.square") : Image(systemName: "xmark.square"))" - ) - } - .font(.subheadline) - .opacity(0.7) - .padding(.leading, 2) - } - Spacer() - } - .onHover(perform: { - isHovered = $0 - }) - .padding(.vertical, 8) - .padding(.horizontal, 8) - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill({ - switch (isSelected, isHovered) { - case (true, _): - return Color.accentColor - case (_, true): - return Color.primary.opacity(0.1) - case (_, false): - return Color.clear - } - }() as Color) - } - .foregroundColor(isSelected ? .white : .primary) - .animation(.easeInOut(duration: 0.1), value: isSelected) - .animation(.easeInOut(duration: 0.1), value: isHovered) - } } } @@ -463,59 +63,22 @@ class ChatModelManagementView_Previews: PreviewProvider { ), ]), editingModel: .init( - id: "3", - name: "Test Model 2", - format: .azureOpenAI, - apiKeyName: "key", - baseURL: "apple.com", - maxTokens: 3000, - supportsFunctionCalling: false, - modelName: "gpt-3.5-turbo" + model: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ) ) ), - reducer: ChatModelManagement( - userDefaults: UserDefaults(suiteName: "ChatModelManagementView_Previews")! - ) - ) - ) - } -} - -class ChatModelManagementView_Editing_Previews: PreviewProvider { - static var previews: some View { - ChatModelManagementView.EditingPanel( - store: .init( - initialState: .init( - id: "1", - name: "Test Model", - format: .openAI, - apiKeyName: "key", - baseURL: "google.com", - maxTokens: 3000, - supportsFunctionCalling: true, - modelName: "gpt-3.5-turbo" - - ), - reducer: ChatModelEdit() + reducer: ChatModelManagement() ) ) } } - -class ChatModelManagementView_Cell_Previews: PreviewProvider { - static var previews: some View { - ChatModelManagementView.Cell(chatModel: .init( - id: "1", - name: "Test Model", - format: .openAI, - info: .init( - apiKeyName: "key", - baseURL: "google.com", - maxTokens: 3000, - supportsFunctionCalling: true, - modelName: "gpt-3.5-turbo" - ) - ), isSelected: false) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift new file mode 100644 index 00000000..1ebde9ea --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -0,0 +1,179 @@ +import AIModel +import ComposableArchitecture +import Dependencies +import Keychain +import OpenAIService +import Preferences +import SwiftUI + +struct EmbeddingModelEdit: ReducerProtocol { + struct State: Equatable, Identifiable { + var id: String + @BindingState var name: String + @BindingState var format: EmbeddingModel.Format + @BindingState var maxTokens: Int = 4000 + @BindingState var modelName: String = "" + var apiKeyName: String { apiKeySelection.apiKeyName } + var baseURL: String { baseURLSelection.baseURL } + var availableModelNames: [String] = [] + var availableAPIKeys: [String] = [] + var isTesting = false + var suggestedMaxTokens: Int? + var apiKeySelection: APIKeySelection.State = .init() + var baseURLSelection: BaseURLSelection.State = .init() + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case appear + case saveButtonClicked + case cancelButtonClicked + case refreshAvailableModelNames + case testButtonClicked + case testSucceeded(String) + case testFailed(String) + case checkSuggestedMaxTokens + case apiKeySelection(APIKeySelection.Action) + case baseURLSelection(BaseURLSelection.Action) + } + + @Dependency(\.toast) var toast + @Dependency(\.apiKeyKeychain) var keychain + + var body: some ReducerProtocol { + BindingReducer() + + Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { + APIKeySelection() + } + + Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { + BaseURLSelection() + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableModelNames) + await send(.checkSuggestedMaxTokens) + } + + case .saveButtonClicked: + return .none + + case .cancelButtonClicked: + return .none + + case .testButtonClicked: + guard !state.isTesting else { return .none } + state.isTesting = true + let model = EmbeddingModel( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL, + maxTokens: state.maxTokens, + modelName: state.modelName + ) + ) + return .run { send in + do { + let tokenUsage = + try await EmbeddingService( + configuration: UserPreferenceEmbeddingConfiguration() + .overriding { + $0.model = model + } + ).embed(text: "Hello").usage.total_tokens + await send(.testSucceeded("Used \(tokenUsage) tokens.")) + } catch { + await send(.testFailed(error.localizedDescription)) + } + } + + case let .testSucceeded(message): + state.isTesting = false + toast(message, .info) + return .none + + case let .testFailed(message): + state.isTesting = false + toast(message, .error) + return .none + + case .refreshAvailableModelNames: + if state.format == .openAI { + state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue) + } + + return .none + + case .checkSuggestedMaxTokens: + guard state.format == .openAI, + let knownModel = OpenAIEmbeddingModel(rawValue: state.modelName) + else { + state.suggestedMaxTokens = nil + return .none + } + state.suggestedMaxTokens = knownModel.maxToken + 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 EmbeddingModelEdit.State { + init(model: EmbeddingModel) { + self.init( + id: model.id, + name: model.name, + format: model.format, + modelName: model.info.modelName, + apiKeySelection: .init( + apiKeyName: model.info.apiKeyName, + apiKeyManagement: .init(availableAPIKeyNames: [model.info.apiKeyName]) + ), + baseURLSelection: .init(baseURL: model.info.baseURL) + ) + } +} + +extension EmbeddingModel { + init(state: EmbeddingModelEdit.State) { + self.init( + id: state.id, + name: state.name, + format: state.format, + info: .init( + apiKeyName: state.apiKeyName, + baseURL: state.baseURL, + maxTokens: state.maxTokens, + modelName: state.modelName + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift new file mode 100644 index 00000000..825b8c58 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -0,0 +1,257 @@ +import AIModel +import ComposableArchitecture +import Preferences +import SwiftUI + +@MainActor +struct EmbeddingModelEditView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Form { + nameTextField + formatPicker + + WithViewStore(store, observe: { $0.format }) { viewStore in + switch viewStore.state { + case .openAI: + openAI + case .azureOpenAI: + azureOpenAI + case .openAICompatible: + openAICompatible + } + } + } + .padding() + + Divider() + + HStack { + WithViewStore(store, observe: { $0.isTesting }) { viewStore in + HStack(spacing: 8) { + Button("Test") { + store.send(.testButtonClicked) + } + .disabled(viewStore.state) + + if viewStore.state { + ProgressView() + .controlSize(.small) + } + } + } + + Spacer() + + Button("Cancel") { + store.send(.cancelButtonClicked) + } + .keyboardShortcut(.cancelAction) + + Button(action: { store.send(.saveButtonClicked) }) { + Text("Save") + } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + .textFieldStyle(.roundedBorder) + .onAppear { + store.send(.appear) + } + } + + var nameTextField: some View { + WithViewStore(store, removeDuplicates: { $0.name == $1.name }) { viewStore in + TextField("Name", text: viewStore.$name) + } + } + + var formatPicker: some View { + WithViewStore(store, removeDuplicates: { $0.format == $1.format }) { viewStore in + Picker( + selection: viewStore.$format, + content: { + ForEach( + EmbeddingModel.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) + } + } + }, + label: { Text("Format") } + ) + .pickerStyle(.segmented) + } + } + + func baseURLTextField(prompt: Text?) -> some View { + BaseURLPicker( + prompt: prompt, + store: store.scope( + state: \.baseURLSelection, + action: EmbeddingModelEdit.Action.baseURLSelection + ) + ) + } + + struct MaxTokensTextField: Equatable { + @BindingViewState var maxTokens: Int + var suggestedMaxTokens: Int? + } + + var maxTokensTextField: some View { + WithViewStore( + store, + observe: { + MaxTokensTextField( + maxTokens: $0.$maxTokens, + suggestedMaxTokens: $0.suggestedMaxTokens + ) + } + ) { viewStore in + HStack { + let textFieldBinding = Binding( + get: { String(viewStore.state.maxTokens) }, + set: { + if let selectionMaxToken = Int($0) { + viewStore.$maxTokens.wrappedValue = selectionMaxToken + } else { + viewStore.$maxTokens.wrappedValue = 0 + } + } + ) + + TextField(text: textFieldBinding) { + Text("Max Input Tokens") + .multilineTextAlignment(.trailing) + } + .overlay(alignment: .trailing) { + Stepper( + value: viewStore.$maxTokens, + in: 0...Int.max, + step: 100 + ) { + EmptyView() + } + } + .foregroundColor({ + guard let max = viewStore.state.suggestedMaxTokens else { + return .primary + } + if viewStore.state.maxTokens > max { + return .red + } + return .primary + }() as Color) + + if let max = viewStore.state.suggestedMaxTokens { + Text("Max: \(max)") + } + } + } + } + + struct APIKeyState: Equatable { + @BindingViewState var apiKeyName: String + var availableAPIKeys: [String] + } + + @ViewBuilder + var apiKeyNamePicker: some View { + APIKeyPicker(store: store.scope( + state: \.apiKeySelection, + action: EmbeddingModelEdit.Action.apiKeySelection + )) + } + + @ViewBuilder + var openAI: some View { + baseURLTextField(prompt: Text("https://api.openai.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$modelName, + content: { + ForEach(ChatGPTModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + } + + maxTokensTextField + } + + @ViewBuilder + var azureOpenAI: some View { + baseURLTextField(prompt: Text("https://xxxx.openai.azure.com")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Deployment Name", text: viewStore.$modelName) + } + + maxTokensTextField + } + + @ViewBuilder + var openAICompatible: some View { + baseURLTextField(prompt: Text("https://")) + apiKeyNamePicker + + WithViewStore( + store, + removeDuplicates: { $0.modelName == $1.modelName } + ) { viewStore in + TextField("Model Name", text: viewStore.$modelName) + } + + maxTokensTextField + } +} + +class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { + static var previews: some View { + EmbeddingModelEditView( + store: .init( + initialState: .init(model: EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + )), + reducer: EmbeddingModelEdit() + ) + ) + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift new file mode 100644 index 00000000..6b379e07 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -0,0 +1,157 @@ +import AIModel +import ComposableArchitecture +import Keychain +import Preferences +import SwiftUI + +extension EmbeddingModel: ManageableAIModel { + var formatName: String { + switch format { + case .openAI: return "OpenAI" + case .azureOpenAI: return "Azure OpenAI" + case .openAICompatible: return "OpenAI Compatible" + } + } + + @ViewBuilder + var infoDescriptors: some View { + Text(info.modelName) + + if !info.baseURL.isEmpty { + Image(systemName: "line.diagonal") + Text(info.baseURL) + } + + Image(systemName: "line.diagonal") + + Text("\(info.maxTokens) tokens") + + Image(systemName: "line.diagonal") + } +} + +struct EmbeddingModelManagement: AIModelManagement { + typealias Model = EmbeddingModel + + struct State: Equatable, AIModelManagementState { + typealias Model = EmbeddingModel + var models: IdentifiedArrayOf = [] + @PresentationState var editingModel: EmbeddingModelEdit.State? + var selectedModelId: Model.ID? { editingModel?.id } + } + + enum Action: Equatable, AIModelManagementAction { + typealias Model = EmbeddingModel + case appear + case createModel + case removeModel(id: Model.ID) + case selectModel(id: Model.ID) + case duplicateModel(id: Model.ID) + case moveModel(from: IndexSet, to: Int) + case embeddingModelItem(PresentationAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .appear: + if isPreview { return .none } + state.models = .init( + userDefaults.value(for: \.embeddingModels), + id: \.id, + uniquingIDsWith: { a, _ in a } + ) + + return .none + + case .createModel: + state.editingModel = .init( + id: UUID().uuidString, + name: "New Model", + format: .openAI + ) + return .none + + case let .removeModel(id): + state.models.remove(id: id) + persist(state) + return .none + + case let .selectModel(id): + guard let model = state.models[id: id] else { return .none } + state.editingModel = .init(model: model) + return .none + + case let .duplicateModel(id): + guard var model = state.models[id: id] else { return .none } + model.id = UUID().uuidString + model.name += " (Copy)" + + if let index = state.models.index(id: id) { + state.models.insert(model, at: index + 1) + } else { + state.models.append(model) + } + persist(state) + return .none + + case let .moveModel(from, to): + state.models.move(fromOffsets: from, toOffset: to) + persist(state) + return .none + + case .embeddingModelItem(.presented(.saveButtonClicked)): + guard let editingModel = state.editingModel, validateModel(editingModel) + else { return .none } + + if let index = state.models + .firstIndex(where: { $0.id == editingModel.id }) + { + state.models[index] = .init(state: editingModel) + } else { + state.models.append(.init(state: editingModel)) + } + persist(state) + return .run { send in + await send(.embeddingModelItem(.dismiss)) + } + + case .embeddingModelItem(.presented(.cancelButtonClicked)): + return .run { send in + await send(.embeddingModelItem(.dismiss)) + } + + case .embeddingModelItem: + return .none + } + }.ifLet(\.$editingModel, action: /Action.embeddingModelItem) { + EmbeddingModelEdit() + } + } + + func persist(_ state: State) { + let models = state.models + userDefaults.set(Array(models), for: \.embeddingModels) + } + + func validateModel(_ chatModel: EmbeddingModelEdit.State) -> Bool { + guard !chatModel.name.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + guard !chatModel.id.isEmpty else { + toast("Model ID cannot be empty", .error) + return false + } + + guard !chatModel.modelName.isEmpty else { + toast("Model name cannot be empty", .error) + return false + } + return true + } +} + diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift new file mode 100644 index 00000000..a71b356d --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagementView.swift @@ -0,0 +1,80 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +struct EmbeddingModelManagementView: View { + let store: StoreOf + + var body: some View { + AIModelManagementView(store: store) + .sheet(store: store.scope( + state: \.$editingModel, + action: EmbeddingModelManagement.Action.embeddingModelItem + )) { store in + EmbeddingModelEditView(store: store) + .frame(minWidth: 400) + } + } +} + +// MARK: - Previews + +class EmbeddingModelManagementView_Previews: PreviewProvider { + static var previews: some View { + EmbeddingModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + EmbeddingModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + EmbeddingModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: .init( + model: EmbeddingModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + modelName: "gpt-3.5-turbo" + ) + ) + ) + ), + reducer: EmbeddingModelManagement() + ) + ) + } +} diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift new file mode 100644 index 00000000..74db33ff --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -0,0 +1,242 @@ +import AIModel +import ComposableArchitecture +import SwiftUI + +protocol AIModelManagementAction { + associatedtype Model: ManageableAIModel + static var appear: Self { get } + static var createModel: Self { get } + static func removeModel(id: Model.ID) -> Self + static func selectModel(id: Model.ID) -> Self + static func duplicateModel(id: Model.ID) -> Self + static func moveModel(from: IndexSet, to: Int) -> Self +} + +protocol AIModelManagementState: Equatable { + associatedtype Model: ManageableAIModel + var models: IdentifiedArrayOf { get } + var selectedModelId: Model.ID? { get } +} + +protocol AIModelManagement: ReducerProtocol where + Action: AIModelManagementAction, + State: AIModelManagementState, + Action.Model == Self.Model, + State.Model == Self.Model +{ + associatedtype Model: ManageableAIModel +} + +protocol ManageableAIModel: Identifiable { + associatedtype V: View + var name: String { get } + var formatName: String { get } + var infoDescriptors: V { get } +} + +struct AIModelManagementView: View + where Management.Model == Model +{ + let store: StoreOf + + var body: some View { + VStack { + HStack { + Spacer() + Button("Add Model") { + store.send(.createModel) + } + }.padding(4) + + ModelList(store: store) + } + .onAppear { + store.send(.appear) + } + } + + struct ModelList: View { + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + List { + ForEach(viewStore.state.models) { model in + let isSelected = viewStore.state.selectedModelId == model.id + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal") + + Button(action: { + viewStore.send(.selectModel(id: model.id)) + }) { + Cell(model: model, isSelected: isSelected) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + Button("Duplicate") { + store.send(.duplicateModel(id: model.id)) + } + Button("Remove") { + store.send(.removeModel(id: model.id)) + } + } + } + } + .onMove(perform: { indices, newOffset in + viewStore.send(.moveModel(from: indices, to: newOffset)) + }) + } + .removeBackground() + .listStyle(.plain) + .listRowInsets(EdgeInsets()) + } + } + } + + struct Cell: View { + let model: Model + let isSelected: Bool + @State var isHovered: Bool = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(model.formatName) + .foregroundColor(isSelected ? .white : .primary) + .font(.subheadline.bold()) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + RoundedRectangle(cornerRadius: 4) + .fill( + isSelected + ? .white.opacity(0.2) + : Color.primary.opacity(0.1) + ) + } + + Text(model.name) + .font(.headline) + } + + HStack(spacing: 4) { + model.infoDescriptors + } + .font(.subheadline) + .opacity(0.7) + .padding(.leading, 2) + } + Spacer() + } + .onHover(perform: { + isHovered = $0 + }) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill({ + switch (isSelected, isHovered) { + case (true, _): + return Color.accentColor + case (_, true): + return Color.primary.opacity(0.1) + case (_, false): + return Color.clear + } + }() as Color) + } + .foregroundColor(isSelected ? .white : .primary) + .animation(.easeInOut(duration: 0.1), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: isHovered) + } + } +} + +// MARK: - Previews + + class AIModelManagement_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: .init( + model: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ) + ) + ), + reducer: ChatModelManagement() + ) + ) + } + } + + + class AIModelManagement_Cell_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView.Cell(model: ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), isSelected: false) + } + } + + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift new file mode 100644 index 00000000..673707e5 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift @@ -0,0 +1,25 @@ +import SwiftUI +import ComposableArchitecture + +struct BaseURLPicker: View { + let prompt: Text? + let store: StoreOf + + var body: some View { + WithViewStore(store) { viewStore in + TextField("Base URL", text: viewStore.$baseURL, prompt: prompt) + .overlay(alignment: .trailing) { + Picker( + "", + selection: viewStore.$baseURL, + content: { + ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in + Text(baseURL).tag(baseURL) + } + } + ) + .frame(width: 20) + } + } + } +} diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift new file mode 100644 index 00000000..f06dbeb7 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -0,0 +1,46 @@ +import ComposableArchitecture +import Foundation +import Preferences +import SwiftUI + +struct BaseURLSelection: ReducerProtocol { + struct State: Equatable { + @BindingState var baseURL: String = "" + var availableBaseURLs: [String] = [] + } + + enum Action: Equatable, BindableAction { + case appear + case refreshAvailableBaseURLNames + case binding(BindingAction) + } + + @Dependency(\.toast) var toast + @Dependency(\.userDefaults) var userDefaults + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + await send(.refreshAvailableBaseURLNames) + } + + case .refreshAvailableBaseURLNames: + let chatModels = userDefaults.value(for: \.chatModels) + let embeddingModels = userDefaults.value(for: \.embeddingModels) + var allBaseURLs = Set( + chatModels.map(\.info.baseURL) + embeddingModels.map(\.info.baseURL) + ) + state.availableBaseURLs = Array(allBaseURLs).sorted() + return .none + + case .binding: + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 06de9c24..52f6834f 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -9,8 +9,10 @@ extension List { func removeBackground() -> some View { if #available(macOS 13.0, *) { scrollContentBackground(.hidden) + .listRowBackground(EmptyView()) } else { background(Color.clear) + .listRowBackground(EmptyView()) } } } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 7b96456b..65f0ab1d 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -9,12 +9,16 @@ import LicenseManagement struct HostApp: ReducerProtocol { struct State: Equatable { var general = General.State() + var chatModelManagement = ChatModelManagement.State() + var embeddingModelManagement = EmbeddingModelManagement.State() } enum Action: Equatable { case appear case informExtensionServiceAboutLicenseKeyChange case general(General.Action) + case chatModelManagement(ChatModelManagement.Action) + case embeddingModelManagement(EmbeddingModelManagement.Action) } @Dependency(\.toast) var toast @@ -23,11 +27,20 @@ struct HostApp: ReducerProtocol { Scope(state: \.general, action: /Action.general) { General() } + + Scope(state: \.chatModelManagement, action: /Action.chatModelManagement) { + ChatModelManagement() + } + + Scope(state: \.embeddingModelManagement, action: /Action.embeddingModelManagement) { + EmbeddingModelManagement() + } Reduce { _, action in switch action { case .appear: return .none + case .informExtensionServiceAboutLicenseKeyChange: #if canImport(LicenseManagement) return .run { _ in @@ -42,10 +55,54 @@ struct HostApp: ReducerProtocol { #else return .none #endif + case .general: return .none + + case .chatModelManagement: + return .none + + case .embeddingModelManagement: + return .none } } } } +import Dependencies +import Preferences +import Keychain + +struct UserDefaultsDependencyKey: DependencyKey { + static var liveValue: UserDefaultsType = UserDefaults.shared + static var previewValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppPreview")! + it.removePersistentDomain(forName: "HostAppPreview") + return it + }() + static var testValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppTest")! + it.removePersistentDomain(forName: "HostAppTest") + return it + }() +} + +extension DependencyValues { + var userDefaults: UserDefaultsType { + get { self[UserDefaultsDependencyKey.self] } + set { self[UserDefaultsDependencyKey.self] = newValue } + } +} + +struct APIKeyKeychainDependencyKey: DependencyKey { + static var liveValue: KeychainType = Keychain.apiKey + static var previewValue: KeychainType = FakeKeyChain() + static var testValue: KeychainType = FakeKeyChain() +} + +extension DependencyValues { + var apiKeyKeychain: KeychainType { + get { self[APIKeyKeychainDependencyKey.self] } + set { self[APIKeyKeychainDependencyKey.self] = newValue } + } +} diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 5960e849..413156f8 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -1,7 +1,10 @@ import SwiftUI +import ComposableArchitecture struct ServiceView: View { + let store: StoreOf @State var tag = 0 + var body: some View { SidebarTabView(tag: $tag) { ScrollView { @@ -23,19 +26,25 @@ struct ServiceView: View { ) ScrollView { - OpenAIView().padding() + ChatModelManagementView(store: store.scope( + state: \.chatModelManagement, + action: HostApp.Action.chatModelManagement + )).padding() }.sidebarItem( tag: 2, - title: "OpenAI", + title: "Chat Models", subtitle: "Chat, Prompt to Code", image: "globe" ) ScrollView { - AzureView().padding() + EmbeddingModelManagementView(store: store.scope( + state: \.embeddingModelManagement, + action: HostApp.Action.embeddingModelManagement + )).padding() }.sidebarItem( tag: 3, - title: "Azure", + title: "Embedding Models", subtitle: "Chat, Prompt to Code", image: "globe" ) @@ -54,6 +63,6 @@ struct ServiceView: View { struct AccountView_Previews: PreviewProvider { static var previews: some View { - ServiceView() + ServiceView(store: .init(initialState: .init(), reducer: HostApp())) } } diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift index 40cf5bd9..174280d8 100644 --- a/Tool/Sources/AIModel/EmbeddingModel.swift +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -1,7 +1,7 @@ import Foundation import CodableWrappers -public struct EmbeddingModel: Codable, Equatable { +public struct EmbeddingModel: Codable, Equatable, Identifiable { public var id: String public var name: String @FallbackDecoding @@ -16,10 +16,10 @@ public struct EmbeddingModel: Codable, Equatable { self.info = info } - public enum Format: String, Codable, Equatable { + public enum Format: String, Codable, Equatable, CaseIterable { case openAI case azureOpenAI - case openAIFormat + case openAICompatible } public struct Info: Codable, Equatable { @@ -51,7 +51,7 @@ public struct EmbeddingModel: Codable, Equatable { public var endpoint: String { switch format { - case .openAI, .openAIFormat: + case .openAI, .openAICompatible: let baseURL = info.baseURL if baseURL.isEmpty { return "https://api.openai.com/v1/embeddings" } return "\(baseURL)/v1/embeddings" diff --git a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift index 8229ed16..0ad7cc07 100644 --- a/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/EmbeddingConfiguration.swift @@ -5,7 +5,6 @@ import Preferences public protocol EmbeddingConfiguration { var model: EmbeddingModel { get } - var endpoint: String { get } var apiKey: String { get } var maxToken: Int { get } } diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift index d8bb980c..396cfd98 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceEmbeddingConfiguration.swift @@ -21,14 +21,17 @@ public class OverridingEmbeddingConfiguration< Configuration: EmbeddingConfiguration >: EmbeddingConfiguration { public struct Overriding { - var modelId: String? - var maxTokens: Int? + public var modelId: String? + public var model: EmbeddingModel? + public var maxTokens: Int? public init( modelId: String? = nil, + model: EmbeddingModel? = nil, maxTokens: Int? = nil ) { self.modelId = modelId + self.model = model self.maxTokens = maxTokens } } @@ -42,6 +45,7 @@ public class OverridingEmbeddingConfiguration< } public var model: EmbeddingModel { + if let model = overriding.model { return model } let models = UserDefaults.shared.value(for: \.embeddingModels) guard let id = overriding.modelId, let model = models.first(where: { $0.id == id }) diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index fa11a03d..a17b0863 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -55,7 +55,7 @@ public struct EmbeddingService { request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !configuration.apiKey.isEmpty { switch model.format { - case .openAI, .openAIFormat: + case .openAI, .openAICompatible: request.setValue( "Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization" @@ -104,7 +104,7 @@ public struct EmbeddingService { request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !configuration.apiKey.isEmpty { switch model.format { - case .openAI, .openAIFormat: + case .openAI, .openAICompatible: request.setValue( "Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization" diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift index 2204fd1a..9370bf6c 100644 --- a/Tool/Sources/Preferences/UserDefaults.swift +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -2,6 +2,11 @@ import AIModel import Configs import Foundation +public protocol UserDefaultsType { + func value(forKey: String) -> Any? + func set(_ value: Any?, forKey: String) +} + public extension UserDefaults { static var shared = UserDefaults(suiteName: userDefaultSuiteName)! @@ -19,6 +24,8 @@ public extension UserDefaults { } } +extension UserDefaults: UserDefaultsType {} + public protocol UserDefaultsStorable {} extension Int: UserDefaultsStorable {} @@ -48,7 +55,7 @@ extension Array: RawRepresentable where Element: Codable { } } -public extension UserDefaults { +public extension UserDefaultsType { // MARK: Normal Types func value( @@ -146,7 +153,7 @@ public extension UserDefaults { // MARK: - Deprecated Key Accessor -public extension UserDefaults { +public extension UserDefaultsType { // MARK: Normal Types func deprecatedValue( @@ -179,7 +186,7 @@ public extension UserDefaults { } } -public extension UserDefaults { +public extension UserDefaultsType { @available(*, deprecated, message: "This preference key is deprecated.") func value( for keyPath: KeyPath> From 51a45c7a6a5c309b75139ddc7949801eb34037e2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 10:59:18 +0800 Subject: [PATCH 34/81] Fix Keychain.getAll, change scope implementation --- Tool/Package.swift | 5 ++++ Tool/Sources/Keychain/Keychain.swift | 28 +++++++++----------- Tool/Tests/KeychainTests/KeychainTests.swift | 16 +++++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 Tool/Tests/KeychainTests/KeychainTests.swift diff --git a/Tool/Package.swift b/Tool/Package.swift index db4a449c..ad5d5be0 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -77,6 +77,11 @@ let package = Package( dependencies: ["Configs"] ), + .testTarget( + name: "KeychainTests", + dependencies: ["Keychain"] + ), + .target( name: "Toast", dependencies: [.product( diff --git a/Tool/Sources/Keychain/Keychain.swift b/Tool/Sources/Keychain/Keychain.swift index 0dfe7266..f3601ab5 100644 --- a/Tool/Sources/Keychain/Keychain.swift +++ b/Tool/Sources/Keychain/Keychain.swift @@ -35,7 +35,7 @@ public struct Keychain: KeychainType { let service = keychainService let accessGroup = keychainAccessGroup let scope: String - + public static var apiKey: Keychain { Keychain(scope: "apiKey") } @@ -61,7 +61,6 @@ public struct Keychain: KeychainType { } func set(_ value: String, key: String) throws { - let key = scopeKey(key) let query = query(key).merging([ kSecValueData as String: value.data(using: .utf8) ?? Data(), ], uniquingKeysWith: { _, b in b }) @@ -80,18 +79,18 @@ public struct Keychain: KeychainType { if scope.isEmpty { return key } - return "\(scope).\(key)" + return "\(scope)::\(key)" } - + func escapeScope(_ key: String) -> String? { if scope.isEmpty { return key } - if !key.hasPrefix("\(scope).") { return nil } - return key.replacingOccurrences(of: "\(scope).", with: "") + if !key.hasPrefix("\(scope)::") { return nil } + return key.replacingOccurrences(of: "\(scope)::", with: "") } - public func getAll() throws -> [String : String] { + public func getAll() throws -> [String: String] { let query = [ kSecClass as String: kSecClassGenericPassword as String, kSecAttrService as String: service, @@ -109,18 +108,17 @@ public struct Keychain: KeychainType { var dict = [String: String]() for item in items { - guard let keyData = item[kSecAttrAccount as String] as? Data, - let key = String(data: keyData, encoding: .utf8), - let valueData = item[kSecValueData as String] as? Data, - let value = String(data: valueData, encoding: .utf8), + guard let key = item[kSecAttrAccount as String] as? String, let escapedKey = escapeScope(key) - else { - continue - } + else { continue } + guard let valueData = item[kSecValueData as String] as? Data, + let value = String(data: valueData, encoding: .utf8) + else { continue } dict[escapedKey] = value } + return dict } - + return [:] } diff --git a/Tool/Tests/KeychainTests/KeychainTests.swift b/Tool/Tests/KeychainTests/KeychainTests.swift new file mode 100644 index 00000000..f5e39dfe --- /dev/null +++ b/Tool/Tests/KeychainTests/KeychainTests.swift @@ -0,0 +1,16 @@ +import Foundation +import XCTest + +@testable import Keychain + +class KeychainTests: XCTestCase { + func test_scope_key() { + let keychain = Keychain(scope: "scope") + XCTAssertEqual(keychain.scopeKey("key"), "scope::key") + } + + func test_escape_scope() { + let keychain = Keychain(scope: "scope") + XCTAssertEqual(keychain.escapeScope("scope::key"), "key") + } +} From 7cb8e6e2d39bce94dd07b8ae5c7c43e5bf27b86c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 10:59:53 +0800 Subject: [PATCH 35/81] Fix migrateTo240 to apply even if the settings already has a default value --- .../ServiceUpdateMigration/MigrateTo240.swift | 191 +++++++++--------- 1 file changed, 98 insertions(+), 93 deletions(-) diff --git a/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift index feba89bd..b926b482 100644 --- a/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift +++ b/Core/Sources/ServiceUpdateMigration/MigrateTo240.swift @@ -10,102 +10,107 @@ func migrateTo240( let finishedMigrationKey = "MigrateTo240Finished" if defaults.bool(forKey: finishedMigrationKey) { return } - let chatModelOpenAIId = UUID().uuidString - let chatModelAzureOpenAIId = UUID().uuidString - let embeddingModelOpenAIId = UUID().uuidString - let embeddingModelAzureOpenAIId = UUID().uuidString - - let openAIAPIKeyName = "OpenAI" - let openAIAPIKey = defaults.deprecatedValue(for: \.openAIAPIKey) - if !openAIAPIKey.isEmpty { - try keychain.update(openAIAPIKey, key: openAIAPIKeyName) - } - - let azureOpenAIAPIKeyName = "Azure OpenAI" - let azureOpenAIAPIKey = defaults.deprecatedValue(for: \.azureOpenAIAPIKey) - if !azureOpenAIAPIKey.isEmpty { - try keychain.update(azureOpenAIAPIKey, key: azureOpenAIAPIKeyName) - } - - defaults.setupDefaultValue(for: \.chatModels, defaultValue: { - let openAIModel = ChatGPTModel(rawValue: defaults.deprecatedValue(for: \.chatGPTModel)) - - let openAI = ChatModel( - id: chatModelOpenAIId, - name: "OpenAI", - format: .openAI, - info: .init( - apiKeyName: openAIAPIKeyName, - baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), - maxTokens: openAIModel?.maxToken ?? defaults - .deprecatedValue(for: \.chatGPTMaxToken), - modelName: openAIModel?.rawValue ?? defaults - .deprecatedValue(for: \.chatGPTModel) + do { + let chatModelOpenAIId = UUID().uuidString + let chatModelAzureOpenAIId = UUID().uuidString + let embeddingModelOpenAIId = UUID().uuidString + let embeddingModelAzureOpenAIId = UUID().uuidString + + let openAIAPIKeyName = "OpenAI" + let openAIAPIKey = defaults.deprecatedValue(for: \.openAIAPIKey) + if !openAIAPIKey.isEmpty { + try keychain.update(openAIAPIKey, key: openAIAPIKeyName) + } + + let azureOpenAIAPIKeyName = "Azure OpenAI" + let azureOpenAIAPIKey = defaults.deprecatedValue(for: \.azureOpenAIAPIKey) + if !azureOpenAIAPIKey.isEmpty { + try keychain.update(azureOpenAIAPIKey, key: azureOpenAIAPIKeyName) + } + + defaults.set({ + let openAIModel = ChatGPTModel(rawValue: defaults.deprecatedValue(for: \.chatGPTModel)) + + let openAI = ChatModel( + id: chatModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? defaults + .deprecatedValue(for: \.chatGPTMaxToken), + modelName: openAIModel?.rawValue ?? defaults + .deprecatedValue(for: \.chatGPTModel) + ) ) - ) - let azureOpenAI = ChatModel( - id: chatModelAzureOpenAIId, - name: "Azure OpenAI", - format: .azureOpenAI, - info: .init( - apiKeyName: azureOpenAIAPIKeyName, - baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), - maxTokens: defaults.deprecatedValue(for: \.chatGPTMaxToken), - modelName: defaults - .deprecatedValue(for: \.azureChatGPTDeployment) + let azureOpenAI = ChatModel( + id: chatModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: defaults.deprecatedValue(for: \.chatGPTMaxToken), + modelName: defaults + .deprecatedValue(for: \.azureChatGPTDeployment) + ) ) - ) - - return [openAI, azureOpenAI] - }()) - - defaults.setupDefaultValue(for: \.defaultChatFeatureChatModelId, defaultValue: { - if defaults.deprecatedValue(for: \.chatFeatureProvider) == .azureOpenAI { - return chatModelAzureOpenAIId - } - return chatModelOpenAIId - }()) - - defaults.setupDefaultValue(for: \.embeddingModels, defaultValue: { - let openAIModel = OpenAIEmbeddingModel( - rawValue: defaults.deprecatedValue(for: \.embeddingModel) - ) - - let openAI = EmbeddingModel( - id: embeddingModelOpenAIId, - name: "OpenAI", - format: .openAI, - info: .init( - apiKeyName: openAIAPIKeyName, - baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), - maxTokens: openAIModel?.maxToken ?? 8191, - modelName: openAIModel?.rawValue ?? defaults.deprecatedValue(for: \.embeddingModel) + + return [openAI, azureOpenAI] + }(), for: \.chatModels) + + defaults.set({ + if defaults.deprecatedValue(for: \.chatFeatureProvider) == .azureOpenAI { + return chatModelAzureOpenAIId + } + return chatModelOpenAIId + }(), for: \.defaultChatFeatureChatModelId) + + defaults.set({ + let openAIModel = OpenAIEmbeddingModel( + rawValue: defaults.deprecatedValue(for: \.embeddingModel) ) - ) - - let azureOpenAI = EmbeddingModel( - id: embeddingModelAzureOpenAIId, - name: "Azure OpenAI", - format: .azureOpenAI, - info: .init( - apiKeyName: azureOpenAIAPIKeyName, - baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), - maxTokens: 8191, - modelName: defaults - .deprecatedValue(for: \.azureEmbeddingDeployment) + + let openAI = EmbeddingModel( + id: embeddingModelOpenAIId, + name: "OpenAI", + format: .openAI, + info: .init( + apiKeyName: openAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.openAIBaseURL), + maxTokens: openAIModel?.maxToken ?? 8191, + modelName: openAIModel?.rawValue ?? defaults.deprecatedValue(for: \.embeddingModel) + ) ) - ) - - return [openAI, azureOpenAI] - }()) - - defaults.setupDefaultValue(for: \.defaultChatFeatureEmbeddingModelId, defaultValue: { - if defaults.deprecatedValue(for: \.embeddingFeatureProvider) == .azureOpenAI { - return embeddingModelAzureOpenAIId - } - return embeddingModelOpenAIId - }()) - - defaults.set(true, forKey: finishedMigrationKey) + + let azureOpenAI = EmbeddingModel( + id: embeddingModelAzureOpenAIId, + name: "Azure OpenAI", + format: .azureOpenAI, + info: .init( + apiKeyName: azureOpenAIAPIKeyName, + baseURL: defaults.deprecatedValue(for: \.azureOpenAIBaseURL), + maxTokens: 8191, + modelName: defaults + .deprecatedValue(for: \.azureEmbeddingDeployment) + ) + ) + + return [openAI, azureOpenAI] + }(), for: \.embeddingModels) + + defaults.set({ + if defaults.deprecatedValue(for: \.embeddingFeatureProvider) == .azureOpenAI { + return embeddingModelAzureOpenAIId + } + return embeddingModelOpenAIId + }(), for: \.defaultChatFeatureEmbeddingModelId) + + defaults.set(true, forKey: finishedMigrationKey) + } catch { + print(error.localizedDescription) + throw error + } } From 70a1ab6d98cac6e20845abd93e4ba036e2cdf774 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:00:13 +0800 Subject: [PATCH 36/81] Add test to test plan --- TestPlan.xctestplan | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index bc08db6d..9b31428f 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -133,6 +133,13 @@ "identifier" : "ServiceUpdateMigrationTests", "name" : "ServiceUpdateMigrationTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "KeychainTests", + "name" : "KeychainTests" + } } ], "version" : 1 From fb8470bbb738e987fbc02b026a29420e7e87ec4a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:00:21 +0800 Subject: [PATCH 37/81] Fix --- Core/Sources/HostApp/TabContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 77c62a2c..02083499 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -43,7 +43,7 @@ public struct TabContainer: View { title: "General", image: "app.gift" ) - ServiceView().tabBarItem( + ServiceView(store: store).tabBarItem( tag: 1, title: "Service", image: "globe" From fdf52566cbff61a29c555b6f332398a0deb88cdc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:00:36 +0800 Subject: [PATCH 38/81] Remove the scroll view wrapper --- Core/Sources/HostApp/ServiceView.swift | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 413156f8..ef259ee6 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -25,24 +25,20 @@ struct ServiceView: View { image: "globe" ) - ScrollView { - ChatModelManagementView(store: store.scope( - state: \.chatModelManagement, - action: HostApp.Action.chatModelManagement - )).padding() - }.sidebarItem( + ChatModelManagementView(store: store.scope( + state: \.chatModelManagement, + action: HostApp.Action.chatModelManagement + )).sidebarItem( tag: 2, title: "Chat Models", subtitle: "Chat, Prompt to Code", image: "globe" ) - ScrollView { - EmbeddingModelManagementView(store: store.scope( - state: \.embeddingModelManagement, - action: HostApp.Action.embeddingModelManagement - )).padding() - }.sidebarItem( + EmbeddingModelManagementView(store: store.scope( + state: \.embeddingModelManagement, + action: HostApp.Action.embeddingModelManagement + )).sidebarItem( tag: 3, title: "Embedding Models", subtitle: "Chat, Prompt to Code", From c8e720c82c9f55aecd06d46d605671f2e6e8d28b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:00:48 +0800 Subject: [PATCH 39/81] Adjust AIModelManagementView style --- .../EmbeddingModelManagement/EmbeddingModelManagement.swift | 2 -- .../SharedModelManagement/AIModelManagementVIew.swift | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift index 6b379e07..eda907d3 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -25,8 +25,6 @@ extension EmbeddingModel: ManageableAIModel { Image(systemName: "line.diagonal") Text("\(info.maxTokens) tokens") - - Image(systemName: "line.diagonal") } } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift index 74db33ff..038b5262 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -40,13 +40,15 @@ struct AIModelManagementView var body: some View { - VStack { + VStack(spacing: 0) { HStack { Spacer() Button("Add Model") { store.send(.createModel) } }.padding(4) + + Divider() ModelList(store: store) } From 40eba80858f143a46187fa0809e659b56c713a58 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:01:02 +0800 Subject: [PATCH 40/81] Sort api key names --- .../AccountSettings/APIKeyManagement/APIKeyManangement.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift index c9633dc4..3ff3188e 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyManangement.swift @@ -51,7 +51,7 @@ struct APIKeyManagement: ReducerProtocol { case .refreshAvailableAPIKeyNames: do { let pairs = try keychain.getAll() - state.availableAPIKeyNames = Array(pairs.keys) + state.availableAPIKeyNames = Array(pairs.keys).sorted() } catch { toast(error.localizedDescription, .error) } From 60cfdf43476b7944ba737ba1de3c079c549fad65 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:17:39 +0800 Subject: [PATCH 41/81] Adjust implementation of text field with picker --- .../APIKeyManagement/APIKeyPicker.swift | 16 +- .../ChatModelEditView.swift | 3 + .../EmbeddingModelEditView.swift | 5 +- .../AIModelManagementVIew.swift | 153 ++++++++++-------- .../SharedModelManagement/BaseURLPicker.swift | 14 +- .../BaseURLSelection.swift | 2 +- 6 files changed, 115 insertions(+), 78 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift index 11e5fd47..a18e0a4c 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift @@ -14,6 +14,13 @@ struct APIKeyPicker: View { if viewStore.state.availableAPIKeyNames.isEmpty { Text("No API key found, please add a new one →") } + + if !viewStore.state.availableAPIKeyNames.contains(viewStore.state.apiKeyName), + !viewStore.state.apiKeyName.isEmpty { + Text("Key not found: \(viewStore.state.apiKeyName)") + .tag(viewStore.state.apiKeyName) + } + ForEach(viewStore.state.availableAPIKeyNames, id: \.self) { name in Text(name).tag(name) } @@ -21,12 +28,11 @@ struct APIKeyPicker: View { }, label: { Text("API Key") } ) - } - Button(action: { store.send(.manageAPIKeysButtonClicked) }) { - Text(Image(systemName: "key")) - } - .sheet(isPresented: viewStore.$isAPIKeyManagementPresented) { + Button(action: { store.send(.manageAPIKeysButtonClicked) }) { + Text(Image(systemName: "key")) + } + }.sheet(isPresented: viewStore.$isAPIKeyManagementPresented) { APIKeyManagementView(store: store.scope( state: \.apiKeyManagement, action: APIKeySelection.Action.apiKeyManagement diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 7f85a4e6..2427b442 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -203,6 +203,9 @@ struct ChatModelEditView: View { "", selection: viewStore.$modelName, content: { + if ChatGPTModel(rawValue: viewStore.state.modelName) == nil { + Text("Custom Model").tag(viewStore.state.modelName) + } ForEach(ChatGPTModel.allCases, id: \.self) { model in Text(model.rawValue).tag(model.rawValue) } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index 825b8c58..c1162181 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -191,7 +191,10 @@ struct EmbeddingModelEditView: View { "", selection: viewStore.$modelName, content: { - ForEach(ChatGPTModel.allCases, id: \.self) { model in + if OpenAIEmbeddingModel(rawValue: viewStore.state.modelName) == nil { + Text("Custom Model").tag(viewStore.state.modelName) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in Text(model.rawValue).tag(model.rawValue) } } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift index 038b5262..b767eac7 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -47,7 +47,7 @@ struct AIModelManagementView( - store: .init( - initialState: .init( - models: IdentifiedArray(uniqueElements: [ - ChatModel( - id: "1", - name: "Test Model", - format: .openAI, - info: .init( - apiKeyName: "key", - baseURL: "google.com", - maxTokens: 3000, - supportsFunctionCalling: true, - modelName: "gpt-3.5-turbo" - ) - ), - ChatModel( - id: "2", - name: "Test Model 2", - format: .azureOpenAI, - info: .init( - apiKeyName: "key", - baseURL: "apple.com", - maxTokens: 3000, - supportsFunctionCalling: false, - modelName: "gpt-3.5-turbo" - ) - ), - ChatModel( - id: "3", - name: "Test Model 3", - format: .openAICompatible, - info: .init( - apiKeyName: "key", - baseURL: "apple.com", - maxTokens: 3000, - supportsFunctionCalling: false, - modelName: "gpt-3.5-turbo" - ) - ), - ]), - editingModel: .init( - model: ChatModel( - id: "3", - name: "Test Model 3", - format: .openAICompatible, - info: .init( - apiKeyName: "key", - baseURL: "apple.com", - maxTokens: 3000, - supportsFunctionCalling: false, - modelName: "gpt-3.5-turbo" - ) - ) - ) - ), - reducer: ChatModelManagement() - ) - ) - } - } - - - class AIModelManagement_Cell_Previews: PreviewProvider { +class AIModelManagement_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView( + store: .init( + initialState: .init( + models: IdentifiedArray(uniqueElements: [ + ChatModel( + id: "1", + name: "Test Model", + format: .openAI, + info: .init( + apiKeyName: "key", + baseURL: "google.com", + maxTokens: 3000, + supportsFunctionCalling: true, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "2", + name: "Test Model 2", + format: .azureOpenAI, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ), + ]), + editingModel: .init( + model: ChatModel( + id: "3", + name: "Test Model 3", + format: .openAICompatible, + info: .init( + apiKeyName: "key", + baseURL: "apple.com", + maxTokens: 3000, + supportsFunctionCalling: false, + modelName: "gpt-3.5-turbo" + ) + ) + ) + ), + reducer: ChatModelManagement() + ) + ) + } +} + +class AIModelManagement_Empty_Previews: PreviewProvider { + static var previews: some View { + AIModelManagementView( + store: .init( + initialState: .init(models: []), + reducer: ChatModelManagement() + ) + ) + } +} + +class AIModelManagement_Cell_Previews: PreviewProvider { static var previews: some View { AIModelManagementView.Cell(model: ChatModel( id: "1", @@ -239,6 +255,5 @@ struct AIModelManagementView - + var body: some View { WithViewStore(store) { viewStore in TextField("Base URL", text: viewStore.$baseURL, prompt: prompt) @@ -13,6 +13,12 @@ struct BaseURLPicker: View { "", selection: viewStore.$baseURL, content: { + if !viewStore.state.availableBaseURLs + .contains(viewStore.state.baseURL) + { + Text("Custom Value").tag(viewStore.state.baseURL) + } + ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in Text(baseURL).tag(baseURL) } @@ -20,6 +26,10 @@ struct BaseURLPicker: View { ) .frame(width: 20) } + .onAppear { + viewStore.send(.appear) + } } } } + diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift index f06dbeb7..ef25c085 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -31,7 +31,7 @@ struct BaseURLSelection: ReducerProtocol { case .refreshAvailableBaseURLNames: let chatModels = userDefaults.value(for: \.chatModels) let embeddingModels = userDefaults.value(for: \.embeddingModels) - var allBaseURLs = Set( + let allBaseURLs = Set( chatModels.map(\.info.baseURL) + embeddingModels.map(\.info.baseURL) ) state.availableBaseURLs = Array(allBaseURLs).sorted() From 80a03d0e930837447f0439f5215e1e719bf6e46a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:19:40 +0800 Subject: [PATCH 42/81] Trim white spaces and newlines from suggested base urls --- .../SharedModelManagement/BaseURLSelection.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift index ef25c085..dc1dac76 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -32,7 +32,10 @@ struct BaseURLSelection: ReducerProtocol { let chatModels = userDefaults.value(for: \.chatModels) let embeddingModels = userDefaults.value(for: \.embeddingModels) let allBaseURLs = Set( - chatModels.map(\.info.baseURL) + embeddingModels.map(\.info.baseURL) + chatModels.map(\.info.baseURL) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + embeddingModels.map(\.info.baseURL) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ) state.availableBaseURLs = Array(allBaseURLs).sorted() return .none From 3bce5a8eca84e40829c7ad38412e5f27ab22d7d7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:31:32 +0800 Subject: [PATCH 43/81] Trim whitespaces and newlines --- .../AccountSettings/APIKeyManagement/APIKeySubmission.swift | 5 ++++- .../AccountSettings/ChatModelManagement/ChatModelEdit.swift | 4 ++-- .../EmbeddingModelManagement/EmbeddingModelEdit.swift | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift index d27285a8..64f16b7d 100644 --- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift +++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeySubmission.swift @@ -32,7 +32,10 @@ struct APIKeySubmission: ReducerProtocol { guard !state.name.isEmpty else { throw E.nameIsEmpty } guard !state.key.isEmpty else { throw E.keyIsEmpty } - try keychain.update(state.name, key: state.key) + try keychain.update( + state.key, + key: state.name.trimmingCharacters(in: .whitespacesAndNewlines) + ) return .run { send in await send(.saveFinished) } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 89da58a6..d43793c0 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -173,10 +173,10 @@ extension ChatModel { format: state.format, info: .init( apiKeyName: state.apiKeyName, - baseURL: state.baseURL, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), maxTokens: state.maxTokens, supportsFunctionCalling: state.supportsFunctionCalling, - modelName: state.modelName + modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines) ) ) } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index 1ebde9ea..c33221f8 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -169,9 +169,9 @@ extension EmbeddingModel { format: state.format, info: .init( apiKeyName: state.apiKeyName, - baseURL: state.baseURL, + baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines), maxTokens: state.maxTokens, - modelName: state.modelName + modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines) ) ) } From 90b72d19d04e9cdf8a4a6748cf46e2adca81a6be Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:58:09 +0800 Subject: [PATCH 44/81] Fix max token display --- .../EmbeddingModelManagement/EmbeddingModelEdit.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index c33221f8..6e0918e1 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -46,7 +46,7 @@ struct EmbeddingModelEdit: ReducerProtocol { Scope(state: \.apiKeySelection, action: /Action.apiKeySelection) { APIKeySelection() } - + Scope(state: \.baseURLSelection, action: /Action.baseURLSelection) { BaseURLSelection() } @@ -123,7 +123,7 @@ struct EmbeddingModelEdit: ReducerProtocol { case .apiKeySelection: return .none - + case .baseURLSelection: return .none @@ -151,6 +151,7 @@ extension EmbeddingModelEdit.State { id: model.id, name: model.name, format: model.format, + maxTokens: model.info.maxTokens, modelName: model.info.modelName, apiKeySelection: .init( apiKeyName: model.info.apiKeyName, From 2346fade000ef04a1de9fcfdef65c1ef5c7da6b1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 11:58:25 +0800 Subject: [PATCH 45/81] Update chat settings to use new models --- .../FeatureSettings/ChatSettingsView.swift | 94 ++++++------------- 1 file changed, 27 insertions(+), 67 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index b288f736..a65ca08e 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -5,7 +5,6 @@ struct ChatSettingsView: View { class Settings: ObservableObject { static let availableLocalizedLocales = Locale.availableLocalizedLocales @AppStorage(\.chatGPTLanguage) var chatGPTLanguage - @AppStorage(\.chatGPTMaxToken) var chatGPTMaxToken @AppStorage(\.chatGPTTemperature) var chatGPTTemperature @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount @AppStorage(\.chatFontSize) var chatFontSize @@ -14,13 +13,12 @@ struct ChatSettingsView: View { var maxFocusedCodeLineCount @AppStorage(\.useCodeScopeByDefaultInChatContext) var useCodeScopeByDefaultInChatContext - - @AppStorage(\.chatFeatureProvider) var chatFeatureProvider - @AppStorage(\.chatGPTModel) var chatGPTModel + @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations - - @AppStorage(\.embeddingFeatureProvider) var embeddingFeatureProvider + @AppStorage(\.defaultChatFeatureEmbeddingModelId) var defaultChatFeatureEmbeddingModelId + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.embeddingModels) var embeddingModels init() {} } @@ -47,18 +45,34 @@ struct ChatSettingsView: View { Form { Picker( "Chat Feature Provider", - selection: $settings.chatFeatureProvider + selection: $settings.defaultChatFeatureChatModelId ) { - Text("OpenAI").tag(ChatFeatureProvider.openAI) - Text("Azure OpenAI").tag(ChatFeatureProvider.azureOpenAI) + if !settings.chatModels + .contains(where: { $0.id == settings.defaultChatFeatureChatModelId }) + { + Text(settings.chatModels.first?.name ?? "No Model Found") + .tag(settings.defaultChatFeatureChatModelId) + } + + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } } - + Picker( "Embedding Feature Provider", - selection: $settings.embeddingFeatureProvider + selection: $settings.defaultChatFeatureEmbeddingModelId ) { - Text("OpenAI").tag(EmbeddingFeatureProvider.openAI) - Text("Azure OpenAI").tag(EmbeddingFeatureProvider.azureOpenAI) + if !settings.embeddingModels + .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId }) + { + Text(settings.embeddingModels.first?.name ?? "No Model Found") + .tag(settings.defaultChatFeatureEmbeddingModelId) + } + + ForEach(settings.embeddingModels, id: \.id) { embeddingModel in + Text(embeddingModel.name).tag(embeddingModel.id) + } } if #available(macOS 13.0, *) { @@ -72,39 +86,6 @@ struct ChatSettingsView: View { } } - let binding = Binding( - get: { String(settings.chatGPTMaxToken) }, - set: { - if let selectionMaxToken = Int($0) { - settings.chatGPTMaxToken = selectionMaxToken - } else { - settings.chatGPTMaxToken = 0 - } - } - ) - HStack { - Stepper( - value: $settings.chatGPTMaxToken, - in: 0...Int.max, - step: 1 - ) { - Text("Max Token (Including Reply)") - .multilineTextAlignment(.trailing) - } - TextField(text: binding) { - EmptyView() - } - .labelsHidden() - .textFieldStyle(.roundedBorder) - .foregroundColor(maxTokenOverLimit ? .red : .primary) - - if let model = ChatGPTModel(rawValue: settings.chatGPTModel), - settings.chatFeatureProvider == .openAI - { - Text("Max: \(model.maxToken)") - } - } - HStack { Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) { Text("Temperature") @@ -141,14 +122,6 @@ struct ChatSettingsView: View { .lineLimit(6) } .padding(.vertical, 4) - }.onAppear { - checkMaxToken() - }.onChange(of: settings.chatFeatureProvider) { _ in - checkMaxToken() - }.onChange(of: settings.chatGPTModel) { _ in - checkMaxToken() - }.onChange(of: settings.chatGPTMaxToken) { _ in - checkMaxToken() } } @@ -251,19 +224,6 @@ struct ChatSettingsView: View { ) } } - - func checkMaxToken() { - switch settings.chatFeatureProvider { - case .openAI: - if let model = ChatGPTModel(rawValue: settings.chatGPTModel) { - maxTokenOverLimit = model.maxToken < settings.chatGPTMaxToken - } else { - maxTokenOverLimit = false - } - case .azureOpenAI: - maxTokenOverLimit = false - } - } } // MARK: - Preview From 8dc12f0202cfeb77e0dca4069723930c44bdef52 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 12:14:52 +0800 Subject: [PATCH 46/81] Add support to store api keys in user defaults --- Core/Sources/HostApp/DebugView.swift | 28 +++++++++++-------- Tool/Sources/Keychain/Keychain.swift | 40 ++++++++++++++++++++++++++-- Tool/Sources/Preferences/Keys.swift | 12 ++++++--- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 96a3844a..13d9be12 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -14,6 +14,7 @@ final class DebugSettings: ObservableObject { @AppStorage(\.disableFunctionCalling) var disableFunctionCalling @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) var disableGitHubCopilotSettingsAutoRefreshOnAppear + @AppStorage(\.useUserDefaultsBaseAPIKeychain) var useUserDefaultsBaseAPIKeychain init() {} } @@ -41,17 +42,22 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.triggerActionWithAccessibilityAPI) { Text("Trigger command with AccessibilityAPI") } - Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) { - Text("Always accept suggestion with Accessibility API") - } - Toggle(isOn: $settings.enableXcodeInspectorDebugMenu) { - Text("Enable Xcode inspector debug menu") - } - Toggle(isOn: $settings.disableFunctionCalling) { - Text("Disable function calling for chat feature") - } - Toggle(isOn: $settings.disableGitHubCopilotSettingsAutoRefreshOnAppear) { - Text("Disable GitHub Copilot settings auto refresh status on appear") + Group { + Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) { + Text("Always accept suggestion with Accessibility API") + } + Toggle(isOn: $settings.enableXcodeInspectorDebugMenu) { + Text("Enable Xcode inspector debug menu") + } + Toggle(isOn: $settings.disableFunctionCalling) { + Text("Disable function calling for chat feature") + } + Toggle(isOn: $settings.disableGitHubCopilotSettingsAutoRefreshOnAppear) { + Text("Disable GitHub Copilot settings auto refresh status on appear") + } + Toggle(isOn: $settings.useUserDefaultsBaseAPIKeychain) { + Text("Store API keys in UserDefaults") + } } } .padding() diff --git a/Tool/Sources/Keychain/Keychain.swift b/Tool/Sources/Keychain/Keychain.swift index f3601ab5..87eca493 100644 --- a/Tool/Sources/Keychain/Keychain.swift +++ b/Tool/Sources/Keychain/Keychain.swift @@ -1,5 +1,6 @@ import Configs import Foundation +import Preferences import Security public protocol KeychainType { @@ -31,13 +32,48 @@ public final class FakeKeyChain: KeychainType { } } +public final class UserDefaultsBaseAPIKeychain: KeychainType { + let defaults = UserDefaults.shared + let scope: String + var key: String { + "UserDefaultsBaseAPIKeychain-\(scope)" + } + + init(scope: String) { + self.scope = scope + } + + public func getAll() throws -> [String : String] { + defaults.dictionary(forKey: key) as? [String: String] ?? [:] + } + + public func update(_ value: String, key: String) throws { + var dict = try getAll() + dict[key] = value + defaults.set(dict, forKey: self.key) + } + + public func get(_ key: String) throws -> String? { + try getAll()[key] + } + + public func remove(_ key: String) throws { + var dict = try getAll() + dict[key] = nil + defaults.set(dict, forKey: self.key) + } +} + public struct Keychain: KeychainType { let service = keychainService let accessGroup = keychainAccessGroup let scope: String - public static var apiKey: Keychain { - Keychain(scope: "apiKey") + public static var apiKey: KeychainType { + if UserDefaults.shared.value(for: \.useUserDefaultsBaseAPIKeychain) { + return UserDefaultsBaseAPIKeychain(scope: "apiKey") + } + return Keychain(scope: "apiKey") } public enum Error: Swift.Error { diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 450c0db0..fc739f64 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -213,7 +213,7 @@ public extension UserDefaultPreferenceKeys { supportsFunctionCalling: true, modelName: ChatGPTModel.gpt35Turbo.rawValue ) - ) + ), ], key: "ChatModels") } @@ -245,7 +245,7 @@ public extension UserDefaultPreferenceKeys { maxTokens: OpenAIEmbeddingModel.textEmbeddingAda002.maxToken, modelName: OpenAIEmbeddingModel.textEmbeddingAda002.rawValue ) - ) + ), ], key: "EmbeddingModels") } } @@ -320,7 +320,7 @@ public extension UserDefaultPreferenceKeys { var chatFeatureProvider: DeprecatedPreferenceKey { .init(defaultValue: .openAI, key: "ChatFeatureProvider") } - + var defaultChatFeatureChatModelId: PreferenceKey { .init(defaultValue: "", key: "DefaultChatFeatureChatModelId") } @@ -328,7 +328,7 @@ public extension UserDefaultPreferenceKeys { var embeddingFeatureProvider: DeprecatedPreferenceKey { .init(defaultValue: .openAI, key: "EmbeddingFeatureProvider") } - + var defaultChatFeatureEmbeddingModelId: PreferenceKey { .init(defaultValue: "", key: "DefaultChatFeatureEmbeddingModelId") } @@ -481,6 +481,10 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: false, key: "FeatureFlag-DisableFunctionCalling") } + var useUserDefaultsBaseAPIKeychain: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-UseUserDefaultsBaseAPIKeychain") + } + var disableGitHubCopilotSettingsAutoRefreshOnAppear: FeatureFlag { .init( defaultValue: false, From bc43b9124c00a1bc51b9de90a002495b8c2010ba Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 13:55:01 +0800 Subject: [PATCH 47/81] Disable function calling for models that don't support it --- .../OpenAIService/ChatGPTService.swift | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index c9eaa96c..274c4725 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -196,7 +196,7 @@ extension ChatGPTService { ) } let remainingTokens = await memory.remainingTokens - + let model = configuration.model let requestBody = CompletionRequestBody( @@ -209,14 +209,19 @@ extension ChatGPTService { maxToken: model.info.maxTokens, remainingTokens: remainingTokens ), - function_call: functionProvider.functionCallStrategy, - functions: functionProvider.functions.map { - ChatGPTFunctionSchema( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - } + function_call: model.info.supportsFunctionCalling + ? functionProvider.functionCallStrategy + : nil, + functions: + model.info.supportsFunctionCalling + ? functionProvider.functions.map { + ChatGPTFunctionSchema( + name: $0.name, + description: $0.description, + parameters: $0.argumentSchema + ) + } + : [] ) let api = buildCompletionStreamAPI( @@ -298,7 +303,7 @@ extension ChatGPTService { ) } let remainingTokens = await memory.remainingTokens - + let model = configuration.model let requestBody = CompletionRequestBody( @@ -311,14 +316,19 @@ extension ChatGPTService { maxToken: model.info.maxTokens, remainingTokens: remainingTokens ), - function_call: functionProvider.functionCallStrategy, - functions: functionProvider.functions.map { - ChatGPTFunctionSchema( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - } + function_call: model.info.supportsFunctionCalling + ? functionProvider.functionCallStrategy + : nil, + functions: + model.info.supportsFunctionCalling + ? functionProvider.functions.map { + ChatGPTFunctionSchema( + name: $0.name, + description: $0.description, + parameters: $0.argumentSchema + ) + } + : [] ) let api = buildCompletionAPI( From de604b1c572b536dfd3eeeb56e7672ba1635d583 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 13:59:17 +0800 Subject: [PATCH 48/81] Remove AzureView and OpenAIView --- .../HostApp/AccountSettings/AzureView.swift | 83 ------------ .../HostApp/AccountSettings/OpenAIView.swift | 118 ------------------ 2 files changed, 201 deletions(-) delete mode 100644 Core/Sources/HostApp/AccountSettings/AzureView.swift delete mode 100644 Core/Sources/HostApp/AccountSettings/OpenAIView.swift diff --git a/Core/Sources/HostApp/AccountSettings/AzureView.swift b/Core/Sources/HostApp/AccountSettings/AzureView.swift deleted file mode 100644 index 2d73a96f..00000000 --- a/Core/Sources/HostApp/AccountSettings/AzureView.swift +++ /dev/null @@ -1,83 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionModel -import SwiftUI - -final class AzureViewSettings: ObservableObject { - @AppStorage(\.azureOpenAIAPIKey) var azureOpenAIAPIKey: String - @AppStorage(\.azureOpenAIBaseURL) var azureOpenAIBaseURL: String - @AppStorage(\.azureChatGPTDeployment) var azureChatGPTDeployment: String - @AppStorage(\.azureEmbeddingDeployment) var azureEmbeddingDeployment: String - init() {} -} - -struct AzureView: View { - @Environment(\.toast) var toast - @State var isTesting = false - @StateObject var settings = AzureViewSettings() - - var body: some View { - Form { - SecureField(text: $settings.azureOpenAIAPIKey, prompt: Text("")) { - Text("OpenAI Service API Key") - } - .textFieldStyle(.roundedBorder) - - TextField( - text: $settings.azureOpenAIBaseURL, - prompt: Text("https://XXXXXX.openai.azure.com") - ) { - Text("OpenAI Service Base URL") - }.textFieldStyle(.roundedBorder) - - HStack { - TextField( - text: $settings.azureChatGPTDeployment, - prompt: Text("") - ) { - Text("Chat Model Deployment Name") - }.textFieldStyle(.roundedBorder) - - Button("Test") { - Task { @MainActor in - isTesting = true - defer { isTesting = false } - do { - let reply = - try await ChatGPTService( - configuration: UserPreferenceChatGPTConfiguration() - ) - .sendAndWait(content: "Hello", summary: nil) - toast("ChatGPT replied: \(reply ?? "N/A")", .info) - } catch { - toast(error.localizedDescription, .error) - } - } - } - .disabled(isTesting) - } - - HStack { - TextField( - text: $settings.azureEmbeddingDeployment, - prompt: Text("") - ) { - Text("Embedding Model Deployment Name") - }.textFieldStyle(.roundedBorder) - } - } - } -} - -struct AzureView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - AzureView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - diff --git a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift b/Core/Sources/HostApp/AccountSettings/OpenAIView.swift deleted file mode 100644 index ef9a379d..00000000 --- a/Core/Sources/HostApp/AccountSettings/OpenAIView.swift +++ /dev/null @@ -1,118 +0,0 @@ -import AppKit -import Client -import OpenAIService -import Preferences -import SuggestionModel -import SwiftUI - -struct OpenAIView: View { - final class Settings: ObservableObject { - @AppStorage(\.openAIAPIKey) var openAIAPIKey: String - @AppStorage(\.chatGPTModel) var chatGPTModel: String - @AppStorage(\.embeddingModel) var embeddingModel: String - @AppStorage(\.openAIBaseURL) var openAIBaseURL: String - init() {} - } - - let apiKeyURL = URL(string: "https://platform.openai.com/account/api-keys")! - let modelURL = URL( - string: "https://platform.openai.com/docs/models/model-endpoint-compatibility" - )! - @Environment(\.openURL) var openURL - @Environment(\.toast) var toast - @State var isTesting = false - @StateObject var settings = Settings() - - var body: some View { - Form { - HStack { - SecureField(text: $settings.openAIAPIKey, prompt: Text("sk-*")) { - Text("OpenAI API Key") - } - .textFieldStyle(.roundedBorder) - Button(action: { - openURL(apiKeyURL) - }) { - Image(systemName: "questionmark.circle.fill") - }.buttonStyle(.plain) - } - - HStack { - TextField( - text: $settings.openAIBaseURL, - prompt: Text("https://api.openai.com") - ) { - Text("OpenAI Base URL") - }.textFieldStyle(.roundedBorder) - - Button("Test") { - Task { @MainActor in - isTesting = true - defer { isTesting = false } - do { - let reply = - try await ChatGPTService( - configuration: UserPreferenceChatGPTConfiguration() - ) - .sendAndWait(content: "Hello", summary: nil) - toast("ChatGPT replied: \(reply ?? "N/A")", .info) - } catch { - toast(error.localizedDescription, .error) - } - } - }.disabled(isTesting) - } - - HStack { - Picker(selection: $settings.chatGPTModel) { - if !settings.chatGPTModel.isEmpty, - ChatGPTModel(rawValue: settings.chatGPTModel) == nil - { - Text(settings.chatGPTModel).tag(settings.chatGPTModel) - } - ForEach(ChatGPTModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } label: { - Text("ChatGPT Model") - }.pickerStyle(.menu) - Button(action: { - openURL(modelURL) - }) { - Image(systemName: "questionmark.circle.fill") - }.buttonStyle(.plain) - } - - HStack { - Picker(selection: $settings.embeddingModel) { - if !settings.embeddingModel.isEmpty, - OpenAIEmbeddingModel(rawValue: settings.embeddingModel) == nil - { - Text(settings.embeddingModel).tag(settings.embeddingModel) - } - ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in - Text(model.rawValue).tag(model.rawValue) - } - } label: { - Text("Embedding Model") - }.pickerStyle(.menu) - Button(action: { - openURL(modelURL) - }) { - Image(systemName: "questionmark.circle.fill") - }.buttonStyle(.plain) - } - } - } -} - -struct OpenAIView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - OpenAIView() - } - .frame(height: 800) - .padding(.all, 8) - } -} - From 192d3be50f287df87407eca5c295a71c1af85208 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 14:00:06 +0800 Subject: [PATCH 49/81] Remove unused dependency --- Core/Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Core/Package.swift b/Core/Package.swift index 7abdf0b6..793eb4d1 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -50,7 +50,6 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), - .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), ].pro, targets: [ // MARK: - Main From 3675e8ac12bf9843b4b75201449b9f6d24f54889 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 14:14:52 +0800 Subject: [PATCH 50/81] Pass configuration to DynamicContextController --- .../ChatContextCollector.swift | 3 ++- .../ActiveDocumentChatContextCollector.swift | 3 ++- .../LegacyActiveDocumentChatContextCollector.swift | 3 ++- .../SystemInfoChatContextCollector.swift | 3 ++- .../WebChatContextCollector/SearchFunction.swift | 6 +++--- .../WebChatContextCollector.swift | 5 +++-- .../ContextAwareAutoManagedChatGPTMemory.swift | 1 + .../ChatService/DynamicContextController.swift | 14 ++++++++++++-- 8 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Core/Sources/ChatContextCollector/ChatContextCollector.swift b/Core/Sources/ChatContextCollector/ChatContextCollector.swift index dd4ad75a..e890a882 100644 --- a/Core/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Core/Sources/ChatContextCollector/ChatContextCollector.swift @@ -14,7 +14,8 @@ public protocol ChatContextCollector { func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? } diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 0f9cee2c..a3a32ff4 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -15,7 +15,8 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { guard let info = getEditorInformation() else { return nil } let context = getActiveDocumentContext(info) diff --git a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index 43460b22..3e3af26b 100644 --- a/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -11,7 +11,8 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { guard let content = getEditorInformation() else { return nil } let relativePath = content.relativePath diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift index fd137a24..19aab821 100644 --- a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift @@ -14,7 +14,8 @@ public final class SystemInfoChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { return .init( systemPrompt: """ diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift index 00208529..348753b3 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/SearchFunction.swift @@ -28,6 +28,8 @@ struct SearchFunction: ChatGPTFunction { }.joined(separator: "\n") } } + + let maxTokens: Int var reportProgress: (String) async -> Void = { _ in } @@ -73,11 +75,9 @@ struct SearchFunction: ChatGPTFunction { searchURL: UserDefaults.shared.value(for: \.bingSearchEndpoint) ) - #warning("request chat service to pass in the token length") - let result = try await bingSearch.search( query: arguments.query, - numberOfResult: UserDefaults.shared.value(for: \.chatGPTMaxToken) > 5000 ? 5 : 3, + numberOfResult: maxTokens > 5000 ? 5 : 3, freshness: arguments.freshness ) diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift index 4035d44b..bf72667f 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -10,12 +10,13 @@ public final class WebChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], scopes: Set, - content: String + content: String, + configuration: ChatGPTConfiguration ) -> ChatContext? { guard scopes.contains("web") || scopes.contains("w") else { return nil } let links = Self.detectLinks(from: history) + Self.detectLinks(from: content) let functions: [(any ChatGPTFunction)?] = [ - SearchFunction(), + SearchFunction(maxTokens: configuration.maxTokens), // allow this function only when there is a link in the memory. links.isEmpty ? nil : QueryWebsiteFunction(), ] diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift index a5a39040..0a22051c 100644 --- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift @@ -35,6 +35,7 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { contextController = DynamicContextController( memory: memory, functionProvider: functionProvider, + configuration: configuration, contextCollectors: allContextCollectors ) self.functionProvider = functionProvider diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index cc53bd88..5c1cc81a 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -9,16 +9,19 @@ final class DynamicContextController { let contextCollectors: [ChatContextCollector] let memory: AutoManagedChatGPTMemory let functionProvider: ChatFunctionProvider + let configuration: ChatGPTConfiguration var defaultScopes = [] as Set convenience init( memory: AutoManagedChatGPTMemory, functionProvider: ChatFunctionProvider, + configuration: ChatGPTConfiguration, contextCollectors: ChatContextCollector... ) { self.init( memory: memory, functionProvider: functionProvider, + configuration: configuration, contextCollectors: contextCollectors ) } @@ -26,10 +29,12 @@ final class DynamicContextController { init( memory: AutoManagedChatGPTMemory, functionProvider: ChatFunctionProvider, + configuration: ChatGPTConfiguration, contextCollectors: [ChatContextCollector] ) { self.memory = memory self.functionProvider = functionProvider + self.configuration = configuration self.contextCollectors = contextCollectors } @@ -37,12 +42,17 @@ final class DynamicContextController { var content = content var scopes = Self.parseScopes(&content) scopes.formUnion(defaultScopes) - + functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) let oldMessages = await memory.history let contexts = contextCollectors.compactMap { - $0.generateContext(history: oldMessages, scopes: scopes, content: content) + $0.generateContext( + history: oldMessages, + scopes: scopes, + content: content, + configuration: configuration + ) } let contextualSystemPrompt = """ \(language.isEmpty ? "" : "You must always reply in \(language)") From eb42acd75c1abea05fdf5d577860c1cfed8d4569 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 14:49:21 +0800 Subject: [PATCH 51/81] Add feature flag --- .../AIModelManagementVIew.swift | 18 ++++++++++++++++-- Pro | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift index b767eac7..37bcac29 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/AIModelManagementVIew.swift @@ -1,5 +1,6 @@ import AIModel import ComposableArchitecture +import PlusFeatureFlag import SwiftUI protocol AIModelManagementAction { @@ -43,8 +44,21 @@ struct AIModelManagementView= 2 + + Button(disabled ? "Add More Model (Plus)" : "Add Model") { + store.send(.createModel) + }.disabled(disabled) + } } }.padding(4) diff --git a/Pro b/Pro index b5d94157..7dce70ef 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit b5d94157e6340af49d473bc6a60839211692a3cc +Subproject commit 7dce70ef7e49f44c1cbf98bf0d54e667944be0f5 From 3267270fcf6b8623d1f458368b2bc6d9d0ee95da Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 14:49:29 +0800 Subject: [PATCH 52/81] Update target dependency --- Tool/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Package.swift b/Tool/Package.swift index ad5d5be0..34740cc3 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -74,7 +74,7 @@ let package = Package( .target( name: "Keychain", - dependencies: ["Configs"] + dependencies: ["Configs", "Preferences"] ), .testTarget( From 95b5b847e6bba34c3f442267deb49b5f4d31c46b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 14:49:42 +0800 Subject: [PATCH 53/81] Fix default max tokens for embedding models --- .../EmbeddingModelManagement/EmbeddingModelEdit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index 6e0918e1..6b0d772b 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -11,7 +11,7 @@ struct EmbeddingModelEdit: ReducerProtocol { var id: String @BindingState var name: String @BindingState var format: EmbeddingModel.Format - @BindingState var maxTokens: Int = 4000 + @BindingState var maxTokens: Int = 8191 @BindingState var modelName: String = "" var apiKeyName: String { apiKeySelection.apiKeyName } var baseURL: String { baseURLSelection.baseURL } From eb8a5ea419a4ed8ab49a7b5397dd24f2e32b0539 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 16:02:06 +0800 Subject: [PATCH 54/81] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 7dce70ef..6816c6ea 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 7dce70ef7e49f44c1cbf98bf0d54e667944be0f5 +Subproject commit 6816c6ead24a8ce10cb32713316792c8788e8243 From 06a98fd7486488c041bd76f0525eaff614f4b3dc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 19:30:08 +0800 Subject: [PATCH 55/81] Fix accepting suggestions from the middle of a line --- .../SuggestionInjector.swift | 34 +++- .../AcceptSuggestionTests.swift | 159 ++++++++++++------ 2 files changed, 135 insertions(+), 58 deletions(-) diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index 8558d16d..44d2df91 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -174,12 +174,16 @@ public struct SuggestionInjector { ) } - // appending suffix text not in range if needed. + #warning( + "TODO: I feel like the implementation is doing a lot of receptive work to recover suffix." + ) + + /// The number of character that is in the last modified line but not included. let leftoverCount: Int = { let maxCount = lastRemovedLine?.count ?? 0 guard let first = toBeInserted.first? .dropLast((toBeInserted.first?.hasSuffix("\n") ?? false) ? 1 : 0), - !first.isEmpty else { return maxCount } + !first.isEmpty else { return maxCount } guard let last = toBeInserted.last? .dropLast((toBeInserted.last?.hasSuffix("\n") ?? false) ? 1 : 0), !last.isEmpty else { return maxCount } @@ -193,7 +197,7 @@ public struct SuggestionInjector { // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) if end.character < droppedLast.count { // locate the split index, the prefix of which matches the suggestion prefix. - var splitIndex: String.Index? = nil + var splitIndex: String.Index? for offset in end.character.. 0, @@ -234,12 +241,23 @@ public struct SuggestionInjector { if toBeInserted[toBeInserted.endIndex - 1].hasSuffix("\n") { toBeInserted[toBeInserted.endIndex - 1].removeLast(1) } - let leftover = lastRemovedLine[leftoverRange].suffix(leftoverCount) + let leftover = { + if lastRemovedLine.hasSuffix("\n") { + return lastRemovedLine[leftoverRange].dropLast(1).suffix(leftoverCount) + } + return lastRemovedLine[leftoverRange].suffix(leftoverCount) + }() toBeInserted[toBeInserted.endIndex - 1].append(contentsOf: leftover) + + appenderCount = leftover.count + + if !toBeInserted[toBeInserted.endIndex - 1].hasSuffix("\n") { + toBeInserted[toBeInserted.endIndex - 1].append("\n") + } } - let cursorCol = toBeInserted[toBeInserted.endIndex - 1].count - 1 + let cursorCol = toBeInserted[toBeInserted.endIndex - 1].count - 1 - appenderCount let insertingIndex = min(start.line, content.endIndex) content.insert(contentsOf: toBeInserted, at: insertingIndex) extraInfo.modifications.append(.inserted(insertingIndex, toBeInserted)) diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index c99ba417..f32077da 100644 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -25,7 +25,7 @@ final class AcceptSuggestionTests: XCTestCase { displayText: "" ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 1) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -36,14 +36,19 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) - XCTAssertEqual(lines.joined(separator: ""), """ - struct Cat { - var name: String - var age: String - } - """) + XCTAssertEqual( + lines.joined(separator: ""), + """ + struct Cat { + var name: String + var age: String + } + + """, + "There is always a new line at the end of each line! When you join them, it will look like this" + ) } func test_accept_suggestion_start_from_previous_line() async throws { @@ -68,7 +73,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -79,13 +84,14 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var age: String } + """) } @@ -111,7 +117,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 1, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -122,16 +128,17 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var age: String } + """) } - + func test_accept_suggestion_overlap_continue_typing() async throws { let content = """ struct Cat { @@ -154,7 +161,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 1, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -165,16 +172,17 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var age: String } + """) } - + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed() async throws { let content = """ print("") @@ -194,7 +202,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 7) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -205,14 +213,14 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 21)) XCTAssertEqual(lines.joined(separator: ""), """ print("Hello World!") - + """) } - + func test_accept_suggestion_overlap_continue_typing_suggestion_in_the_middle() async throws { let content = """ print("He") @@ -232,7 +240,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 7) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -243,14 +251,16 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 0, character: 20)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ print("Hello World!") + """) } - - func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines() async throws { + + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines( + ) async throws { let content = """ struct Cat {} """ @@ -272,7 +282,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 12) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -283,19 +293,19 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 3, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ struct Cat { var name: String var kind: String } - + """) } func test_propose_suggestion_partial_overlap() async throws { - let content = "func quickSort() {}}\n" + let content = "func quickSort() {}}" let text = """ func quickSort() { var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] @@ -317,7 +327,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 18) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -328,7 +338,7 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 6, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ func quickSort() { @@ -343,7 +353,7 @@ final class AcceptSuggestionTests: XCTestCase { } func test_no_overlap_append_to_the_end() async throws { - let content = "func quickSort() {\n" + let content = "func quickSort() {" let text = """ var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] var left = 0 @@ -364,7 +374,7 @@ final class AcceptSuggestionTests: XCTestCase { ) var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 18) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -375,7 +385,7 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 6, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ func quickSort() { @@ -388,7 +398,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_replacing_multiple_lines() async throws { let content = """ struct Cat { @@ -412,9 +422,9 @@ final class AcceptSuggestionTests: XCTestCase { ), displayText: "" ) - + var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 0, character: 7) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -422,25 +432,32 @@ final class AcceptSuggestionTests: XCTestCase { completion: suggestion, extraInfo: &extraInfo ) - + XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 4, character: 0)) - XCTAssertEqual(lines.joined(separator: ""), text) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 4, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Dog { + func speak() { + print("woof") + } + } + + """) } - + func test_replacing_multiple_lines_in_the_middle() async throws { let content = """ protocol Animal { func speak() } - + struct Cat: Animal { func speak() { print("meow") } } - + func foo() {} """ let text = """ @@ -459,9 +476,9 @@ final class AcceptSuggestionTests: XCTestCase { ), displayText: "" ) - + var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakLines() + var lines = content.breakIntoEditorStyleLines() var cursor = CursorPosition(line: 5, character: 34) SuggestionInjector().acceptSuggestion( intoContentWithoutSuggestion: &lines, @@ -469,24 +486,66 @@ final class AcceptSuggestionTests: XCTestCase { completion: suggestion, extraInfo: &extraInfo ) - + XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertNil(extraInfo.suggestionRange) - XCTAssertEqual(lines, content.breakLines().applying(extraInfo.modifications)) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 7, character: 5)) XCTAssertEqual(lines.joined(separator: ""), """ protocol Animal { func speak() } - + struct Dog { func speak() { print("woof") } } - + func foo() {} + + """) + } + + func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character( + ) async throws { + let content = """ + apiKeyName: ,, + """ + + let suggestion = CodeSuggestion( + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + uuid: "", + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + displayText: "" + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName,, + """) } } + +extension String { + func breakIntoEditorStyleLines() -> [String] { + split(separator: "\n", omittingEmptySubsequences: false).map { $0 + "\n" } + } +} + From da44c76b87ebf6f9e4f19f4e677207289c847a2b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 22:07:56 +0800 Subject: [PATCH 56/81] Reimplement suffix recovering --- .../SuggestionInjector.swift | 152 +++++++++--------- 1 file changed, 73 insertions(+), 79 deletions(-) diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index 44d2df91..1195bbaa 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -174,97 +174,77 @@ public struct SuggestionInjector { ) } - #warning( - "TODO: I feel like the implementation is doing a lot of receptive work to recover suffix." + let recoveredSuffixLength = recoverSuffixIfNeeded( + endOfReplacedContent: end, + toBeInserted: &toBeInserted, + lastRemovedLine: lastRemovedLine ) - /// The number of character that is in the last modified line but not included. - let leftoverCount: Int = { - let maxCount = lastRemovedLine?.count ?? 0 - guard let first = toBeInserted.first? - .dropLast((toBeInserted.first?.hasSuffix("\n") ?? false) ? 1 : 0), - !first.isEmpty else { return maxCount } - guard let last = toBeInserted.last? - .dropLast((toBeInserted.last?.hasSuffix("\n") ?? false) ? 1 : 0), - !last.isEmpty else { return maxCount } - let droppedLast = lastRemovedLine? - .dropLast((lastRemovedLine?.hasSuffix("\n") ?? false) ? 1 : 0) - guard let droppedLast, !droppedLast.isEmpty else { return maxCount } - // case 1: user keeps typing as the suggestion suggests. - if first.hasPrefix(droppedLast) { - return 0 - } - // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) - if end.character < droppedLast.count { - // locate the split index, the prefix of which matches the suggestion prefix. - var splitIndex: String.Index? - for offset in end.character.. Int { + // If there is no line removed, there is no need to recover anything. + guard let lastRemovedLine, !lastRemovedLine.isEmptyOrNewLine else { return 0 } - return maxCount - }() + let lastRemovedLineCleaned = lastRemovedLine.droppedLineBreak() - /// The number of characters appended to the last line. - var appenderCount = 0 + // If the replaced range covers the whole line, return immediately. + guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.count else { return 0 } - // appending suffix text not in range if needed. - if let lastRemovedLine, - leftoverCount > 0, - !lastRemovedLine.isEmptyOrNewLine, - end.character >= 0, - end.character - 1 < lastRemovedLine.count, - !toBeInserted.isEmpty - { - let leftoverRange = (lastRemovedLine.index( - lastRemovedLine.startIndex, - offsetBy: end.character, - limitedBy: lastRemovedLine.endIndex - ) ?? lastRemovedLine.endIndex).. String { + if hasSuffix("\n") { + return String(dropLast(1)) + } + return self + } + + func recoveredLineBreak() -> String { + if hasSuffix("\n") { + return self + } + return self + "\n" + } } func longestCommonPrefix(of a: String, and b: String) -> String { From 205c9b33a0f96d385c391c11ef0130e21ddcbc39 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 4 Sep 2023 22:29:10 +0800 Subject: [PATCH 57/81] Remove the first adjacent placeholder when accepting suggestions --- .../SuggestionInjector.swift | 19 +++++++++-- .../AcceptSuggestionTests.swift | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index 1195bbaa..a88f3fcb 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -233,10 +233,25 @@ public struct SuggestionInjector { // then check how many characters are not in the suffix of the suggestion. guard let splitIndex else { return 0 } - - let suffix = lastRemovedLineCleaned[splitIndex...] + + var suffix = String(lastRemovedLineCleaned[splitIndex...]) if last.hasSuffix(suffix) { return 0 } + // remove the first adjacent placeholder in suffix which looks like `<#Hello#>` + + let regex = try! NSRegularExpression(pattern: "<#.*?#>") + + if let firstPlaceholderRange = regex.firstMatch( + in: suffix, + options: [], + range: NSRange(suffix.startIndex..., in: suffix) + )?.range, + firstPlaceholderRange.location <= 1, + let r = Range(firstPlaceholderRange, in: suffix) + { + suffix.removeSubrange(r) + } + let lastInsertingLine = toBeInserted[toBeInserted.endIndex - 1] .droppedLineBreak() .appending(suffix) diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index f32077da..5fc2af8f 100644 --- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -541,6 +541,40 @@ final class AcceptSuggestionTests: XCTestCase { """) } + + func test_remove_the_first_adjacent_placeholder_in_the_last_line( + ) async throws { + let content = """ + apiKeyName: <#T##value: BinaryInteger##BinaryInteger#> <#Hello#>, + """ + + let suggestion = CodeSuggestion( + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + uuid: "", + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + displayText: "" + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName <#Hello#>, + + """) + } } extension String { From e17bdd45f23ff83755f3955595ee6e4b9e675a6c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 5 Sep 2023 16:48:35 +0800 Subject: [PATCH 58/81] Update --- Core/Sources/SuggestionInjector/SuggestionInjector.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index a88f3fcb..c9d8b96f 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -239,14 +239,14 @@ public struct SuggestionInjector { // remove the first adjacent placeholder in suffix which looks like `<#Hello#>` - let regex = try! NSRegularExpression(pattern: "<#.*?#>") + let regex = try! NSRegularExpression(pattern: "\\s+<#.*?#>") if let firstPlaceholderRange = regex.firstMatch( in: suffix, options: [], range: NSRange(suffix.startIndex..., in: suffix) )?.range, - firstPlaceholderRange.location <= 1, + firstPlaceholderRange.location == 0, let r = Range(firstPlaceholderRange, in: suffix) { suffix.removeSubrange(r) From 24862007a74484b5acb8e0352504e568611b4f01 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 5 Sep 2023 16:53:04 +0800 Subject: [PATCH 59/81] Remove existed chat tabs before restoring --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 6816c6ea..0f85ecab 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 6816c6ead24a8ce10cb32713316792c8788e8243 +Subproject commit 0f85ecab268df26106725fee391f7b7cb604c4a5 From 2815292bd3578f23860931502b86cea761eb13e9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 5 Sep 2023 18:17:12 +0800 Subject: [PATCH 60/81] Fix suggestion panel position Y --- Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 5db67131..5323ffb1 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -300,7 +300,7 @@ enum UpdateLocationStrategy { return .init( frame: .init( x: x, - y: y + selectionFrame.height - Style.widgetPadding, + y: y + selectionFrame.height + Style.widgetPadding, width: Style.inlineSuggestionMinWidth, height: Style.inlineSuggestionMaxHeight ), From 6a690f7f18c2bc9a4315e1e91232cbea029509f3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 5 Sep 2023 18:53:06 +0800 Subject: [PATCH 61/81] Adjust prompt to code panel --- .../FeatureReducers/PromptToCode.swift | 3 +- .../PromptToCodePanel.swift | 29 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index 7b8beba5..38caf0d3 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -63,7 +63,8 @@ public struct PromptToCode: ReducerProtocol { @BindingState public var prompt: String @BindingState public var isContinuous: Bool @BindingState public var isAttachedToSelectionRange: Bool - + + public var filename: String { documentURL.lastPathComponent } public var canRevert: Bool { history != .empty } public init( diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 6363bff9..e0875de8 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -31,6 +31,7 @@ extension PromptToCodePanel { let store: StoreOf struct AttachButtonState: Equatable { + var attachedToFilename: String var isAttachedToSelectionRange: Bool var selectionRange: CursorRange? } @@ -45,6 +46,7 @@ extension PromptToCodePanel { WithViewStore( store, observe: { AttachButtonState( + attachedToFilename: $0.filename, isAttachedToSelectionRange: $0.isAttachedToSelectionRange, selectionRange: $0.selectionRange ) } @@ -53,7 +55,7 @@ extension PromptToCodePanel { let color: Color = isAttached ? .indigo : .secondary.opacity(0.6) HStack(spacing: 4) { Image( - systemName: isAttached ? "bandage" : "character.cursor.ibeam" + systemName: isAttached ? "link" : "character.cursor.ibeam" ) .resizable() .aspectRatio(contentMode: .fit) @@ -68,13 +70,16 @@ extension PromptToCodePanel { ) ) - let text: String = { - if isAttached, let range = viewStore.state.selectionRange { - return range.description - } - return "text cursor" - }() - Text(text).foregroundColor(.primary) + if isAttached { + HStack(spacing: 4) { + Text(viewStore.state.attachedToFilename) + if let range = viewStore.state.selectionRange { + Text(range.description) + } + }.foregroundColor(.primary) + } else { + Text("text selection").foregroundColor(.secondary) + } } .padding(2) .padding(.trailing, 4) @@ -422,8 +427,8 @@ struct PromptToCodePanel_Preview: PreviewProvider { language: .builtIn(.swift), indentSize: 4, usesTabsForIndentation: false, - projectRootURL: URL(fileURLWithPath: ""), - documentURL: URL(fileURLWithPath: ""), + projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), + documentURL: URL(fileURLWithPath: "path/to/file.txt"), allCode: "", commandName: "Generate Code", description: "Hello world", @@ -452,8 +457,8 @@ struct PromptToCodePanel_Error_Detached_Preview: PreviewProvider { language: .builtIn(.swift), indentSize: 4, usesTabsForIndentation: false, - projectRootURL: URL(fileURLWithPath: ""), - documentURL: URL(fileURLWithPath: ""), + projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), + documentURL: URL(fileURLWithPath: "path/to/file.txt"), allCode: "", commandName: "Generate Code", description: "Hello world", From d89e94940e720a1b15e0807bf16a1964c53e4b02 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 5 Sep 2023 22:23:37 +0800 Subject: [PATCH 62/81] Fix system promt --- .../Sources/PromptToCodeService/OpenAIPromptToCodeService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 871920dd..81f6fe46 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -31,7 +31,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { { return "" } - return userPreferredLanguage.isEmpty ? "" : "in \(userPreferredLanguage)" + return userPreferredLanguage.isEmpty ? "" : " in \(userPreferredLanguage)" }() let rule: String = { From efcaa94d9b58953579059523a3bb2db86d26bb9a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 09:59:31 +0800 Subject: [PATCH 63/81] Change api of PromptToCodeServiceType --- .../OpenAIPromptToCodeService.swift | 51 ++++++++++++------- .../PreviewPromptToCodeService.swift | 8 +-- .../PromptToCodeServiceType.swift | 30 ++++++++--- Core/Sources/Service/GUI/ChatTabFactory.swift | 14 ++--- .../FeatureReducers/PromptToCode.swift | 16 +++--- 5 files changed, 76 insertions(+), 43 deletions(-) diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 81f6fe46..ee9cf3ef 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -2,10 +2,11 @@ import Foundation import OpenAIService import Preferences import SuggestionModel +import XcodeInspector public final class OpenAIPromptToCodeService: PromptToCodeServiceType { var service: (any ChatGPTServiceType)? - + public init() {} public func stopResponding() { @@ -14,13 +15,9 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { public func modifyCode( code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, + source: PromptToCodeSource, + isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { @@ -34,6 +31,22 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { return userPreferredLanguage.isEmpty ? "" : " in \(userPreferredLanguage)" }() + let editor: EditorInformation = XcodeInspector.shared.focusedEditorContent ?? .init( + editorContent: .init( + content: source.allCode, + lines: [], + selections: [source.range], + cursorPosition: .outOfScope, + lineAnnotations: [] + ), + selectedContent: code, + selectedLines: [], + documentURL: source.documentURL, + projectURL: source.projectRootURL, + relativePath: "", + language: source.language + ) + let rule: String = { func generateDescription(index: Int) -> String { let generateDescription = generateDescriptionRequirement ?? UserDefaults.shared @@ -46,7 +59,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { """ : "\(index). Reply with the result." } - switch language { + switch editor.language { case .builtIn(.markdown), .plaintext: if code.isEmpty { return """ @@ -82,20 +95,20 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { }() let systemPrompt = { - switch language { + switch editor.language { case .builtIn(.markdown), .plaintext: if code.isEmpty { return """ - You are good at writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are good at writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) """ } else { return """ - You are good at writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are good at writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) @@ -104,16 +117,16 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { default: if code.isEmpty { return """ - You are a senior programer in writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are a senior programer in writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) """ } else { return """ - You are a senior programer in writing in \(language.rawValue). - The active file is: \(fileURL.lastPathComponent). + You are a senior programer in writing in \(editor.language.rawValue). + The active file is: \(editor.documentURL.lastPathComponent). \(extraSystemPrompt ?? "") \(rule) @@ -125,6 +138,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { let firstMessage: String? = { if code.isEmpty { return nil } switch language { + switch editor.language { case .builtIn(.markdown), .plaintext: return """ ``` @@ -161,9 +175,10 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { if let firstMessage { await memory.mutateHistory { history in history.append(.init(role: .user, content: firstMessage)) + history.append(.init(role: .assistant, content: secondMessage)) } } - let stream = try await chatGPTService.send(content: secondMessage) + let stream = try await chatGPTService.send(content: requirement) return .init { continuation in Task { var content = "" diff --git a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift index eef1c37e..cff916f3 100644 --- a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift @@ -6,13 +6,9 @@ public final class PreviewPromptToCodeService: PromptToCodeServiceType { public func modifyCode( code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, + source: PromptToCodeSource, + isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift index 7199f402..01519d18 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -5,13 +5,9 @@ import SuggestionModel public protocol PromptToCodeServiceType { func modifyCode( code: String, - language: CodeLanguage, - indentSize: Int, - usesTabsForIndentation: Bool, requirement: String, - projectRootURL: URL, - fileURL: URL, - allCode: String, + source: PromptToCodeSource, + isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> @@ -19,6 +15,28 @@ public protocol PromptToCodeServiceType { func stopResponding() } +public struct PromptToCodeSource { + public var language: CodeLanguage + public var documentURL: URL + public var projectRootURL: URL + public var allCode: String + public var range: CursorRange + + public init( + language: CodeLanguage, + documentURL: URL, + projectRootURL: URL, + allCode: String, + range: CursorRange + ) { + self.language = language + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.allCode = allCode + self.range = range + } +} + public struct PromptToCodeServiceDependencyKey: DependencyKey { public static let liveValue: PromptToCodeServiceType = PreviewPromptToCodeService() public static let previewValue: PromptToCodeServiceType = PreviewPromptToCodeService() diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift index 55a9f59a..b574ff5b 100644 --- a/Core/Sources/Service/GUI/ChatTabFactory.swift +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -87,13 +87,15 @@ enum ChatTabFactory { let result = try await service.modifyCode( code: prompt, - language: .plaintext, - indentSize: 4, - usesTabsForIndentation: true, requirement: instruction ?? "Modify content.", - projectRootURL: .init(fileURLWithPath: "/"), - fileURL: .init(fileURLWithPath: "/"), - allCode: prompt, + source: .init( + language: .plaintext, + documentURL: .init(fileURLWithPath: "/"), + projectRootURL: .init(fileURLWithPath: "/"), + allCode: prompt, + range: .outOfScope + ), + isDetached: true, extraSystemPrompt: extraSystemPrompt, generateDescriptionRequirement: false ) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index 38caf0d3..15f02399 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -63,7 +63,7 @@ public struct PromptToCode: ReducerProtocol { @BindingState public var prompt: String @BindingState public var isContinuous: Bool @BindingState public var isAttachedToSelectionRange: Bool - + public var filename: String { documentURL.lastPathComponent } public var canRevert: Bool { history != .empty } @@ -156,13 +156,15 @@ public struct PromptToCode: ReducerProtocol { do { let stream = try await promptToCodeService.modifyCode( code: copiedState.code, - language: copiedState.language, - indentSize: copiedState.indentSize, - usesTabsForIndentation: copiedState.usesTabsForIndentation, requirement: copiedState.prompt, - projectRootURL: copiedState.projectRootURL, - fileURL: copiedState.documentURL, - allCode: copiedState.allCode, + source: .init( + language: copiedState.language, + documentURL: copiedState.documentURL, + projectRootURL: copiedState.projectRootURL, + allCode: copiedState.allCode, + range: copiedState.selectionRange ?? .outOfScope + ), + isDetached: !copiedState.isAttachedToSelectionRange, extraSystemPrompt: copiedState.extraSystemPrompt, generateDescriptionRequirement: copiedState .generateDescriptionRequirement From ecd74a8fc0689022c0f7dc57568ff391dce8d6cc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 09:59:58 +0800 Subject: [PATCH 64/81] Pass line annotations to prompt to code service --- .../OpenAIPromptToCodeService.swift | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index ee9cf3ef..bd996708 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -135,21 +135,30 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { } }() + let annotations = isDetached + ? [] + : extractAnnotations(editorInformation: editor, source: source) + let firstMessage: String? = { if code.isEmpty { return nil } - switch language { switch editor.language { case .builtIn(.markdown), .plaintext: return """ ``` \(code) ``` + + line annotations found: + \(annotations.map { "- \($0)" }.joined(separator: "\n")) """ default: return """ ``` \(code) ``` + + line annotations found: + \(annotations.map { "- \($0)" }.joined(separator: "\n")) """ } }() @@ -246,5 +255,20 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { return (code, description) } + func extractAnnotations( + editorInformation: EditorInformation, + source: PromptToCodeSource + ) -> [String] { + guard let annotations = editorInformation.editorContent?.lineAnnotations else { return [] } + return annotations + .lazy + .filter { annotation in + annotation.line >= source.range.start.line + 1 + && annotation.line <= source.range.end.line + 1 + }.map { annotation in + let relativeLine = annotation.line - source.range.start.line + return "line \(relativeLine): \(annotation.type) \(annotation.message)" + } + } } From 8f9bc5ca6233a64e989f1b7ba81c58346908e4b6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 10:00:34 +0800 Subject: [PATCH 65/81] Support indentation recovering in prompt to code service --- .../OpenAIPromptToCodeService.swift | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index bd996708..9c00c83f 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -163,13 +163,16 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { } }() + let indentation = getCommonLeadingSpaceCount(code) + let secondMessage = """ - Requirements:### - \(requirement) - ### + I will update the code you just provided. + It looks like every line has an indentation of \(indentation) spaces, I will keep that. + + What is your requirement? """ - let configuration = UserPreferenceChatGPTConfiguration() + let configuration = UserPreferenceChatGPTConfiguration() .overriding(.init(temperature: 0)) let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, @@ -255,6 +258,19 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { return (code, description) } + + func getCommonLeadingSpaceCount(_ code: String) -> Int { + let lines = code.split(separator: "\n") + guard !lines.isEmpty else { return 0 } + var commonCount = Int.max + for line in lines { + let count = line.prefix(while: { $0 == " " }).count + commonCount = min(commonCount, count) + if commonCount == 0 { break } + } + return commonCount + } + func extractAnnotations( editorInformation: EditorInformation, source: PromptToCodeSource From 7225b052b5e6050a5f2d18ca0db8825f6a98953a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 10:04:32 +0800 Subject: [PATCH 66/81] Bump version to 0.23.0 --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index c54c75c3..2e937d63 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.22.3 -APP_BUILD = 234 +APP_VERSION = 0.23.0 +APP_BUILD = 240 From 7b61ab8c17ecfcf6d756d3949cc3c31520f4272f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 10:33:33 +0800 Subject: [PATCH 67/81] Adjust transitions --- .../FeatureReducers/WidgetFeature.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 2c09dbb4..d89dfeb5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -79,6 +79,8 @@ public struct WidgetFeature: ReducerProtocol { ) } } + + var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) public init() {} } @@ -109,6 +111,7 @@ public struct WidgetFeature: ReducerProtocol { case updateWindowLocation(animated: Bool) case updateWindowOpacity case updateFocusingDocumentURL + case updateWindowOpacityFinished case panel(PanelFeature.Action) case chatPanel(ChatPanelFeature.Action) @@ -513,9 +516,12 @@ public struct WidgetFeature: ReducerProtocol { case .updateWindowOpacity: let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty - return .run { _ in - try await mainQueue.sleep(for: .seconds(0.2)) - Task { @MainActor in + let shouldDebounce = Date().timeIntervalSince(state.lastUpdateWindowOpacityTime) < 1 + return .run { send in + if shouldDebounce { + try await mainQueue.sleep(for: .seconds(0.2)) + } + let task = Task { @MainActor in if let app = activeApplicationMonitor.activeApplication, app.isXcode { let application = AXUIElementCreateApplication(app.processIdentifier) /// We need this to hide the windows when Xcode is minimized. @@ -564,8 +570,14 @@ public struct WidgetFeature: ReducerProtocol { } } } + _ = await task.value + await send(.updateWindowOpacityFinished) } .cancellable(id: DebounceKey.updateWindowOpacity, cancelInFlight: true) + + case .updateWindowOpacityFinished: + state.lastUpdateWindowOpacityTime = Date() + return .none case .circularWidget: return .none From c5917188279ac4e321e1088cd0754df32a15df73 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 12:31:32 +0800 Subject: [PATCH 68/81] Make every field of CompletionStreamDataTrunk optional --- Tool/Sources/OpenAIService/ChatGPTService.swift | 2 +- Tool/Sources/OpenAIService/CompletionStreamAPI.swift | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 274c4725..f8f598c8 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -238,7 +238,7 @@ extension ChatGPTService { cancelTask = cancel let proposedId = UUID().uuidString for try await trunk in trunks { - guard let delta = trunk.choices.first?.delta else { continue } + guard let delta = trunk.choices?.first?.delta else { continue } // The api will always return a function call with JSON object. // The first round will contain the function name and an empty argument. diff --git a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift index 574988ef..2524257b 100644 --- a/Tool/Sources/OpenAIService/CompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/CompletionStreamAPI.swift @@ -130,13 +130,13 @@ struct CompletionRequestBody: Encodable, Equatable { struct CompletionStreamDataTrunk: Codable { var id: String? - var object: String - var model: String - var choices: [Choice] + var object: String? + var model: String? + var choices: [Choice]? struct Choice: Codable { - var delta: Delta - var index: Int + var delta: Delta? + var index: Int? var finish_reason: String? struct Delta: Codable { From dc0b11796bd145da41037202c2c144e8bf1e5c04 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 12:42:21 +0800 Subject: [PATCH 69/81] If there is no selection, automatically detach --- .../SuggestionWidget/FeatureReducers/PromptToCode.swift | 4 ++++ .../SuggestionPanelContent/PromptToCodePanel.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index 15f02399..bfac06d0 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -105,6 +105,10 @@ public struct PromptToCode: ReducerProtocol { self.generateDescriptionRequirement = generateDescriptionRequirement self.isAttachedToSelectionRange = isAttachedToSelectionRange self.commandName = commandName + + if selectionRange?.isEmpty ?? true { + self.isAttachedToSelectionRange = false + } } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index e0875de8..b20190aa 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -78,7 +78,7 @@ extension PromptToCodePanel { } }.foregroundColor(.primary) } else { - Text("text selection").foregroundColor(.secondary) + Text("current selection").foregroundColor(.secondary) } } .padding(2) From 0fa3facdee4804759aa757ffe3f9d03b0edf4ba5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 14:39:55 +0800 Subject: [PATCH 70/81] Update README.md --- README.md | 58 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index fa22ecac..4892e1f3 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Enable the Extension](#enable-the-extension) - [Granting Permissions to the App](#granting-permissions-to-the-app) - [Setting Up Key Bindings](#setting-up-key-bindings) - - [Setting Up GitHub Copilot](#setting-up-github-copilot) - - [Setting Up Codeium](#setting-up-codeium) - - [Setting Up OpenAI API Key](#setting-up-openai-api-key) + - [Setting Up Suggestion Feature](#setting-up-suggestion-feature) + - [Setting Up GitHub Copilot](#setting-up-github-copilot) + - [Setting Up Codeium](#setting-up-codeium) + - [Setting Up Chat Feature](#setting-up-chat-feature) - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp) - [Update](#update) - [Feature](#feature) @@ -54,7 +55,8 @@ For suggestion features: For chat and prompt to code features: -- Valid OpenAI API key. +- A valid OpenAI API key. +- Access to other LLMs, such as Azure OpenAI and LocalAI. ## Permissions Required @@ -118,36 +120,45 @@ Essentially using `⌥⇧` as the "access" key combination for all bindings. Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. -### Setting Up GitHub Copilot +### Setting Up Suggestion Feature -1. In the host app, switch to the service tab and click on GitHub Copilot to access your GitHub Copilot account settings. -2. Click "Install" to install the language server. -3. Optionally setup the path to Node. The default value is just `node`, Copilot for Xcode.app will try to find the Node from the PATH available in a login shell. If your Node is installed somewhere else, you can run `which node` from terminal to get the path. -4. Click "Sign In", and you will be directed to a verification website provided by GitHub, and a user code will be pasted into your clipboard. -5. After signing in, go back to the app and click "Confirm Sign-in" to finish. +#### Setting Up GitHub Copilot + +1. In the host app, navigate to "Service - GitHub Copilot" to access your GitHub Copilot account settings. +2. Click on "Install" to install the language server. +3. Optionally, set up the path to Node. The default value is simply `node`. Copilot for Xcode.app will attempt to locate Node from the following directories: `/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`. + + If your Node installation is located elsewhere, you can run `which node` from the terminal to obtain the correct path. + + If you are using a node version manager that provides a shim executable, you will need to find the path to the actual executable. Please refer to the FAQ for more information. + +4. Click on "Sign In", and you will be redirected to a verification website provided by GitHub. A user code will be copied to your clipboard. +5. After signing in, return to the app and click on "Confirm Sign-in" to complete the process. 6. Go to "Feature - Suggestion" and update the feature provider to "GitHub Copilot". The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/GitHub Copilot/executable/`. -### Setting Up Codeium +#### Setting Up Codeium -1. In the host app, switch to the service tab and click Codeium to access the Codeium account settings. -2. Click "Install" to install the language server. -3. Click "Sign In", and you will be directed to codeium.com. After signing in, a token will be presented. You will need to paste the token back to the app to finish signing in. +1. In the host app, navigate to "Service - Codeium" to access the Codeium account settings. +2. Click on "Install" to install the language server. +3. Click on "Sign In" and you will be redirected to codeium.com. After signing in, a token will be provided. You need to copy and paste this token back into the app to complete the sign-in process. 4. Go to "Feature - Suggestion" and update the feature provider to "Codeium". > The key is stored in the keychain. When the helper app tries to access the key for the first time, it will prompt you to enter the password to access the keychain. Please select "Always Allow" to let the helper app access the key. The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`. -### Setting Up OpenAI API Key +### Setting Up Chat Feature -1. In the host app, click OpenAI to enter the OpenAI account settings. -2. Enter your api key to the text field. +1. In the host app, navigate to "Service - Chat Model". +2. Update the OpenAI model or create a new one if necessary. Use the test button to verify the model. +3. Optionally, set up the embedding model in "Service - Embedding Model", which is required for a subset of the chat feature. +4. Go to "Feature - Chat" and update the chat/embedding feature provider with the one you just updated/created. ### Managing `CopilotForXcodeExtensionService.app` -This app runs whenever you open `Copilot for Xcode.app` or `Xcode.app`. You can quit it with its menu bar item that looks like a steering wheel. +This app runs whenever you open `Copilot for Xcode.app` or `Xcode.app`. You can quit it with its menu bar item that looks like a tentacle. You can also set it to quit automatically when the above 2 apps are closed. @@ -161,9 +172,7 @@ brew upgrade --cask copilot-for-xcode Alternatively, You can use the in-app updater or download the latest version manually from the latest [release](https://github.com/intitni/CopilotForXcode/releases). -After updating, please restart Xcode to allow the extension to reload. - -If you are upgrading from a version lower than **0.7.0**, please run `Copilot for Xcode.app` at least once to let it set up the new launch agent for you and re-grant the permissions according to the new rules. +After updating, please open Copilot for Xcode.app once and restart Xcode to allow the extension to reload. If you find that some of the features are no longer working, please first try regranting permissions to the app. @@ -193,6 +202,9 @@ Whenever your code is updated, the app will automatically fetch suggestions for - Previous Suggestion: If there is more than one suggestion, switch to the previous one. - Accept Suggestion: Add the suggestion to the code. - Reject Suggestion: Remove the suggestion comments. + +Commands called by the app: + - Real-time Suggestions: Call only by Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. - Prefetch Suggestions: Call only by Copilot for Xcode. In the background, Copilot for Xcode will occasionally run this command to prefetch real-time suggestions. @@ -283,6 +295,7 @@ This feature is recommended when you need to update a specific piece of code. So #### Commands - Prompt to Code: Open a prompt to code window, where you can use natural language to write or edit selected code. +- Accept Prompt to Code: Accept the result of prompt to code. ### Custom Commands @@ -310,6 +323,7 @@ These features are included in another repo, and are not open sourced. The currently available Plus features include: +- Unlimited chat/embedding models. - Tab to accept suggestions. - Persisted chat panel. - Browser tab in chat panel. @@ -326,7 +340,7 @@ The request contains only the license key, the email address (only on activation ## Limitations -- The extension uses some dirty tricks to get the file and project/workspace paths. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though. +- The extension utilizes various tricks to monitor the state of Xcode. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though. ## License From b7017e4028358629efe0126472c201be8dbb047e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 14:42:29 +0800 Subject: [PATCH 71/81] Update DEVELOPMENT.md --- DEVELOPMENT.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bc243c73..002dcc2e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,12 +18,10 @@ The `ExtensionService` is a program that operates in the background and performs Most of the logics are implemented inside the package `Core` and `Tool`. -- The `CopilotService` is responsible for communicating with the GitHub Copilot LSP. - The `Service` is responsible for handling the requests from the `EditorExtension`, communicating with the `CopilotService`, update the code blocks and present the GUI. - The `Client` is basically just a wrapper around the XPCService - The `SuggestionInjector` is responsible for injecting the suggestions into the code. Used in comment mode to present the suggestions, and all modes to accept suggestions. -- The `Environment` contains some swappable global functions. It is used to make testing easier. -- The `SuggestionWidget` is responsible for presenting the suggestions in floating widget mode. +- The `SuggestionWidget` is responsible for the UI of the widgets. ## Building and Archiving the App @@ -31,9 +29,9 @@ Most of the logics are implemented inside the package `Core` and `Tool`. 2. Build or archive the Copilot for Xcode target. 3. If Xcode complains that the pro package doesn't exist, please remove the package from the project, and update the last function in Core/Package.swift to return false. -## Testing Extension +## Testing Source Editor Extension -Just run both the `ExtensionService` and the `EditorExtension` Target. +Just run both the `ExtensionService` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details. ## SwiftUI Previews From 1fed399989fda9edd791af56e41f70f49311a049 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 14:46:55 +0800 Subject: [PATCH 72/81] Fix prompt --- .../OpenAIPromptToCodeService.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 9c00c83f..064399fa 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -136,7 +136,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { }() let annotations = isDetached - ? [] + ? "" : extractAnnotations(editorInformation: editor, source: source) let firstMessage: String? = { @@ -148,8 +148,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { \(code) ``` - line annotations found: - \(annotations.map { "- \($0)" }.joined(separator: "\n")) + \(annotations) """ default: return """ @@ -157,8 +156,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { \(code) ``` - line annotations found: - \(annotations.map { "- \($0)" }.joined(separator: "\n")) + \(annotations) """ } }() @@ -274,9 +272,9 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { func extractAnnotations( editorInformation: EditorInformation, source: PromptToCodeSource - ) -> [String] { - guard let annotations = editorInformation.editorContent?.lineAnnotations else { return [] } - return annotations + ) -> String { + guard let annotations = editorInformation.editorContent?.lineAnnotations else { return "" } + let all = annotations .lazy .filter { annotation in annotation.line >= source.range.start.line + 1 @@ -285,6 +283,11 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { let relativeLine = annotation.line - source.range.start.line return "line \(relativeLine): \(annotation.type) \(annotation.message)" } + guard !all.isEmpty else { return "" } + return """ + line annotations found: + \(annotations.map { "- \($0)" }.joined(separator: "\n")) + """ } } From 8ba71c542204d5f696e46fb1bfe6824e7a250285 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 16:52:18 +0800 Subject: [PATCH 73/81] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 0f85ecab..39a2407c 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 0f85ecab268df26106725fee391f7b7cb604c4a5 +Subproject commit 39a2407c29febaf5a74095177dc1035f5551dd69 From 03fcec5c6cd8fb2057091a3375897d526227408e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 16:52:37 +0800 Subject: [PATCH 74/81] Add debug buttons --- Core/Sources/HostApp/DebugView.swift | 11 +++++++++++ .../ServiceUpdateMigrator.swift | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 13d9be12..142be6ca 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -58,6 +58,17 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.useUserDefaultsBaseAPIKeychain) { Text("Store API keys in UserDefaults") } + + Button("Reset Migration Version to 0") { + UserDefaults.shared.set(nil, forKey: "OldMigrationVersion") + } + + Button("Reset 0.23.0 migration") { + UserDefaults.shared.set("239", forKey: "OldMigrationVersion") + UserDefaults.shared.set(nil, forKey: "MigrateTo240Finished") + UserDefaults.shared.set(nil, forKey: "ChatModels") + UserDefaults.shared.set(nil, forKey: "EmbeddingModels") + } } } .padding() diff --git a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift index ac6e3986..6da2bcf3 100644 --- a/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift +++ b/Core/Sources/ServiceUpdateMigration/ServiceUpdateMigrator.swift @@ -22,7 +22,7 @@ public struct ServiceUpdateMigrator { } func migrate(from oldVersion: String, to currentVersion: String) async throws { - guard let old = Int(oldVersion) else { return } + guard let old = Int(oldVersion), old != 0 else { return } if old <= 135 { try migrateFromLowerThanOrEqualToVersion135() } From 350a91d14647cf73d463e58867214b6688f79682 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 16:52:49 +0800 Subject: [PATCH 75/81] Tweak base url picker --- .../SharedModelManagement/BaseURLPicker.swift | 5 ++++- .../SharedModelManagement/BaseURLSelection.swift | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift index 2d163dba..47f5144a 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift @@ -14,11 +14,14 @@ struct BaseURLPicker: View { selection: viewStore.$baseURL, content: { if !viewStore.state.availableBaseURLs - .contains(viewStore.state.baseURL) + .contains(viewStore.state.baseURL), + !viewStore.state.baseURL.isEmpty { Text("Custom Value").tag(viewStore.state.baseURL) } + Text("Empty (Default Value)").tag("") + ForEach(viewStore.state.availableBaseURLs, id: \.self) { baseURL in Text(baseURL).tag(baseURL) } diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift index dc1dac76..c4cd4b96 100644 --- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift +++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLSelection.swift @@ -31,12 +31,13 @@ struct BaseURLSelection: ReducerProtocol { case .refreshAvailableBaseURLNames: let chatModels = userDefaults.value(for: \.chatModels) let embeddingModels = userDefaults.value(for: \.embeddingModels) - let allBaseURLs = Set( + var allBaseURLs = Set( chatModels.map(\.info.baseURL) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + embeddingModels.map(\.info.baseURL) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ) + allBaseURLs.remove("") state.availableBaseURLs = Array(allBaseURLs).sorted() return .none From b2b6bf07502856de5857d2eeba9af569ae746ede Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 17:02:30 +0800 Subject: [PATCH 76/81] Make Xcode active when cancelling prompt to code --- .../SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index 5cc19724..d6a05d69 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import Foundation import PromptToCodeService import SuggestionModel +import Environment public struct PromptToCodeGroup: ReducerProtocol { public struct State: Equatable { @@ -130,7 +131,9 @@ public struct PromptToCodeGroup: ReducerProtocol { switch action { case .cancelButtonTapped: state.promptToCodes.remove(id: id) - return .none + return .run { _ in + try await Environment.makeXcodeActive() + } default: return .none } From c0746aa111b7bbf553f01b3b1e7ced0fb2697c9c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 18:00:31 +0800 Subject: [PATCH 77/81] Update appcast --- appcast.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/appcast.xml b/appcast.xml index e915b77f..c03f7938 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.23.0 + Wed, 06 Sep 2023 17:58:47 +0800 + 240 + 0.23.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.23.0 + + + + 0.22.3 Sat, 02 Sep 2023 15:51:16 +0800 From 5db1cf24e76f229166c5035bf775cb89ac4fc1a7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 20:51:10 +0800 Subject: [PATCH 78/81] Fix unit test --- Core/Package.swift | 1 + Core/Sources/SuggestionInjector/SuggestionInjector.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/Package.swift b/Core/Package.swift index 793eb4d1..e9620e51 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -161,6 +161,7 @@ let package = Package( .product(name: "SuggestionModel", package: "Tool"), .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift index c9d8b96f..a84a2c74 100644 --- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -239,7 +239,7 @@ public struct SuggestionInjector { // remove the first adjacent placeholder in suffix which looks like `<#Hello#>` - let regex = try! NSRegularExpression(pattern: "\\s+<#.*?#>") + let regex = try! NSRegularExpression(pattern: "\\s*?<#.*?#>") if let firstPlaceholderRange = regex.firstMatch( in: suffix, From a964bf67418038ab96a481d345a66b1451f247d7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 20:55:42 +0800 Subject: [PATCH 79/81] Adjust selection text --- .../HostApp/FeatureSettings/ChatSettingsView.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index a65ca08e..6a4c83fc 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -50,8 +50,11 @@ struct ChatSettingsView: View { if !settings.chatModels .contains(where: { $0.id == settings.defaultChatFeatureChatModelId }) { - Text(settings.chatModels.first?.name ?? "No Model Found") - .tag(settings.defaultChatFeatureChatModelId) + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.defaultChatFeatureChatModelId) } ForEach(settings.chatModels, id: \.id) { chatModel in @@ -66,8 +69,11 @@ struct ChatSettingsView: View { if !settings.embeddingModels .contains(where: { $0.id == settings.defaultChatFeatureEmbeddingModelId }) { - Text(settings.embeddingModels.first?.name ?? "No Model Found") - .tag(settings.defaultChatFeatureEmbeddingModelId) + Text( + (settings.embeddingModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.defaultChatFeatureEmbeddingModelId) } ForEach(settings.embeddingModels, id: \.id) { embeddingModel in From d06f430080f70bfb40c4399afa40538d95fe3ae8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 21:10:00 +0800 Subject: [PATCH 80/81] Bump version to 0.23.1 --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index 2e937d63..987c2122 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.23.0 +APP_VERSION = 0.23.1 APP_BUILD = 240 From cbe4dc2205496b2d1ace38f63c9b7615b420c5e0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 6 Sep 2023 21:10:09 +0800 Subject: [PATCH 81/81] Update appcast.xml --- appcast.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/appcast.xml b/appcast.xml index c03f7938..ca5e80bf 100644 --- a/appcast.xml +++ b/appcast.xml @@ -4,15 +4,15 @@ Copilot for Xcode - 0.23.0 - Wed, 06 Sep 2023 17:58:47 +0800 + 0.23.1 + Wed, 06 Sep 2023 21:08:26 +0800 240 - 0.23.0 + 0.23.1 12.0 - https://github.com/intitni/CopilotForXcode/releases/tag/0.23.0 + https://github.com/intitni/CopilotForXcode/releases/tag/0.23.1 - +