Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
05adc9d
Feature flag. Add branching to identifyLanguage call
iamgabrielma Mar 17, 2025
2d2036d
If flag enabled, show AIActionSheet in product creation
iamgabrielma Mar 17, 2025
26559a2
Update HubMenuItem UI
iamgabrielma Mar 17, 2025
c5a5b3a
Add generateAIProduct version using own API key
iamgabrielma Mar 18, 2025
90eaae4
add support for model selection
iamgabrielma Mar 18, 2025
ba32d19
add anthropic support
iamgabrielma Mar 18, 2025
d102126
Update AI settings UI and add debug logs
iamgabrielma Mar 18, 2025
212f32a
Extract duplication to data objects
iamgabrielma Mar 18, 2025
91408aa
remove duplication to common function
iamgabrielma Mar 18, 2025
39c2dea
extract parseAIResponse to common function
iamgabrielma Mar 18, 2025
cf1e94b
Create AISettingsViewModel and tests
iamgabrielma Mar 19, 2025
2195b1c
hook view and vm
iamgabrielma Mar 19, 2025
13284be
Additional tests
iamgabrielma Mar 19, 2025
61de898
lint
iamgabrielma Mar 19, 2025
e06e5bc
Add feature flag to eligibility check conditions
iamgabrielma Mar 19, 2025
15cba0d
Add AISource enum
iamgabrielma Mar 19, 2025
0181a7d
make test compile
iamgabrielma Mar 19, 2025
d8eedfd
Add analytics property
iamgabrielma Mar 19, 2025
f883887
delete debug helpers
iamgabrielma Mar 19, 2025
97512ec
Ammend - Add String conformance
iamgabrielma Mar 19, 2025
d40d8cd
Render AI Settings row only when eligible
iamgabrielma Mar 19, 2025
2ff8ff3
add analytic event on ai settings row tap
iamgabrielma Mar 19, 2025
cd113bf
Move strings to localization enum
iamgabrielma Mar 19, 2025
52aa1c4
UI update. Add API key security disclaimer
iamgabrielma Mar 19, 2025
a669c8b
Handle AI settings view when site already uses JPAI
iamgabrielma Mar 20, 2025
fe7b97d
UI updates to hide API key from view while not editing
iamgabrielma Mar 20, 2025
efe7dc1
Use Keychain to store and pass API key in WooCommerce and Networking …
iamgabrielma Mar 20, 2025
7b39a46
make test target compile
iamgabrielma Mar 20, 2025
ff6b32e
Remove apikey tests when origin is userdefaults
iamgabrielma Mar 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Experiments/Experiments/DefaultFeatureFlagService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
return buildConfig == .localDeveloper || buildConfig == .alpha
case .backgroundProductImageUpload:
return buildConfig == .localDeveloper || buildConfig == .alpha
case .allowMerchantAIAPIKey:
return buildConfig == .localDeveloper || buildConfig == .alpha
default:
return true
}
Expand Down
4 changes: 4 additions & 0 deletions Experiments/Experiments/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,8 @@ public enum FeatureFlag: Int {
/// Supports uploading product images in background
///
case backgroundProductImageUpload

/// Allows merchants to use their own API keys for AI-powered features
///
case allowMerchantAIAPIKey
}
8 changes: 8 additions & 0 deletions Networking/Networking/Mapper/AIProductMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,12 @@ struct AIProductMapper: Mapper {
.removingSuffix("```")
return try decoder.decode(AIProduct.self, from: Data(textCompletion.utf8))
}

func map(dictionary: [String: Any]) throws -> AIProduct {
// For non-network requests we pass [String: Any] to the map() as data, however decoding
// relies on mapping to a JetpackAIQueryResponse model, which fails in the direct API usage key
let decoder = JSONDecoder()
let data = try JSONSerialization.data(withJSONObject: dictionary)
return try decoder.decode(AIProduct.self, from: data)
}
}
459 changes: 377 additions & 82 deletions Networking/Networking/Remote/GenerativeContentRemote.swift

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@ extension WooAnalyticsEvent {
case description
case field
case featureWordCount = "feature_word_count"
case aiSource = "ai_source"
}

static func entryPointDisplayed() -> WooAnalyticsEvent {
WooAnalyticsEvent(statName: .productCreationAIEntryPointDisplayed,
properties: [:])
}

static func entryPointTapped() -> WooAnalyticsEvent {
static func entryPointTapped(_ aiSource: AISource) -> WooAnalyticsEvent {
WooAnalyticsEvent(statName: .productCreationAIEntryPointTapped,
properties: [:])
properties: [Key.aiSource.rawValue: aiSource.rawValue])
}

