From 67dc0fee576bf64d6459936213c82791e8b7a7dc Mon Sep 17 00:00:00 2001 From: tisfeng Date: Thu, 26 Sep 2024 21:59:53 +0800 Subject: [PATCH] feat: support custom prompt for custom openai service (#679) * feat(UI): add custom prompt toggle and TextEditor * feat: support custom prompt for custom openai service * feat: add detailText for TextEditorCell and ToggleCell * refactor: improve class and variable naming * feat: support system prompt * feat: set custom prompt alignment to leading * refactor: rewrite by cursor claude-3.5-sonnet * fix: update Localizable.xcstrings * fix: add padding for SecureTextField * fix: update Localizable.xcstrings * fix: system prompt should be placed before user prompt * fix: warning @unchecked Sendable * fix: task qos warning * feat: show custom prompt TextEditor only enabled * fix: footnote will be stretched when selected * fix: update Localizable.xcstrings * fix: replace HStack with VStack in TextEditorCell --- Easydict.xcodeproj/project.pbxproj | 8 +- Easydict/App/Localizable.xcstrings | 315 +++++++++++++++--- .../Configuration/DefaultsStoredKey.swift | 3 + .../Localization/LocalizedBundle.swift | 2 +- Easydict/Swift/Model/Error/QueryError.swift | 2 +- .../CustomOpenAI/CustomOpenAIService.swift | 52 ++- .../LLMStreamService+Configuration.swift | 4 + .../Service/OpenAI/LLMStreamService.swift | 27 ++ Easydict/Swift/Service/OpenAI/Prompt.swift | 4 +- .../Utility/AppleScript/AppleScriptTask.swift | 2 +- .../AliService+ConfigurableService.swift | 6 +- .../BaiduTranslate+ConfigurableService.swift | 6 +- .../BingService+ConfigurableService.swift | 2 +- .../CaiyunService+ConfigurableService.swift | 2 +- .../DeepLTranslate+ConfigurableService.swift | 6 +- ...iuTransTranslate+ConfigurableService.swift | 2 +- .../StreamConfigurationView.swift | 55 ++- .../TencentService+ConfigurableService.swift | 4 +- .../VolcanoService+ConfigurableService.swift | 4 +- .../SecureTextField.swift | 1 + ...gurationCells.swift => ServiceCells.swift} | 59 +++- ...erviceConfigurationSecretSectionView.swift | 2 +- .../TextEditorCell.swift | 155 ++++++--- 23 files changed, 599 insertions(+), 124 deletions(-) rename Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/{ServiceConfigurationCells.swift => ServiceCells.swift} (74%) diff --git a/Easydict.xcodeproj/project.pbxproj b/Easydict.xcodeproj/project.pbxproj index 50bceaade..b28111dad 100644 --- a/Easydict.xcodeproj/project.pbxproj +++ b/Easydict.xcodeproj/project.pbxproj @@ -256,7 +256,7 @@ 0AC8A83F2B689E68006DA5CC /* ServiceSecretConfigreValidatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC8A83E2B689E68006DA5CC /* ServiceSecretConfigreValidatable.swift */; }; 0AC8A8412B695480006DA5CC /* DeepLTranslate+ConfigurableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC8A8402B695480006DA5CC /* DeepLTranslate+ConfigurableService.swift */; }; 0AC8A8432B6957B0006DA5CC /* BingService+ConfigurableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC8A8422B6957B0006DA5CC /* BingService+ConfigurableService.swift */; }; - 0AC8A8452B6A4D97006DA5CC /* ServiceConfigurationCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC8A8442B6A4D97006DA5CC /* ServiceConfigurationCells.swift */; }; + 0AC8A8452B6A4D97006DA5CC /* ServiceCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC8A8442B6A4D97006DA5CC /* ServiceCells.swift */; }; 0AC8A8472B6A4E3F006DA5CC /* ServiceConfigurationSecretSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC8A8462B6A4E3F006DA5CC /* ServiceConfigurationSecretSectionView.swift */; }; 0AC8A84F2B6DFDD4006DA5CC /* SettingsAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 0AC8A84E2B6DFDD4006DA5CC /* SettingsAccess */; }; 17BCAEF72B0DFF9000A7D372 /* EZNiuTransTranslateResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 17BCAEF52B0DFF9000A7D372 /* EZNiuTransTranslateResponse.m */; }; @@ -762,7 +762,7 @@ 0AC8A83E2B689E68006DA5CC /* ServiceSecretConfigreValidatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceSecretConfigreValidatable.swift; sourceTree = ""; }; 0AC8A8402B695480006DA5CC /* DeepLTranslate+ConfigurableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeepLTranslate+ConfigurableService.swift"; sourceTree = ""; }; 0AC8A8422B6957B0006DA5CC /* BingService+ConfigurableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BingService+ConfigurableService.swift"; sourceTree = ""; }; - 0AC8A8442B6A4D97006DA5CC /* ServiceConfigurationCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceConfigurationCells.swift; sourceTree = ""; }; + 0AC8A8442B6A4D97006DA5CC /* ServiceCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceCells.swift; sourceTree = ""; }; 0AC8A8462B6A4E3F006DA5CC /* ServiceConfigurationSecretSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceConfigurationSecretSectionView.swift; sourceTree = ""; }; 17BCAEF32B0DFF9000A7D372 /* EZNiuTransTranslateResponse.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = EZNiuTransTranslateResponse.h; sourceTree = ""; }; 17BCAEF42B0DFF9000A7D372 /* EZNiuTransTranslate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = EZNiuTransTranslate.h; sourceTree = ""; }; @@ -2520,7 +2520,7 @@ 0AC8A83E2B689E68006DA5CC /* ServiceSecretConfigreValidatable.swift */, 0AC8A83C2B6685EE006DA5CC /* SecureTextField.swift */, 0AC8A8462B6A4E3F006DA5CC /* ServiceConfigurationSecretSectionView.swift */, - 0AC8A8442B6A4D97006DA5CC /* ServiceConfigurationCells.swift */, + 0AC8A8442B6A4D97006DA5CC /* ServiceCells.swift */, 0357B9592C04387D00A48CB0 /* TextEditorCell.swift */, 035F9CCE2C529A04005D1C9A /* ServiceAPIType.swift */, ); @@ -3143,7 +3143,7 @@ DC46DF802B4417B900DEAE3E /* Configuration.swift in Sources */, 036E7D7B293F4FC8002675DF /* EZOpenLinkButton.m in Sources */, 03832F542B5F6BE200D0DC64 /* AdvancedTab.swift in Sources */, - 0AC8A8452B6A4D97006DA5CC /* ServiceConfigurationCells.swift in Sources */, + 0AC8A8452B6A4D97006DA5CC /* ServiceCells.swift in Sources */, 276742092B3DC230002A2C75 /* AboutTab.swift in Sources */, 03714B902C17FD9400BB4459 /* Notification.Name.swift in Sources */, 03008B2E2941956D0062B821 /* EZURLSchemeHandler.m in Sources */, diff --git a/Easydict/App/Localizable.xcstrings b/Easydict/App/Localizable.xcstrings index a37fbc48b..52bcc64a3 100644 --- a/Easydict/App/Localizable.xcstrings +++ b/Easydict/App/Localizable.xcstrings @@ -5645,19 +5645,19 @@ "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Obnoviť" + "value" : "Odstrániť" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重置" + "value" : "删除" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "重設" + "value" : "刪除" } } } @@ -5866,6 +5866,74 @@ } } }, + "service.configuration.openai.enable_custom_prompt.footnote" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When custom prompt is enabled, the default translation, sentence analysis, and dictionary prompt will be disabled." + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "When custom prompt is enabled, the default translation, sentence analysis, and dictionary prompt will be disabled." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keď je povolený vlastný prompt, bude vypnutý predvolený preklad, analýza věty a slovník." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开启自定义 Prompt 时,会禁用默认的翻译、句子分析和查单词 Prompt。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟自訂提示時,會禁用預設的翻譯、句子分析和查單字提示。" + } + } + } + }, + "service.configuration.openai.enable_custom_prompt.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable Custom Prompt" + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable Custom Prompt" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Povoliť vlastný prompt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用自定义 Prompt" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用自訂 Prompt" + } + } + } + }, "service.configuration.openai.endpoint.placeholder" : { "localizations" : { "en" : { @@ -6002,6 +6070,74 @@ } } }, + "service.configuration.openai.system_prompt.placeholder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You are a helpful translation assistant." + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "You are a helpful translation assistant." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jste užitečná pomocí překladu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你是一个有用的翻译助手。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你是一個有用的翻譯助手。" + } + } + } + }, + "service.configuration.openai.system_prompt.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Prompt" + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Prompt" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systémový prompt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统 Prompt" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "系統 Prompt" + } + } + } + }, "service.configuration.openai.translation.title" : { "localizations" : { "en" : { @@ -6172,104 +6308,207 @@ } } }, - "service.configuration.tencent.secret_id.title" : { + "service.configuration.openai.user_prompt.footnote" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reset" + "value" : "Supports the following environment variables:\n\n${{queryText}}: Query text\n${{queryFromLanguage}}: Query language\n${{queryTargetLanguage}}: Target language\n${{firstLanguage}}: User's first language\n\nExample: Translate the following ${{queryFromLanguage}} text into ${{queryTargetLanguage}}: ${{queryText}}" } }, "en-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Reset" + "value" : "Supports the following environment variables:\n\n${{queryText}}: Query text\n${{queryFromLanguage}}: Query language\n${{queryTargetLanguage}}: Target language\n${{firstLanguage}}: User's first language\n\nExample: Translate the following ${{queryFromLanguage}} text into ${{queryTargetLanguage}}: ${{queryText}}" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Obnoviť" + "value" : "Podporuje nasledujúce premenné prostredia:\n\n${{queryText}}: Text dotazu\n${{queryFromLanguage}}: Jazyk dotazu\n${{queryTargetLanguage}}: Cieľový jazyk\n${{firstLanguage}}: Prvý jazyk používateľa\n\nPríklad: Preložte nasledujúci ${{queryFromLanguage}} text do ${{queryTargetLanguage}}: ${{queryText}}" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重置" + "value" : "支持以下环境变量:\n\n${{queryText}}: 查询文本\n${{queryFromLanguage}}: 查询语言\n${{queryTargetLanguage}}: 目标语言\n${{firstLanguage}}: 用户第一语言\n\n例如:Translate the following ${{queryFromLanguage}} text into ${{queryTargetLanguage}}: ${{queryText}}" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "重設" + "value" : "支援以下環境變數:\n\n${{queryText}}: 查詢文本\n${{queryFromLanguage}}: 查詢語言\n${{queryTargetLanguage}}: 目標語言\n${{firstLanguage}}: 使用者第一語言\n\n例如:Translate the following ${{queryFromLanguage}} text into ${{queryTargetLanguage}}: ${{queryText}}" } } } + }, - "service.configuration.tencent.secret_id.title" : { + "service.configuration.openai.user_prompt.placeholder" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Secret Id" + "value" : "Translate the following ${{queryFromLanguage}} text into ${{queryTargetLanguage}}: ${{queryText}}" } }, "en-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Secret Id" + "value" : "Translate the following ${{queryFromLanguage}} text into ${{queryTargetLanguage}}: ${{queryText}}" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Tajné ID" + "value" : "Preložte nasledujúci ${{queryFromLanguage}} text do ${{queryTargetLanguage}}: ${{queryText}}" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Secret Id" + "value" : "将下面 ${{queryFromLanguage}} 的文本翻译成 ${{queryTargetLanguage}}: ${{queryText}}" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Secret Id" + "value" : "將下面 ${{queryFromLanguage}} 的文本翻譯成 ${{queryTargetLanguage}}: ${{queryText}}" } } } }, - "service.configuration.tencent.secret_key.title" : { + "service.configuration.openai.user_prompt.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Secret Key" + "value" : "User Prompt" } }, "en-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Secret Key" + "value" : "User Prompt" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Tajný kľúč" + "value" : "Uživatelský prompt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Secret Key" + "value" : "用户 Prompt" } }, "zh-Hant" : { "stringUnit" : { + "state" : "translated", + "value" : "用戶 Prompt" + } + } + } + }, + "service.configuration.tencent.secret_id.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret ID" + }, + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret ID" + }, + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret ID" + }, + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret ID" + }, + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret ID" + }, + } + } + }, + "service.configuration.tencent.secret_key.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret Key" + }, + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret Key" + }, + }, + "sk" : { + "stringUnit" : { "state" : "translated", "value" : "Secret Key" + }, + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret Key" + }, + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret Key" + }, + } + } + }, + "service.configuration.validate" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Validate" + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Validate" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overenie" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "验证" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "驗證" } } } @@ -6649,104 +6888,104 @@ } } }, - "setting.general.advance.header.http_server" : { + "setting.general.advance.footer.query_text_processing_desc" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "HTTP Server" + "value" : "Note: This will not modify the original text, only process the query content." } }, "en-CA" : { "stringUnit" : { "state" : "translated", - "value" : "HTTP Server" + "value" : "Note: This will not modify the original text, only process the query content." } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "HTTP Server" + "value" : "Poznámka: Toto nebude meniť pôvodný text, len spracuje text dotazu." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "HTTP 服务器" + "value" : "注意,这不会修改原文,仅对查询内容进行处理" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "HTTP 伺服器" + "value" : "注意,這不會修改原文,僅對查詢內容進行處理" } } } }, - "setting.general.advance.header.query_text_processing" : { + "setting.general.advance.header.http_server" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Query Text Processing" + "value" : "HTTP Server" } }, "en-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Query Text Processing" + "value" : "HTTP Server" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Spracovanie textu dopytu" + "value" : "HTTP Server" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询文本处理" + "value" : "HTTP 服务器" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢文本處理" + "value" : "HTTP 伺服器" } } } }, - "setting.general.advance.footer.query_text_processing_desc" : { + "setting.general.advance.header.query_text_processing" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Note: This will not modify the original text, only process the query content." + "value" : "Query Text Processing" } }, "en-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Note: This will not modify the original text, only process the query content." + "value" : "Query Text Processing" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Poznámka: Toto nebude meniť pôvodný text, len spracuje text dotazu." + "value" : "Spracovanie textu dopytu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "注意,这不会修改原文,仅对查询内容进行处理" + "value" : "查询文本处理" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "注意,這不會修改原文,僅對查詢內容進行處理" + "value" : "查詢文本處理" } } } diff --git a/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift b/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift index 7a09edea0..9cb925bef 100644 --- a/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift +++ b/Easydict/Swift/Feature/Configuration/DefaultsStoredKey.swift @@ -48,6 +48,9 @@ enum StoredKey: String { case apiKey = "API" case endpoint = "EndPoint" case name + case enableCustomPrompt + case systemPrompt + case userPrompt } extension String { diff --git a/Easydict/Swift/Feature/Localization/LocalizedBundle.swift b/Easydict/Swift/Feature/Localization/LocalizedBundle.swift index 8e5e3b23d..31c8bb7d2 100644 --- a/Easydict/Swift/Feature/Localization/LocalizedBundle.swift +++ b/Easydict/Swift/Feature/Localization/LocalizedBundle.swift @@ -9,7 +9,7 @@ import Foundation @objc(EZLocalizedBundle) -class LocalizedBundle: Bundle { +class LocalizedBundle: Bundle, @unchecked Sendable { override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String { I18nHelper.shared.localizedBundle.localizedString(forKey: key, value: value, table: tableName) } diff --git a/Easydict/Swift/Model/Error/QueryError.swift b/Easydict/Swift/Model/Error/QueryError.swift index 9d0dd15ce..aa59d3061 100644 --- a/Easydict/Swift/Model/Error/QueryError.swift +++ b/Easydict/Swift/Model/Error/QueryError.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - QueryError @objcMembers -public class QueryError: NSError, LocalizedError { +public class QueryError: NSError, LocalizedError, @unchecked Sendable { // MARK: Lifecycle public init(type: ErrorType, code: Int = -1, message: String) { diff --git a/Easydict/Swift/Service/CustomOpenAI/CustomOpenAIService.swift b/Easydict/Swift/Service/CustomOpenAI/CustomOpenAIService.swift index 027a5ba90..98ab9317f 100644 --- a/Easydict/Swift/Service/CustomOpenAI/CustomOpenAIService.swift +++ b/Easydict/Swift/Service/CustomOpenAI/CustomOpenAIService.swift @@ -42,7 +42,57 @@ class CustomOpenAIService: BaseOpenAIService { override func configurationListItems() -> Any { StreamConfigurationView( service: self, - showNameSection: true + showNameSection: true, + showCustomPromptSection: true ) } + + override func chatMessageDicts(_ chatQuery: ChatQueryParam) -> [[String: String]] { + if enableCustomPrompt { + let systemPrompt = replaceCustomPromptWithVariable(systemPrompt) + let userPrompt = replaceCustomPromptWithVariable(userPrompt) + return [ + chatMessage(role: .system, content: systemPrompt), + chatMessage(role: .user, content: userPrompt), + ] + } + return super.chatMessageDicts(chatQuery) + } + + /** + Convert custom prompt $xxx to variable. + + e.g. + prompt: Translate the following ${{queryFromLanguage}} text into ${{queryTargetLanguage}}: ${{queryText}} + runtime prompt: Translate the following English text into Simplified-Chinese: Hello, world + + ${{queryFromLanguage}} --> queryModel.queryFromLanguage.rawValue + ${{queryTargetLanguage}} --> queryModel.queryTargetLanguage.rawValue + ${{queryText}} --> queryModel.queryText + ${{firstLanguage}} --> Configuration.shared.firstLanguage.rawValue + */ + func replaceCustomPromptWithVariable(_ prompt: String) -> String { + var runtimePrompt = prompt + if runtimePrompt.isEmpty { + return queryModel.queryText + } + + runtimePrompt = runtimePrompt.replacingOccurrences( + of: "${{queryFromLanguage}}", + with: queryModel.queryFromLanguage.rawValue + ) + runtimePrompt = runtimePrompt.replacingOccurrences( + of: "${{queryTargetLanguage}}", + with: queryModel.queryTargetLanguage.rawValue + ) + runtimePrompt = runtimePrompt.replacingOccurrences( + of: "${{queryText}}", + with: queryModel.queryText + ) + runtimePrompt = runtimePrompt.replacingOccurrences( + of: "${{firstLanguage}}", + with: Configuration.shared.firstLanguage.rawValue + ) + return runtimePrompt + } } diff --git a/Easydict/Swift/Service/OpenAI/LLMStreamService+Configuration.swift b/Easydict/Swift/Service/OpenAI/LLMStreamService+Configuration.swift index 47b463a6f..10a02e6c2 100644 --- a/Easydict/Swift/Service/OpenAI/LLMStreamService+Configuration.swift +++ b/Easydict/Swift/Service/OpenAI/LLMStreamService+Configuration.swift @@ -90,6 +90,10 @@ extension LLMStreamService { defaultsKey(key, serviceType: serviceType(), id: uuid, defaultValue: defaultValue) } + func boolDefaultsKey(_ key: StoredKey, defaultValue: Bool) -> Defaults.Key { + defaultsKey(key, serviceType: serviceType(), id: uuid, defaultValue: defaultValue) + } + func serviceDefaultsKey(_ key: StoredKey, defaultValue: T) -> Defaults.Key { defaultsKey(key, serviceType: serviceType(), id: uuid, defaultValue: defaultValue) } diff --git a/Easydict/Swift/Service/OpenAI/LLMStreamService.swift b/Easydict/Swift/Service/OpenAI/LLMStreamService.swift index bf9d11517..04e92c38f 100644 --- a/Easydict/Swift/Service/OpenAI/LLMStreamService.swift +++ b/Easydict/Swift/Service/OpenAI/LLMStreamService.swift @@ -125,6 +125,33 @@ public class LLMStreamService: QueryService { stringDefaultsKey(.supportedModels, defaultValue: supportedModels(from: defaultModels)) } + var enableCustomPromptKey: Defaults.Key { + boolDefaultsKey(.enableCustomPrompt, defaultValue: false) + } + + var enableCustomPrompt: Bool { + get { Defaults[enableCustomPromptKey] } + set { Defaults[enableCustomPromptKey] = newValue } + } + + var userPromptKey: Defaults.Key { + stringDefaultsKey(.userPrompt, defaultValue: "") + } + + var userPrompt: String { + get { Defaults[userPromptKey] } + set { Defaults[userPromptKey] = newValue } + } + + var systemPromptKey: Defaults.Key { + stringDefaultsKey(.systemPrompt, defaultValue: "") + } + + var systemPrompt: String { + get { Defaults[systemPromptKey] } + set { Defaults[systemPromptKey] = newValue } + } + /// Just getter, we should set supportedModels and get validModels. var validModels: [String] { Defaults[validModelsKey] diff --git a/Easydict/Swift/Service/OpenAI/Prompt.swift b/Easydict/Swift/Service/OpenAI/Prompt.swift index cf356f524..1292de44d 100644 --- a/Easydict/Swift/Service/OpenAI/Prompt.swift +++ b/Easydict/Swift/Service/OpenAI/Prompt.swift @@ -749,14 +749,14 @@ extension LLMStreamService { } } - private func chatMessage(role: ChatRole, content: String) -> [String: String] { + func chatMessage(role: ChatRole, content: String) -> [String: String] { [ "role": role.rawValue, "content": content, ] } - private func chatMessagePair(userContent: String, assistantContent: String) -> [[String: String]] { + func chatMessagePair(userContent: String, assistantContent: String) -> [[String: String]] { [ chatMessage(role: .user, content: userContent), chatMessage(role: .assistant, content: assistantContent), diff --git a/Easydict/Swift/Utility/AppleScript/AppleScriptTask.swift b/Easydict/Swift/Utility/AppleScript/AppleScriptTask.swift index f49623917..ec18d6564 100644 --- a/Easydict/Swift/Utility/AppleScript/AppleScriptTask.swift +++ b/Easydict/Swift/Utility/AppleScript/AppleScriptTask.swift @@ -57,7 +57,7 @@ class AppleScriptTask: NSObject { @discardableResult static func runAppleScript(_ appleScript: String) async throws -> String? { - try await Task.detached { + try await Task.detached(priority: .userInitiated) { var errorInfo: NSDictionary? let script = NSAppleScript(source: appleScript) guard let output = script?.executeAndReturnError(&errorInfo) else { diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/AliService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/AliService+ConfigurableService.swift index c9be8593e..facc09031 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/AliService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/AliService+ConfigurableService.swift @@ -11,16 +11,16 @@ import SwiftUI extension AliService { override func configurationListItems() -> Any? { ServiceConfigurationSecretSectionView(service: self, observeKeys: [.aliAccessKeyId, .aliAccessKeySecret]) { - ServiceConfigurationPickerCell( + StaticPickerCell( titleKey: "service.configuration.api_picker.title", key: .aliServiceApiTypeKey, values: ServiceAPIType.allCases ) - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.ali.access_key_id.title", key: .aliAccessKeyId ) - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.ali.access_key_secret.title", key: .aliAccessKeySecret ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BaiduTranslate+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BaiduTranslate+ConfigurableService.swift index 4c2f4cdab..92a6e9256 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BaiduTranslate+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BaiduTranslate+ConfigurableService.swift @@ -16,18 +16,18 @@ extension EZBaiduTranslate { service: self, observeKeys: [.baiduAppId, .baiduSecretKey] ) { - ServiceConfigurationPickerCell( + StaticPickerCell( titleKey: "service.configuration.api_picker.title", key: .baiduServiceApiTypeKey, values: ServiceAPIType.allCases ) - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.baidu.app_id.title", key: .baiduAppId ) - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.baidu.secret_key.title", key: .baiduSecretKey ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BingService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BingService+ConfigurableService.swift index 2719aacc9..2e2c81aa1 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BingService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/BingService+ConfigurableService.swift @@ -11,7 +11,7 @@ import SwiftUI extension EZBingService { open override func configurationListItems() -> Any? { ServiceConfigurationSecretSectionView(service: self, observeKeys: [.bingCookieKey]) { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.bing.cookie.title", key: .bingCookieKey ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/CaiyunService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/CaiyunService+ConfigurableService.swift index 9623ad922..3163c2866 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/CaiyunService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/CaiyunService+ConfigurableService.swift @@ -14,7 +14,7 @@ import SwiftUI extension CaiyunService { public override func configurationListItems() -> Any? { ServiceConfigurationSecretSectionView(service: self, observeKeys: [.caiyunToken]) { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.caiyun.token.title", key: .caiyunToken ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/DeepLTranslate+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/DeepLTranslate+ConfigurableService.swift index de79178c3..aa5b2cf0c 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/DeepLTranslate+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/DeepLTranslate+ConfigurableService.swift @@ -13,18 +13,18 @@ import SwiftUI extension EZDeepLTranslate { open override func configurationListItems() -> Any? { ServiceConfigurationSecretSectionView(service: self, observeKeys: [.deepLAuth]) { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.deepl.auth_key.title", key: .deepLAuth ) - ServiceConfigurationInputCell( + InputCell( textFieldTitleKey: "service.configuration.deepl.endpoint.title", key: .deepLTranslateEndPointKey, placeholder: "service.configuration.deepl.endpoint.placeholder" ) - ServiceConfigurationPickerCell( + StaticPickerCell( titleKey: "service.configuration.deepl.translation.title", key: .deepLTranslation, values: DeepLAPIUsagePriority.allCases diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/NiuTransTranslate+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/NiuTransTranslate+ConfigurableService.swift index 881e34dab..0d52b4d4d 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/NiuTransTranslate+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/NiuTransTranslate+ConfigurableService.swift @@ -14,7 +14,7 @@ import SwiftUI extension EZNiuTransTranslate { open override func configurationListItems() -> Any? { ServiceConfigurationSecretSectionView(service: self, observeKeys: [.niuTransAPIKey]) { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.niutrans.api_key.title", key: .niuTransAPIKey ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift index 56d53a53c..5fa144115 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift @@ -23,6 +23,7 @@ struct StreamConfigurationView: View { showEndpointSection: Bool = true, showSupportedModelsSection: Bool = true, showUsedModelSection: Bool = true, + showCustomPromptSection: Bool = false, showTranslationToggle: Bool = true, showSentenceToggle: Bool = true, showDictionaryToggle: Bool = true, @@ -35,6 +36,7 @@ struct StreamConfigurationView: View { self.showEndpointSection = showEndpointSection self.showSupportedModelsSection = showSupportedModelsSection self.showUsedModelSection = showUsedModelSection + self.showCustomPromptSection = showCustomPromptSection self.showTranslationToggle = showTranslationToggle self.showSentenceToggle = showSentenceToggle self.showDictionaryToggle = showDictionaryToggle @@ -46,6 +48,8 @@ struct StreamConfigurationView: View { #if DEBUG self.isEditable = isEditable || Defaults[.enableBetaFeature] #endif + + self._showCustomPromptTextEditor = .init(service.enableCustomPromptKey) } // MARK: Internal @@ -57,6 +61,7 @@ struct StreamConfigurationView: View { let showEndpointSection: Bool let showSupportedModelsSection: Bool let showUsedModelSection: Bool + let showCustomPromptSection: Bool let showTranslationToggle: Bool let showSentenceToggle: Bool let showDictionaryToggle: Bool @@ -64,13 +69,16 @@ struct StreamConfigurationView: View { var isEditable = true + // show system prompt and user prompt, according to service.enableCustomPrompt + @Default var showCustomPromptTextEditor: Bool + var body: some View { ServiceConfigurationSecretSectionView( service: service, observeKeys: service.observeKeys ) { if showNameSection { - ServiceConfigurationInputCell( + InputCell( textFieldTitleKey: "service.configuration.custom_openai.name.title", key: service.nameKey, placeholder: "custom_openai", @@ -79,7 +87,7 @@ struct StreamConfigurationView: View { } if showAPIKeySection { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.openai.api_key.title", key: service.apiKeyKey, placeholder: service.apiKeyPlaceholder @@ -87,7 +95,7 @@ struct StreamConfigurationView: View { } if showEndpointSection { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.openai.endpoint.title", key: service.endpointKey, placeholder: service.endpointPlaceholder, @@ -99,7 +107,9 @@ struct StreamConfigurationView: View { TextEditorCell( titleKey: "service.configuration.custom_openai.supported_models.title", storedValueKey: service.supportedModelsKey, - placeholder: "service.configuration.custom_openai.model.placeholder" + placeholder: "service.configuration.custom_openai.model.placeholder", + minHeight: 55, + maxHeight: 100 ).disabled(!isEditable) } @@ -111,27 +121,56 @@ struct StreamConfigurationView: View { ) } + if showCustomPromptSection { + ToggleCell( + titleKey: "service.configuration.openai.enable_custom_prompt.title", + key: service.enableCustomPromptKey, + footnote: "service.configuration.openai.enable_custom_prompt.footnote" + ) + + if showCustomPromptTextEditor { + VStack(spacing: 5) { + // system prompt + TextEditorCell( + titleKey: "service.configuration.openai.system_prompt.title", + storedValueKey: service.systemPromptKey, + placeholder: "service.configuration.openai.system_prompt.placeholder", + height: 100 + ) + + // user prompt + TextEditorCell( + titleKey: "service.configuration.openai.user_prompt.title", + storedValueKey: service.userPromptKey, + placeholder: "service.configuration.openai.user_prompt.placeholder", + footnote: "service.configuration.openai.user_prompt.footnote", + height: 120 + ) + } + } + } + if showTranslationToggle { - ServiceConfigurationToggleCell( + StringToggleCell( titleKey: "service.configuration.openai.translation.title", key: service.translationKey ) } if showSentenceToggle { - ServiceConfigurationToggleCell( + StringToggleCell( titleKey: "service.configuration.openai.sentence.title", key: service.sentenceKey ) } if showDictionaryToggle { - ServiceConfigurationToggleCell( + StringToggleCell( titleKey: "service.configuration.openai.dictionary.title", key: service.dictionaryKey ) } if showUsageStatusPicker { - ServiceConfigurationPickerCell( + StaticPickerCell( titleKey: "service.configuration.openai.usage_status.title", key: service.serviceUsageStatusKey, values: ServiceUsageStatus.allCases diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/TencentService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/TencentService+ConfigurableService.swift index 73014326a..a3daec3e6 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/TencentService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/TencentService+ConfigurableService.swift @@ -12,11 +12,11 @@ import SwiftUI extension TencentService { public override func configurationListItems() -> Any? { ServiceConfigurationSecretSectionView(service: self, observeKeys: [.tencentSecretId, .tencentSecretKey]) { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.tencent.secret_id.title", key: .tencentSecretId ) - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.tencent.secret_key.title", key: .tencentSecretKey ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/VolcanoService+ConfigurableService.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/VolcanoService+ConfigurableService.swift index ee423b177..c009a8622 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/VolcanoService+ConfigurableService.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/VolcanoService+ConfigurableService.swift @@ -15,11 +15,11 @@ extension VolcanoService { service: self, observeKeys: [.volcanoAccessKeyID, .volcanoSecretAccessKey] ) { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.volcano.access_id.title", key: .volcanoAccessKeyID ) - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.volcano.secret_key.title", key: .volcanoSecretAccessKey ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/SecureTextField.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/SecureTextField.swift index f91b5cf1b..c706f874b 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/SecureTextField.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/SecureTextField.swift @@ -37,6 +37,7 @@ struct SecureTextField: View { Image(systemName: showText ? "eye.slash.fill" : "eye.fill") } } + .padding(10) .onChange(of: focus) { newValue in // if the PasswordField is focused externally, then make sure the correct field is actually focused if newValue != nil { diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationCells.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceCells.swift similarity index 74% rename from Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationCells.swift rename to Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceCells.swift index 426a41700..d1dc9dba3 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationCells.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceCells.swift @@ -10,9 +10,9 @@ import Combine import Defaults import SwiftUI -// MARK: - ServiceConfigurationSecureInputCell +// MARK: - SecureInputCell -struct ServiceConfigurationSecureInputCell: View { +struct SecureInputCell: View { // MARK: Lifecycle init( @@ -39,9 +39,9 @@ struct ServiceConfigurationSecureInputCell: View { } } -// MARK: - ServiceConfigurationInputCell +// MARK: - InputCell -struct ServiceConfigurationInputCell: View { +struct InputCell: View { // MARK: Lifecycle init( @@ -81,9 +81,9 @@ struct ServiceConfigurationInputCell: View { } } -// MARK: - ServiceConfigurationPickerCell +// MARK: - StaticPickerCell -struct ServiceConfigurationPickerCell: View { +struct StaticPickerCell: View { // MARK: Lifecycle init(titleKey: LocalizedStringKey, key: Defaults.Key, values: [T]) { @@ -159,9 +159,11 @@ class ToggleViewModel: ObservableObject { } } -// MARK: - ServiceConfigurationToggleCell +// MARK: - StringToggleCell -struct ServiceConfigurationToggleCell: View { +/// Since we previously used String for the toggle value, we have to connect String <--> Bool with a viewModel. +/// For new feature, we should use ToggleCell instead of StringToggleCell. +struct StringToggleCell: View { // MARK: Lifecycle init(titleKey: LocalizedStringKey, key: Defaults.Key) { @@ -173,11 +175,46 @@ struct ServiceConfigurationToggleCell: View { let titleKey: LocalizedStringKey - // Since we previously used String for the toggle value, we have to connect String <--> Bool with a viewModel. - @ObservedObject var viewModel: ToggleViewModel - var body: some View { Toggle(titleKey, isOn: $viewModel.isOn) .padding(10.0) } + + // MARK: Private + + @ObservedObject private var viewModel: ToggleViewModel +} + +// MARK: - ToggleCell + +struct ToggleCell: View { + // MARK: Lifecycle + + init(titleKey: LocalizedStringKey, key: Defaults.Key, footnote: LocalizedStringKey? = nil) { + self.titleKey = titleKey + self.footnote = footnote + self._value = .init(key) + } + + // MARK: Internal + + let titleKey: LocalizedStringKey + let footnote: LocalizedStringKey? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle(titleKey, isOn: $value) + + if let footnote { + Text(footnote) + .font(.footnote) + .foregroundColor(.gray) + } + } + .padding(10.0) + } + + // MARK: Private + + @Default private var value: Bool } diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationSecretSectionView.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationSecretSectionView.swift index 6c007fadd..8abd508fc 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationSecretSectionView.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ServiceConfigurationSecretSectionView.swift @@ -201,7 +201,7 @@ private class ServiceValidationViewModel: ObservableObject { #Preview { ServiceConfigurationSecretSectionView(service: EZBingService(), observeKeys: [.bingCookieKey]) { - ServiceConfigurationSecureInputCell( + SecureInputCell( textFieldTitleKey: "service.configuration.bing.cookie.title", key: .bingCookieKey ) diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/TextEditorCell.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/TextEditorCell.swift index f5f4fed34..86550cc06 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/TextEditorCell.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/TextEditorCell.swift @@ -17,10 +17,20 @@ struct TextEditorCell: View { init( titleKey: LocalizedStringKey, storedValueKey: Defaults.Key, - placeholder: LocalizedStringKey + placeholder: LocalizedStringKey? = nil, + alignment: TextAlignment = .leading, + footnote: LocalizedStringKey? = nil, + minHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + height: CGFloat? = nil ) { self.titleKey = titleKey self.placeholder = placeholder + self.alignment = alignment + self.footnote = footnote + self.minHeight = minHeight + self.maxHeight = maxHeight + self.height = height _value = .init(storedValueKey) } @@ -28,61 +38,126 @@ struct TextEditorCell: View { let titleKey: LocalizedStringKey @Default var value: String - let placeholder: LocalizedStringKey + let placeholder: LocalizedStringKey? + let alignment: TextAlignment + let footnote: LocalizedStringKey? + let minHeight: CGFloat? + let maxHeight: CGFloat? + let height: CGFloat? var body: some View { - HStack(alignment: .center, spacing: 20) { - Text(titleKey) - - TrailingTextEditorWithPlaceholder(text: $value, placeholder: placeholder) - .padding(.horizontal, 3) - .padding(.top, 5) - .padding(.bottom, 7) - .font(.body) - .lineSpacing(5) - .scrollContentBackground(.hidden) // Refer https://stackoverflow.com/a/62848618/8378840 - .scrollIndicators(.hidden) - .background(Color.clear) - .clipShape(corner) - .overlay(alignment: .center, content: { - corner.stroke(Color(NSColor.separatorColor), lineWidth: 1) - }) - .frame(minHeight: 55, maxHeight: 200) // min height is two lines, for English placeholder. + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 15) { + Text(titleKey) + textEditor + } + footnoteView } .padding(10) } // MARK: Private - private let corner = RoundedRectangle(cornerRadius: 5) + private var textEditor: some View { + TextEditorWithPlaceholder(text: $value, placeholder: placeholder, alignment: alignment) + .padding(.horizontal, 3) + .padding(.vertical, 5) + .font(.body) + .lineSpacing(5) + .scrollContentBackground(.hidden) + .scrollIndicators(.hidden) + .background(Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color(NSColor.separatorColor), lineWidth: 1)) + .frame(minHeight: minHeight, maxHeight: maxHeight) + .frame(height: height) + } + + @ViewBuilder + private var footnoteView: some View { + if let footnote = footnote { + Text(footnote) + .font(.footnote) + .foregroundStyle(.gray) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } } -// MARK: - TrailingTextEditorWithPlaceholder +// MARK: - TextEditorWithPlaceholder + +struct TextEditorWithPlaceholder: View { + // MARK: Lifecycle + + init( + text: Binding, + placeholder: LocalizedStringKey? = nil, + alignment: TextAlignment = .leading, + font: Font = .body, + lineSpacing: CGFloat = 3 + ) { + self._text = text + self.placeholder = placeholder + self.alignment = alignment + self.font = font + self.lineSpacing = lineSpacing + self._placeholderAlignment = State(initialValue: alignment == .leading ? .topLeading : .topTrailing) + self._textAlignment = State(initialValue: alignment) + } + + // MARK: Internal -struct TrailingTextEditorWithPlaceholder: View { @Binding var text: String let placeholder: LocalizedStringKey? - @State var oneLineAlignment: Alignment = .topTrailing + let alignment: TextAlignment + let font: Font + let lineSpacing: CGFloat var body: some View { - ZStack(alignment: oneLineAlignment) { - if let placeholder = placeholder, text.isEmpty { - Text(placeholder) - .font(.body) - .foregroundStyle(Color(NSColor.placeholderTextColor)) - .padding(.horizontal, 5) - .background(GeometryReader { geometry in - Color.clear.onAppear { - // 22 is one line height, if placeholder is more than one line, always set alignment to .leading - if geometry.size.height > 22 { - oneLineAlignment = .topLeading - } - } - }) - } - + ZStack(alignment: placeholderAlignment) { + placeholderView TextEditor(text: $text) - .multilineTextAlignment(.trailing) + .font(font) + .lineSpacing(lineSpacing) + .multilineTextAlignment(textAlignment) } + .onAppear(perform: updateAlignments) + .onChange(of: text, perform: { _ in updateAlignments() }) + } + + // MARK: Private + + @State private var placeholderAlignment: Alignment + @State private var textAlignment: TextAlignment + + @ViewBuilder + private var placeholderView: some View { + if let placeholder = placeholder, text.isEmpty { + Text(placeholder) + .font(font) + .lineSpacing(lineSpacing) + .foregroundStyle(Color(NSColor.placeholderTextColor)) + .padding(.horizontal, 5) + .background(GeometryReader { geometry in + Color.clear.onAppear { + updatePlaceholderAlignment(height: geometry.size.height) + } + }) + } + } + + private func updateAlignments() { + updatePlaceholderAlignment(height: 0) + updateTextAlignment() + } + + private func updatePlaceholderAlignment(height: CGFloat) { + placeholderAlignment = (height > 22 || text.contains("\n")) ? .topLeading : + (alignment == .leading ? .topLeading : .topTrailing) + } + + private func updateTextAlignment() { + textAlignment = text.contains("\n") ? .leading : alignment } }