diff --git a/Easydict.xcodeproj/project.pbxproj b/Easydict.xcodeproj/project.pbxproj index 7c45aa060..2f3787be4 100644 --- a/Easydict.xcodeproj/project.pbxproj +++ b/Easydict.xcodeproj/project.pbxproj @@ -3555,7 +3555,7 @@ repositoryURL = "https://github.com/google/generative-ai-swift"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.4.4; + minimumVersion = 0.5.3; }; }; 03022F1A2B35DEBA00B63209 /* XCRemoteSwiftPackageReference "Hue" */ = { diff --git a/Easydict.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Easydict.xcworkspace/xcshareddata/swiftpm/Package.resolved index b579e007d..2e0e0074b 100644 --- a/Easydict.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Easydict.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/generative-ai-swift", "state" : { - "revision" : "dcbdb5e591e1aa2bb68851dc7515f6b0a59026cd", - "version" : "0.4.7" + "revision" : "5d750b80651da9721c37c5eb1fc0b6750d1884d3", + "version" : "0.5.3" } }, { diff --git a/Easydict/App/Localizable.xcstrings b/Easydict/App/Localizable.xcstrings index 0ecc4103d..bb0b58633 100644 --- a/Easydict/App/Localizable.xcstrings +++ b/Easydict/App/Localizable.xcstrings @@ -2410,18 +2410,18 @@ } } }, - "service.configuration.gemini.api_key.title" : { + "service.configuration.gemini.api_key.placeholder" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "API Key" + "value" : "xxxxxxxxxxxxx" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "API Key" + "value" : "xxxxxxxxxxxxx" } } } diff --git a/Easydict/Swift/Feature/Configuration/Configuration+Defaults.swift b/Easydict/Swift/Feature/Configuration/Configuration+Defaults.swift index 46618c67f..629799283 100644 --- a/Easydict/Swift/Feature/Configuration/Configuration+Defaults.swift +++ b/Easydict/Swift/Feature/Configuration/Configuration+Defaults.swift @@ -208,7 +208,7 @@ class ShortcutWrapper { // Service Configuration extension Defaults.Keys { // OpenAI - static let openAIAPIKey = Key(EZOpenAIAPIKey) + static let openAIAPIKey = Key(apiStoredKey(.openAI)) // EZOpenAIAPIKey static let openAITranslation = Key( translationStoredKey(.openAI), default: "1" @@ -221,27 +221,30 @@ extension Defaults.Keys { sentenceStoredKey(.openAI), default: "1" ) - static let openAIServiceUsageStatus = Key( + static let openAIServiceUsageStatus = Key( serviceUsageStatusStoredKey(.openAI), default: .default ) - static let openAIEndPoint = Key(EZOpenAIEndPointKey) - static let openAIModel = Key(EZOpenAIModelKey, default: OpenAIModel.gpt3_5_turbo.rawValue) + static let openAIEndPoint = Key(endpointStoredKey(.openAI)) + static let openAIModel = Key( + modelStoredKey(.openAI), + default: OpenAIModel.gpt3_5_turbo.rawValue + ) static let openAIAvailableModels = Key( - EZOpenAIAvailableModelsKey, + availableModelsStoredKey(.openAI), default: OpenAIModel.allCases.map { $0.rawValue }.joined(separator: ",") ) static let openAIVaildModels = Key( - EZOpenAIValidModelsKey, + validModelsStoredKey(.openAI), default: OpenAIModel.allCases.map { $0.rawValue } ) // Custom OpenAI static let customOpenAINameKey = Key( - EZCustomOpenAINameKey, + nameStoredKey(.customOpenAI), default: NSLocalizedString("custom_openai", comment: "") ) - static let customOpenAIAPIKey = Key(EZCustomOpenAIAPIKey, default: "") + static let customOpenAIAPIKey = Key(apiStoredKey(.customOpenAI)) static let customOpenAITranslation = Key( translationStoredKey(.customOpenAI), default: "1" @@ -254,20 +257,29 @@ extension Defaults.Keys { sentenceStoredKey(.customOpenAI), default: "0" ) - static let customOpenAIServiceUsageStatus = Key( + static let customOpenAIServiceUsageStatus = Key( serviceUsageStatusStoredKey(.builtInAI), default: .default ) - static let customOpenAIEndPoint = Key(EZCustomOpenAIEndPointKey, default: "") - static let customOpenAIModel = Key(EZCustomOpenAIModelKey, default: "") - static let customOpenAIAvailableModels = Key(EZCustomOpenAIAvailableModelsKey, default: "") + static let customOpenAIEndPoint = Key(endpointStoredKey(.customOpenAI)) + static let customOpenAIModel = Key( + modelStoredKey(.customOpenAI), + default: "" + ) + static let customOpenAIAvailableModels = Key( + availableModelsStoredKey(.customOpenAI), + default: "" + ) static let customOpenAIVaildModels = Key( - EZCustomOpenAIValidModelsKey, + validModelsStoredKey(.customOpenAI), default: [""] ) // Built-in AI - static let builtInAIModel = Key(EZBuiltInAIModelKey, default: "") + static let builtInAIModel = Key( + modelStoredKey(.builtInAI), + default: "" + ) // EZBuiltInAIModelKey static let builtInAITranslation = Key( translationStoredKey(.builtInAI), default: "1" @@ -280,11 +292,42 @@ extension Defaults.Keys { sentenceStoredKey(.builtInAI), default: "0" ) - static let builtInAIServiceUsageStatus = Key( + static let builtInAIServiceUsageStatus = Key( serviceUsageStatusStoredKey(.builtInAI), default: .default ) + // Gemni + static let geminiAPIKey = Key(apiStoredKey(.gemini)) // EZGeminiAPIKey + static let geminiTranslation = Key( + translationStoredKey(.gemini), + default: "1" + ) + static let geminiDictionary = Key( + dictionaryStoredKey(.gemini), + default: "1" + ) + static let geminiSentence = Key( + sentenceStoredKey(.gemini), + default: "1" + ) + static let geminiServiceUsageStatus = Key( + serviceUsageStatusStoredKey(.gemini), + default: .default + ) + static let geminiModel = Key( + modelStoredKey(.gemini), + default: GeminiModel.gemini1_5_flash.rawValue + ) + static let geminiAvailableModels = Key( + availableModelsStoredKey(.gemini), + default: GeminiModel.allCases.map { $0.rawValue }.joined(separator: ",") + ) + static let geminiValidModels = Key( + validModelsStoredKey(.gemini), + default: GeminiModel.allCases.map { $0.rawValue } + ) + // DeepL static let deepLAuth = Key(EZDeepLAuthKey) static let deepLTranslation = Key( @@ -309,9 +352,6 @@ extension Defaults.Keys { // Ali static let aliAccessKeyId = Key(EZAliAccessKeyId) static let aliAccessKeySecret = Key(EZAliAccessKeySecret) - - // Gemni - static let geminiAPIKey = Key(EZGeminiAPIKey) } /// shortcut diff --git a/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift b/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift index a5ed08daa..203ea793a 100644 --- a/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift +++ b/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift @@ -8,6 +8,7 @@ import Foundation +// TODO: refactor key with enum key type. func storedKey(_ key: String, serviceType: ServiceType) -> String { // This key should be compatible with existing OpenAI config keys // EZOpenAIServiceUsageStatusKey @@ -31,6 +32,30 @@ func dictionaryStoredKey(_ serviceType: ServiceType) -> String { storedKey(EZDictionaryKey, serviceType: serviceType) } +func availableModelsStoredKey(_ serviceType: ServiceType) -> String { + storedKey(EZAvailableModelsKey, serviceType: serviceType) +} + +func validModelsStoredKey(_ serviceType: ServiceType) -> String { + storedKey(EZValidModelsKey, serviceType: serviceType) +} + +func modelStoredKey(_ serviceType: ServiceType) -> String { + storedKey(EZModelKey, serviceType: serviceType) +} + +func apiStoredKey(_ serviceType: ServiceType) -> String { + storedKey(EZAPIKey, serviceType: serviceType) +} + +func endpointStoredKey(_ serviceType: ServiceType) -> String { + storedKey(EZEndpointKey, serviceType: serviceType) +} + +func nameStoredKey(_ serviceType: ServiceType) -> String { + storedKey(EZNameKey, serviceType: serviceType) +} + extension UserDefaults { static func bool(forKey key: String, serviceType: ServiceType) -> Bool { let key = storedKey(key, serviceType: serviceType) diff --git a/Easydict/Swift/Service/Gemini/GeminiService.swift b/Easydict/Swift/Service/Gemini/GeminiService.swift index 85c07e3f8..e27700adb 100644 --- a/Easydict/Swift/Service/Gemini/GeminiService.swift +++ b/Easydict/Swift/Service/Gemini/GeminiService.swift @@ -10,7 +10,8 @@ import Defaults import Foundation import GoogleGenerativeAI -// TODO: add a LLM stream service base class, make both OpenAI and Gemini inherit from it. +// MARK: - GeminiService + @objc(EZGeminiService) public final class GeminiService: LLMStreamService { // MARK: Public @@ -27,10 +28,6 @@ public final class GeminiService: LLMStreamService { NSLocalizedString("gemini_translate", comment: "The name of Gemini Translate") } - override public func queryTextType() -> EZQueryTextType { - [.translation] - } - override public func translate( _ text: String, from: Language, @@ -39,27 +36,47 @@ public final class GeminiService: LLMStreamService { ) { Task { do { - let translationPrompt = translationPrompt(text: text, from: from, to: to) - let prompt = LLMStreamService.translationSystemPrompt + - "\n" + translationPrompt + result.isStreamFinished = false + + let queryType = queryType(text: text, from: from, to: to) + let systemPrompt = queryType == .dictionary ? LLMStreamService + .dictSystemPrompt : LLMStreamService + .translationSystemPrompt + + var enableSystemPromptInChats = false + var systemInstruction: ModelContent? = try ModelContent(role: "system", systemPrompt) + + // !!!: gemini-1.0-pro model does not support system instruction https://github.com/google-gemini/generative-ai-python/issues/328 + if model == GeminiModel.gemini1_0_pro.rawValue { + systemInstruction = nil + enableSystemPromptInChats = true + } + + let chatHistory = promptContent( + queryType: queryType, + text: text, + from: from, + to: to, + systemPrompt: enableSystemPromptInChats + ) + let model = GenerativeModel( - name: "gemini-pro", + name: model, apiKey: apiKey, safetySettings: [ harassmentBlockNone, hateSpeechBlockNone, sexuallyExplicitBlockNone, dangerousContentBlockNone, - ] + ], + systemInstruction: systemInstruction ) - result.isStreamFinished = false - var resultString = "" // Gemini Docs: https://github.com/google/generative-ai-swift - let outputContentStream = model.generateContentStream(prompt) + let outputContentStream = model.generateContentStream(chatHistory) for try await outputContent in outputContentStream { guard let line = outputContent.text else { return @@ -69,9 +86,12 @@ public final class GeminiService: LLMStreamService { result.translatedResults = [resultString] await MainActor.run { - throttler.throttle { [unowned self] in - completion(result, nil) - } + handleResult( + queryType: queryType, + resultText: concatenateStrings(from: result.translatedResults ?? []), + error: nil, + completion: completion + ) } } } @@ -123,6 +143,20 @@ public final class GeminiService: LLMStreamService { Defaults[.geminiAPIKey] ?? "" } + override var availableModels: [String] { + Defaults[.geminiValidModels] + } + + override var model: String { + get { + Defaults[.geminiModel] + } + set { + // easydict://writeKeyValue?EZGeminiModelKey=gemini-1.5-flash + Defaults[.geminiModel] = newValue + } + } + // MARK: Private // Set Gemini safety level to BLOCK_NONE @@ -130,4 +164,70 @@ public final class GeminiService: LLMStreamService { private let hateSpeechBlockNone = SafetySetting(harmCategory: .hateSpeech, threshold: .blockNone) private let sexuallyExplicitBlockNone = SafetySetting(harmCategory: .sexuallyExplicit, threshold: .blockNone) private let dangerousContentBlockNone = SafetySetting(harmCategory: .dangerousContent, threshold: .blockNone) + + /// Given a roleRaw, currently only support "user" and "model", "model" is equal to "assistant". https://ai.google.dev/gemini-api/docs/get-started/tutorial?lang=swift&hl=zh-cn#multi-turn-conversations-chat + private func getCorrectParts(from roleRaw: String) -> String { + if roleRaw == "assistant" { + "model" + } else if roleRaw == "system" { + "user" + } else { + roleRaw + } + } + + private func concatenateStrings(from array: [String]) -> String { + array.joined() + } +} + +extension GeminiService { + func promptContent( + queryType: EZQueryTextType, + text: String, + from sourceLanguage: Language, + to targetLanguage: Language, + systemPrompt: Bool + ) + -> [ModelContent] { + var prompts = [[String: String]]() + + switch queryType { + case .dictionary: + prompts = dictMessages( + word: text, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + systemPrompt: systemPrompt + ) + case .sentence: + prompts = sentenceMessages( + sentence: text, + from: sourceLanguage, + to: targetLanguage, + systemPrompt: systemPrompt + ) + case .translation: + fallthrough + default: + prompts = translationMessages( + text: text, + from: sourceLanguage, + to: targetLanguage, + systemPrompt: systemPrompt + ) + } + + var chats: [ModelContent] = [] + for prompt in prompts { + if let roleRaw = prompt["role"], + let parts = prompt["content"] { + let role = getCorrectParts(from: roleRaw) + let chat = ModelContent(role: role, parts: parts) + chats.append(chat) + } + } + + return chats + } } diff --git a/Easydict/Swift/Service/OpenAI/BaseOpenAIService.swift b/Easydict/Swift/Service/OpenAI/BaseOpenAIService.swift index 44cd99830..d60c61d2a 100644 --- a/Easydict/Swift/Service/OpenAI/BaseOpenAIService.swift +++ b/Easydict/Swift/Service/OpenAI/BaseOpenAIService.swift @@ -16,8 +16,6 @@ import OpenAI @objcMembers @objc(EZBaseOpenAIService) public class BaseOpenAIService: LLMStreamService { - // MARK: Public - override public func translate( _ text: String, from: Language, @@ -31,8 +29,6 @@ public class BaseOpenAIService: LLMStreamService { return } - updateCompletion = completion - var resultText = "" result.from = from @@ -92,54 +88,6 @@ public class BaseOpenAIService: LLMStreamService { } } } - - // MARK: Internal - - var updateCompletion: ((EZQueryResult, Error?) -> ())? - - // MARK: Private - - private func handleResult( - queryType: EZQueryTextType, - resultText: String?, - error: Error?, - completion: @escaping (EZQueryResult, Error?) -> () - ) { - var normalResults: [String]? - if let resultText { - normalResults = [resultText.trim()] - } - - result.isStreamFinished = error != nil - result.translatedResults = normalResults - - let updateCompletion = { - self.throttler.throttle { [unowned self] in - self.updateCompletion?(result, error) - } - } - - switch queryType { - case .sentence, .translation: - updateCompletion() - - case .dictionary: - if error != nil { - result.showBigWord = false - result.translateResultsTopInset = 0 - updateCompletion() - return - } - - result.showBigWord = true - result.queryText = queryModel.queryText - result.translateResultsTopInset = 6 - updateCompletion() - - default: - updateCompletion() - } - } } // MARK: OpenAI chat messages @@ -151,7 +99,7 @@ extension BaseOpenAIService { typealias Role = ChatCompletionMessageParam.Role var chats: [ChatCompletionMessageParam] = [] - let messages = translationMessages(text: text, from: from, to: to) + let messages = translationMessages(text: text, from: from, to: to, systemPrompt: false) for message in messages { if let roleRawValue = message["role"], let role = Role(rawValue: roleRawValue), @@ -177,13 +125,13 @@ extension BaseOpenAIService { switch queryType { case .sentence: - messages = sentenceMessages(sentence: text, from: from, to: to) + messages = sentenceMessages(sentence: text, from: from, to: to, systemPrompt: true) case .dictionary: - messages = dictMessages(word: text, sourceLanguage: from, targetLanguage: to) + messages = dictMessages(word: text, sourceLanguage: from, targetLanguage: to, systemPrompt: true) case .translation: fallthrough default: - messages = translationMessages(text: text, from: from, to: to) + messages = translationMessages(text: text, from: from, to: to, systemPrompt: true) } var chats: [ChatCompletionMessageParam] = [] diff --git a/Easydict/Swift/Service/OpenAI/LLMStreamService.swift b/Easydict/Swift/Service/OpenAI/LLMStreamService.swift index 96b21361d..7f198152b 100644 --- a/Easydict/Swift/Service/OpenAI/LLMStreamService.swift +++ b/Easydict/Swift/Service/OpenAI/LLMStreamService.swift @@ -6,6 +6,7 @@ // Copyright © 2024 izual. All rights reserved. // +import Defaults import Foundation // MARK: - LLMStreamService @@ -131,3 +132,86 @@ public class LLMStreamService: QueryService { return .translation } } + +// MARK: - ServiceUsageStatus + +enum ServiceUsageStatus: String, CaseIterable { + case `default` = "0" + case alwaysOff = "1" + case alwaysOn = "2" +} + +// MARK: EnumLocalizedStringConvertible + +extension ServiceUsageStatus: EnumLocalizedStringConvertible { + var title: String { + switch self { + case .default: + NSLocalizedString( + "service.configuration.openai.usage_status_default.title", + bundle: .main, + comment: "" + ) + case .alwaysOff: + NSLocalizedString( + "service.configuration.openai.usage_status_always_off.title", + bundle: .main, + comment: "" + ) + case .alwaysOn: + NSLocalizedString( + "service.configuration.openai.usage_status_always_on.title", + bundle: .main, + comment: "" + ) + } + } +} + +// MARK: Defaults.Serializable + +extension ServiceUsageStatus: Defaults.Serializable {} + +extension LLMStreamService { + func handleResult( + queryType: EZQueryTextType, + resultText: String?, + error: Error?, + completion: @escaping (EZQueryResult, Error?) -> () + ) { + var normalResults: [String]? + if let resultText { + normalResults = [resultText.trim()] + } + + result.isStreamFinished = error != nil + result.translatedResults = normalResults + + let updateCompletion = { + self.throttler.throttle { [unowned self] in + completion(result, error) + } + } + + switch queryType { + case .sentence, .translation: + updateCompletion() + + case .dictionary: + if error != nil { + result.showBigWord = false + result.translateResultsTopInset = 0 + updateCompletion() + return + } + + result.showBigWord = true + result.queryText = queryModel.queryText + result.translateResultsTopInset = 6 + updateCompletion() + + default: + updateCompletion() + } + } +} diff --git a/Easydict/Swift/Service/OpenAI/Prompt.swift b/Easydict/Swift/Service/OpenAI/Prompt.swift index b4f8e3805..24c51bfce 100644 --- a/Easydict/Swift/Service/OpenAI/Prompt.swift +++ b/Easydict/Swift/Service/OpenAI/Prompt.swift @@ -15,11 +15,15 @@ extension LLMStreamService { You are a translation expert proficient in various languages, focusing solely on translating text without interpretation. You accurately understand the meanings of proper nouns, idioms, metaphors, allusions, and other obscure words in sentences, translating them appropriately based on the context and language environment. The translation should be natural and fluent. Only return the translated text, without including redundant quotes or additional notes. """ + static let dictSystemPrompt = """ + You are a word search assistant skilled in multiple languages and knowledgeable in etymology. You can help search for words, phrases, slang, abbreviations, and other information. Prioritize queries from authoritative dictionary databases, such as the Oxford Dictionary, Cambridge Dictionary, and Wikipedia. If a word or abbreviation has multiple meanings, look up the most commonly used ones. + """ + func translationPrompt(text: String, from sourceLanguage: Language, to targetLanguage: Language) -> String { "Translate the following \(sourceLanguage.queryLanguageName) text into \(targetLanguage.queryLanguageName) text: \"\"\"\(text)\"\"\"" } - func translationMessages(text: String, from: Language, to: Language) -> [[String: String]] { + func translationMessages(text: String, from: Language, to: Language, systemPrompt: Bool) -> [[String: String]] { // Use """ %@ """ to wrap user input, Ref: https://help.openai.com/en/articles/6654000-best-practices-for-prompt-engineering-with-openai-api#h_21d4f4dc3d // let prompt = "Translate the following \(from.rawValue) text into \(to.rawValue) text: \"\"\"\(text)\"\"\"" @@ -245,12 +249,16 @@ extension LLMStreamService { ], ] - let systemMessages = [ - [ - "role": "system", - "content": LLMStreamService.translationSystemPrompt, - ], - ] + let systemMessages: [[String: String]] = { + if systemPrompt { + [[ + "role": "system", + "content": LLMStreamService.translationSystemPrompt, + ]] + } else { + [] + } + }() var messages = systemMessages messages.append(contentsOf: chineseFewShot) @@ -276,7 +284,8 @@ extension LLMStreamService { func sentenceMessages( sentence: String, from sourceLanguage: Language, - to targetLanguage: Language + to targetLanguage: Language, + systemPrompt: Bool ) -> [[String: String]] { let answerLanguage = Configuration.shared.firstLanguage @@ -469,12 +478,16 @@ extension LLMStreamService { ], ] - let systemMessages = [ - [ - "role": "system", - "content": LLMStreamService.translationSystemPrompt, - ], - ] + let systemMessages: [[String: String]] = { + if systemPrompt { + [[ + "role": "system", + "content": LLMStreamService.translationSystemPrompt, + ]] + } else { + [] + } + }() var messages = systemMessages @@ -496,7 +509,13 @@ extension LLMStreamService { return messages } - func dictMessages(word: String, sourceLanguage: Language, targetLanguage: Language) -> [[String: String]] { + func dictMessages( + word: String, + sourceLanguage: Language, + targetLanguage: Language, + systemPrompt: Bool + ) + -> [[String: String]] { var prompt = "" let answerLanguage = Configuration.shared.firstLanguage @@ -522,10 +541,6 @@ extension LLMStreamService { let sourceLanguageString = sourceLanguage.rawValue - let dictSystemPrompt = """ - You are a word search assistant skilled in multiple languages and knowledgeable in etymology. You can help search for words, phrases, slang, abbreviations, and other information. Prioritize queries from authoritative dictionary databases, such as the Oxford Dictionary, Cambridge Dictionary, and Wikipedia. For Chinese words, use Baidu Baike. If a word or abbreviation has multiple meanings, look up the most commonly used ones. - """ - let answerLanguagePrompt = "Using \(answerLanguage.rawValue): \n" prompt.append(answerLanguagePrompt) @@ -823,12 +838,16 @@ extension LLMStreamService { ], ] - let systemMessages = [ - [ - "role": "system", - "content": dictSystemPrompt, - ], - ] + let systemMessages: [[String: String]] = { + if systemPrompt { + [[ + "role": "system", + "content": LLMStreamService.dictSystemPrompt, + ]] + } else { + [] + } + }() var messages = systemMessages diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/BuiltInAIService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/BuiltInAIService+ConfigurableService.swift index a876825e1..7895a4965 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/BuiltInAIService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/BuiltInAIService+ConfigurableService.swift @@ -31,7 +31,7 @@ extension BuiltInAIService: ConfigurableService { ServiceConfigurationPickerCell( titleKey: "service.configuration.openai.usage_status.title", key: .builtInAIServiceUsageStatus, - values: OpenAIUsageStatus.allCases + values: ServiceUsageStatus.allCases ) } } diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/CustomOpenAIService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/CustomOpenAIService+ConfigurableService.swift index 34fab1489..f738bc2c5 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/CustomOpenAIService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/CustomOpenAIService+ConfigurableService.swift @@ -89,7 +89,7 @@ private struct CustomOpenAIServiceConfigurationView: View { ServiceConfigurationPickerCell( titleKey: "service.configuration.openai.usage_status.title", key: .customOpenAIServiceUsageStatus, - values: OpenAIUsageStatus.allCases + values: ServiceUsageStatus.allCases ) } .onDisappear { diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/GeminiService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/GeminiService+ConfigurableService.swift index b6a6e75d2..0568af977 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/GeminiService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/GeminiService+ConfigurableService.swift @@ -6,16 +6,181 @@ // Copyright © 2024 izual. All rights reserved. // +import Combine +import Defaults import Foundation import SwiftUI +// MARK: - GeminiService + ConfigurableService + extension GeminiService: ConfigurableService { func configurationListItems() -> some View { - ServiceConfigurationSecretSectionView(service: self, observeKeys: [.geminiAPIKey]) { + GeminiServiceConfigurationView(service: self) + } +} + +// MARK: - GeminiServiceConfigurationView + +private struct GeminiServiceConfigurationView: View { + // MARK: Lifecycle + + init(service: GeminiService) { + self.service = service + self.viewModel = GeminiViewModel(service: service) + } + + // MARK: Internal + + let service: GeminiService + + var body: some View { + ServiceConfigurationSecretSectionView( + service: service, + observeKeys: [.geminiAPIKey, .geminiAvailableModels] + ) { ServiceConfigurationSecureInputCell( - textFieldTitleKey: "service.configuration.gemini.api_key.title", - key: .geminiAPIKey + textFieldTitleKey: "service.configuration.openai.api_key.title", + key: .geminiAPIKey, + placeholder: "service.configuration.gemini.api_key.placeholder" + ) + // supported models + TextField( + "service.configuration.custom_openai.supported_models.title", + text: viewModel.$availableModels ?? "", + prompt: Text("service.configuration.custom_openai.model.placeholder") + ) + .padding(10.0) + Picker( + "service.configuration.openai.model.title", + selection: viewModel.$model + ) { + ForEach(viewModel.validModels, id: \.self) { value in + Text(value) + } + } + .padding(10.0) + + ServiceConfigurationToggleCell( + titleKey: "service.configuration.openai.translation.title", + key: .geminiTranslation + ) + ServiceConfigurationToggleCell( + titleKey: "service.configuration.openai.sentence.title", + key: .geminiSentence + ) + ServiceConfigurationToggleCell( + titleKey: "service.configuration.openai.dictionary.title", + key: .geminiDictionary + ) + ServiceConfigurationPickerCell( + titleKey: "service.configuration.openai.usage_status.title", + key: .geminiServiceUsageStatus, + values: ServiceUsageStatus.allCases ) } + .onDisappear { + viewModel.invalidate() + } } + + // MARK: Private + + @ObservedObject private var viewModel: GeminiViewModel } + +// MARK: - GeminiViewModel + +private class GeminiViewModel: ObservableObject { + // MARK: Lifecycle + + init(service: GeminiService) { + self.service = service + Defaults.publisher(.geminiModel, options: []) + .removeDuplicates() + .sink { _ in + self.modelChanged() + } + .store(in: &cancellables) + Defaults.publisher(.geminiAvailableModels) + .removeDuplicates() + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .sink { _ in + self.modelsTextChanged() + } + .store(in: &cancellables) + } + + // MARK: Internal + + let service: GeminiService + + @Default(.geminiModel) var model + @Default(.geminiAvailableModels) var availableModels + + @Published var validModels: [String] = [] + + func invalidate() { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + // MARK: Private + + private var cancellables: Set = [] + + private func modelChanged() { + if !validModels.contains(model) { + if model.isEmpty { + availableModels = "" + } else { + if availableModels?.isEmpty == true { + availableModels = model + } else { + availableModels = availableModels ?? "" + } + } + } + serviceConfigChanged() + } + + private func modelsTextChanged() { + guard let availableModels else { return } + + validModels = availableModels.components(separatedBy: ",") + .map { $0.trim() }.filter { !$0.isEmpty } + + if validModels.isEmpty { + model = "" + } else if !validModels.contains(model) { + model = validModels[0] + } + + Defaults[.geminiValidModels] = validModels + } + + private func serviceConfigChanged() { + objectWillChange.send() + + let userInfo: [String: Any] = [ + EZWindowTypeKey: service.windowType.rawValue, + EZServiceTypeKey: service.serviceType().rawValue, + ] + let notification = Notification(name: .serviceHasUpdated, object: nil, userInfo: userInfo) + NotificationCenter.default.post(notification) + } +} + +// MARK: - GeminiModel + +// swiftlint:disable identifier_name +enum GeminiModel: String, CaseIterable { + // Docs: https://ai.google.dev/gemini-api/docs/models/gemini + + // RPM: Requests per minute, TPM: Tokens per minute + // RPD: Requests per day, TPD: Tokens per day + case gemini1_0_pro = "gemini-1.0-pro" // Free 15 RPM/32,000 TPM, 1,500 RPD/46,080,000 TPD (n/a context length) + case gemini1_5_flash = "gemini-1.5-flash" // Free 15 RPM/100million TPM, 1500 RPD/ n/a TPD (1048k context length) + case gemini1_5_pro = "gemini-1.5-pro" // Free 2 RPM/32,000 TPM, 50 RPD/46,080,000 TPD (1048k context length) +} + +// swiftlint:enable identifier_name diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift index f8a872b6a..da600cba5 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift @@ -81,7 +81,7 @@ private struct OpenAIServiceConfigurationView: View { ServiceConfigurationPickerCell( titleKey: "service.configuration.openai.usage_status.title", key: .openAIServiceUsageStatus, - values: OpenAIUsageStatus.allCases + values: ServiceUsageStatus.allCases ) } .onDisappear { @@ -210,42 +210,3 @@ extension OpenAIModel: EnumLocalizedStringConvertible { // MARK: Defaults.Serializable extension OpenAIModel: Defaults.Serializable {} - -// MARK: - OpenAIUsageStatus - -enum OpenAIUsageStatus: String, CaseIterable { - case `default` = "0" - case alwaysOff = "1" - case alwaysOn = "2" -} - -// MARK: EnumLocalizedStringConvertible - -extension OpenAIUsageStatus: EnumLocalizedStringConvertible { - var title: String { - switch self { - case .default: - NSLocalizedString( - "service.configuration.openai.usage_status_default.title", - bundle: .main, - comment: "" - ) - case .alwaysOff: - NSLocalizedString( - "service.configuration.openai.usage_status_always_off.title", - bundle: .main, - comment: "" - ) - case .alwaysOn: - NSLocalizedString( - "service.configuration.openai.usage_status_always_on.title", - bundle: .main, - comment: "" - ) - } - } -} - -// MARK: Defaults.Serializable - -extension OpenAIUsageStatus: Defaults.Serializable {} diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationCells.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationCells.swift index b89d898fe..d9cd9a55a 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationCells.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationCells.swift @@ -155,7 +155,7 @@ struct ServiceConfigurationToggleCell: View { ServiceConfigurationPickerCell( titleKey: "service.configuration.openai.usage_status.title", key: .openAIServiceUsageStatus, - values: OpenAIUsageStatus.allCases + values: ServiceUsageStatus.allCases ) ServiceConfigurationToggleCell( diff --git a/Easydict/objc/Service/Model/EZConstKey.h b/Easydict/objc/Service/Model/EZConstKey.h index d2acdf309..4a94c7ed9 100644 --- a/Easydict/objc/Service/Model/EZConstKey.h +++ b/Easydict/objc/Service/Model/EZConstKey.h @@ -17,6 +17,12 @@ static NSString *const EZServiceUsageStatusKey = @"ServiceUsageStatus"; static NSString *const EZTranslationKey = @"Translation"; static NSString *const EZDictionaryKey = @"Dictionary"; static NSString *const EZSentenceKey = @"Sentence"; +static NSString *const EZAvailableModelsKey = @"AvailableModels"; +static NSString *const EZValidModelsKey = @"ValidModels"; +static NSString *const EZModelKey = @"Model"; +static NSString *const EZAPIKey = @"API"; +static NSString *const EZEndpointKey = @"EndPoint"; +static NSString *const EZNameKey = @"Name"; // OpenAI static NSString *const EZOpenAIAPIKey = @"EZOpenAIAPIKey"; diff --git a/Easydict/objc/ViewController/View/ResultView/EZResultView.m b/Easydict/objc/ViewController/View/ResultView/EZResultView.m index 96657c438..957533a72 100644 --- a/Easydict/objc/ViewController/View/ResultView/EZResultView.m +++ b/Easydict/objc/ViewController/View/ResultView/EZResultView.m @@ -267,8 +267,8 @@ - (void)setResult:(EZQueryResult *)result { mm_weakify(self); - if ([self isBaseOpenAIService:result.service]) { - EZBaseOpenAIService *service = (EZBaseOpenAIService *)result.service; + if ([self isLLLStreamService:result.service]) { + EZLLMStreamService *service = (EZLLMStreamService *)result.service; NSString *model = service.model; self.serviceModelButton.title = model; // hoverTitle may be different from normalTitle, fix https://github.com/tisfeng/Easydict/pull/516#issuecomment-2064164503 @@ -314,7 +314,7 @@ - (void)updateConstraints { }]; CGFloat modelButtonWidth = 0; - if ([self isBaseOpenAIService:self.result.service]) { + if ([self isLLLStreamService:self.result.service]) { [self.serviceModelButton sizeToFit]; // 105 is the length of "gpt-4-turbo-preview" modelButtonWidth = MIN(self.serviceModelButton.width, 105 * [self windowWidthRatio]); @@ -429,12 +429,12 @@ - (void)updateArrowButton { }]; } -- (BOOL)isBaseOpenAIService:(EZQueryService *)service { - return [service isKindOfClass:[EZBaseOpenAIService class]]; +- (BOOL)isLLLStreamService:(EZQueryService *)service { + return [service isKindOfClass:[EZLLMStreamService class]]; } - (void)showModelSelectionMenu:(EZButton *)sender { - EZBaseOpenAIService *service = (EZBaseOpenAIService *)self.result.service; + EZLLMStreamService *service = (EZLLMStreamService *)self.result.service; NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Menu"]; for (NSString *model in service.availableModels) { NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:model action:@selector(modelDidSelected:) keyEquivalent:@""]; @@ -445,7 +445,7 @@ - (void)showModelSelectionMenu:(EZButton *)sender { } - (void)modelDidSelected:(NSMenuItem *)sender { - EZBaseOpenAIService *service = (EZBaseOpenAIService *)self.result.service; + EZLLMStreamService *service = (EZLLMStreamService *)self.result.service; if (![service.model isEqualToString:sender.title]) { service.model = sender.title; self.serviceModelButton.title = service.model;