static func productNameContinueTapped() -> WooAnalyticsEvent {
Expand Down
1 change: 1 addition & 0 deletions WooCommerce/Classes/Analytics/WooAnalyticsStat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ enum WooAnalyticsStat: String {
case hubMenuSwitchStoreTapped = "hub_menu_switch_store_tapped"
case hubMenuOptionTapped = "hub_menu_option_tapped"
case hubMenuSettingsTapped = "hub_menu_settings_tapped"
case hubMenuAISettingsTapped = "hub_menu_ai_settings_tapped"

// MARK: Coupons
case couponsLoaded = "coupons_loaded"
Expand Down
7 changes: 7 additions & 0 deletions WooCommerce/Classes/Authentication/Keychain+Entries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ extension Keychain {
get { self[WooConstants.siteCredentialPassword] }
set { self[WooConstants.siteCredentialPassword] = newValue }
}

/// AI Provider API key
///
var aiProviderAPIKey: String? {
get { self[WooConstants.aiProviderAPIKey] }
set { self[WooConstants.aiProviderAPIKey] = newValue }
}
}
4 changes: 4 additions & 0 deletions WooCommerce/Classes/System/WooConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public enum WooConstants {
///
static let siteCredentialPassword = "siteCredentialPassword"

/// Keychain Access's Key for the AI API key entered by the merchant in AI settings
///
static let aiProviderAPIKey = "aiProviderAPIKey"

/// Keychain Access's Key for the current application password
///
static let applicationPassword = "ApplicationPassword"
Expand Down
230 changes: 230 additions & 0 deletions WooCommerce/Classes/ViewRelated/AI Settings/AISettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import SwiftUI
import Yosemite

struct AISettingsView: View {
@ObservedObject private var viewModel: AISettingsViewModel

// If we're already providing AI capabilities via WPCOM or JPAI we can
// override API key usage
private var shouldUseWPCOMJPAISource: Bool {
guard let site = ServiceLocator.stores.sessionManager.defaultSite else {
return false
}
if site.isWordPressComStore || site.isAIAssistantFeatureActive {
return true
} else {
return false
}
}

init(viewModel: AISettingsViewModel) {
self.viewModel = viewModel
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if shouldUseWPCOMJPAISource {
Text(Localization.builtInAIEnabled)
.font(.callout)
.foregroundColor(.secondary)
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray6))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.gray), lineWidth: 1)
)
.padding(.bottom, 8)
.frame(width: .infinity)
}

HStack {
Text(Localization.aiProvider)
Picker(Localization.selectProvider, selection: $viewModel.selectedProvider) {
Text(Localization.openAI).tag("OpenAI")
Text(Localization.anthropic).tag("Anthropic")
}
.pickerStyle(MenuPickerStyle())
.onChange(of: viewModel.selectedProvider) { newValue in
viewModel.updateProvider(newValue)
}
.disabled(shouldUseWPCOMJPAISource)
.opacity(shouldUseWPCOMJPAISource ? 0.5 : 1.0)

if shouldUseWPCOMJPAISource {
Image(systemName: "lock.fill")
.foregroundColor(.gray)
}
}

HStack {
Text(Localization.models)
Picker(Localization.selectModel, selection: $viewModel.selectedModel) {
ForEach(viewModel.selectedProvider == "OpenAI" ? viewModel.openAIModels : viewModel.anthropicModels, id: \.self) { model in
Text(model).tag(model)
}
}
.pickerStyle(MenuPickerStyle())
.disabled(shouldUseWPCOMJPAISource)
.opacity(shouldUseWPCOMJPAISource ? 0.5 : 1.0)

if shouldUseWPCOMJPAISource {
Image(systemName: "lock.fill")
.foregroundColor(.gray)
}
}

Divider()

VStack(alignment: .leading, spacing: 8) {
HStack {
TextField(
Localization.enterAPIKey,
text: Binding(
get: { viewModel.isEditingApiKey ? viewModel.apiKey : "**********" },
set: { newValue in if viewModel.isEditingApiKey { viewModel.apiKey = newValue } }
)
)
.textFieldStyle(RoundedBorderTextFieldStyle(focused: viewModel.isEditingApiKey))
.foregroundColor(.primary)
.privacySensitive()
.disabled(!viewModel.isEditingApiKey)

if viewModel.isEditingApiKey, !viewModel.apiKey.isEmpty {
Button(action: viewModel.clearApiKey) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
}

Button(action: viewModel.toggleEditing) {
Text(viewModel.isEditingApiKey ? Localization.save : Localization.edit)
}
.disabled(shouldUseWPCOMJPAISource)
.opacity(shouldUseWPCOMJPAISource ? 0.5 : 1.0)

if shouldUseWPCOMJPAISource {
Image(systemName: "lock.fill")
.foregroundColor(.gray)
}
}

Text(Localization.apiKeyDescription)
.font(.caption)
.foregroundColor(.secondary)

Spacer()
}
Text(Localization.apiKeyDisclaimer)
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.onAppear {
viewModel.onAppear()
if shouldUseWPCOMJPAISource {
viewModel.selectedProvider = "OpenAI"
viewModel.selectedModel = "gpt-4o"
}
}
}
.navigationTitle(Localization.navigationTitle)
}
}

private extension AISettingsView {
enum Localization {
static let navigationTitle = NSLocalizedString(
"aiSettings.navigationTitle",
value: "AI Settings",
comment: "Navigation title for the AI Settings screen"
)

static let aiProvider = NSLocalizedString(
"aiSettings.aiProvider",
value: "Provider",
comment: "Label for the AI provider selection in AI settings"
)

static let selectProvider = NSLocalizedString(
"aiSettings.selectProvider",
value: "Select Provider",
comment: "Accessibility label for the AI provider picker"
)

static let openAI = NSLocalizedString(
"aiSettings.openAI",
value: "OpenAI",
comment: "Label for OpenAI provider option"
)

static let anthropic = NSLocalizedString(
"aiSettings.anthropic",
value: "Anthropic",
comment: "Label for Anthropic provider option"
)

static let models = NSLocalizedString(
"aiSettings.models",
value: "Models",
comment: "Label for the AI models selection"
)

static let selectModel = NSLocalizedString(
"aiSettings.selectModel",
value: "Select Model",
comment: "Accessibility label for the AI model picker"
)

static let enterAPIKey = NSLocalizedString(
"aiSettings.enterAPIKey",
value: "Enter API Key",
comment: "Placeholder text for the API key input field"
)

static let save = NSLocalizedString(
"aiSettings.save",
value: "Save",
comment: "Button title to save API key"
)

static let edit = NSLocalizedString(
"aiSettings.edit",
value: "Edit",
comment: "Button title to edit API key"
)

static let apiKeyDescription = NSLocalizedString(
"aiSettings.apiKeyDescription",
value: "Enter your API key to use AI generation at public API costs.",
comment: "Description text explaining the purpose of the API key"
)

static let builtInAIEnabled = NSLocalizedString(
"aiSettings.builtInAIEnabled",
value: "AI capabilities are already enabled for this site.",
comment: "Message displayed when built-in AI feature is enabled"
)

static func currentAISource(_ source: String) -> String {
String(format: NSLocalizedString(
"aiSettings.currentAISource",
value: "Current AI source: %@",
comment: "Label showing the current AI source being used. %@ shows the provider name (e.g. Jetpack)"
), source)
}

static let apiKeyDisclaimer = NSLocalizedString(
"aiSettings.apiKeyDisclaimer",
value: "API keys open up access to potentially sensitive information. Do not share your API key with others or expose them.",
comment: "Warning message about keeping API keys secure"
)
}
}

#Preview {
AISettingsView(viewModel: AISettingsViewModel())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import SwiftUI
import KeychainAccess

final class AISettingsViewModel: ObservableObject {
private var keychain = Keychain(service: WooConstants.keychainServiceName)

@Published var apiKey: String // TODO: Make function and restrict set access
@Published var selectedModel: String
@Published var selectedProvider: String
@Published var isEditingApiKey: Bool

private let defaults: UserDefaults

let openAIModels = [
"gpt-4o",
"gpt-4-turbo",
"gpt-3.5-turbo"
]

let anthropicModels = [
"claude-3-haiku-20240307"
]

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.apiKey = Keychain(service: WooConstants.keychainServiceName).aiProviderAPIKey ?? ""
self.selectedModel = defaults.string(forKey: "AIProviderModel") ?? ""
self.selectedProvider = defaults.string(forKey: "AIProvider") ?? ""
self.isEditingApiKey = false
}

func onAppear() {
isEditingApiKey = apiKey.isEmpty
}

func updateProvider(_ provider: String) {
selectedProvider = provider
selectedModel = provider == "OpenAI" ? openAIModels.first ?? "" : anthropicModels.first ?? ""
saveSettings()
}

func clearApiKey() {
apiKey = ""
keychain.aiProviderAPIKey = nil
}

func toggleEditing() {
if isEditingApiKey {
saveSettings()
}
isEditingApiKey.toggle()
}

private func saveSettings() {
keychain.aiProviderAPIKey = apiKey
defaults.setValue(selectedModel, forKey: "AIProviderModel")
defaults.setValue(selectedProvider, forKey: "AIProvider")
}
}
6 changes: 6 additions & 0 deletions WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ struct HubMenu: View {
ServiceLocator.analytics.track(event: .Blaze.blazeCampaignListEntryPointSelected(source: .menu))
case HubMenuViewModel.PointOfSaleEntryPoint.id:
viewModel.showsPOS = true
case HubMenuViewModel.AISettings.id:
ServiceLocator.analytics.track(.hubMenuAISettingsTapped)
default:
break
}
Expand Down Expand Up @@ -183,6 +185,10 @@ private extension HubMenu {
BlazeCampaignListHostingControllerRepresentable(siteID: viewModel.siteID, selectedCampaignID: campaignID)
case .blazeCampaignCreation:
BlazeCampaignListHostingControllerRepresentable(siteID: viewModel.siteID, startsCampaignCreationOnAppear: true)
case .aiSettings:
// TODO: Pass eligibility, so we know what's the AI source
let viewModel = AISettingsViewModel()
AISettingsView(viewModel: viewModel)
}
}
.navigationBarTitleDisplayMode(.inline)
Expand Down
Loading