diff --git a/Easydict/App/Localizable.xcstrings b/Easydict/App/Localizable.xcstrings index 51005a66d..f626948db 100644 --- a/Easydict/App/Localizable.xcstrings +++ b/Easydict/App/Localizable.xcstrings @@ -4672,6 +4672,34 @@ } } }, + "service.configuration.openai.hide_think_tag_content.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide Tag Content" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skryť obsah Tag" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏 标签内容" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱藏 標籤內容" + } + } + } + }, "service.configuration.openai.system_prompt.placeholder" : { "localizations" : { "en" : { diff --git a/Easydict/Swift/Feature/Configuration/ServiceConfigurationKey.swift b/Easydict/Swift/Feature/Configuration/ServiceConfigurationKey.swift index e31391c4d..3a155090a 100644 --- a/Easydict/Swift/Feature/Configuration/ServiceConfigurationKey.swift +++ b/Easydict/Swift/Feature/Configuration/ServiceConfigurationKey.swift @@ -51,4 +51,5 @@ enum ServiceConfigurationKey: String { case enableCustomPrompt case systemPrompt case userPrompt + case thinkTag } diff --git a/Easydict/Swift/Service/OpenAI/StreamService+UpdateResult.swift b/Easydict/Swift/Service/OpenAI/StreamService+UpdateResult.swift index ea9d0809f..7b7c5ba86 100644 --- a/Easydict/Swift/Service/OpenAI/StreamService+UpdateResult.swift +++ b/Easydict/Swift/Service/OpenAI/StreamService+UpdateResult.swift @@ -7,6 +7,7 @@ // import Foundation +import RegexBuilder extension StreamService { /// Throttle update result text, avoid update UI too frequently. @@ -52,7 +53,11 @@ extension StreamService { // If error is not nil, means stream is finished. result.isStreamFinished = error != nil - let finalText = resultText?.trim() ?? "" + var finalText = resultText?.trim() ?? "" + + if hideThinkTagContent { + finalText = finalText.filterThinkTagContent().trim() + } let updateCompletion = { [weak result] in guard let result else { return } diff --git a/Easydict/Swift/Service/OpenAI/StreamService.swift b/Easydict/Swift/Service/OpenAI/StreamService.swift index 43308c113..173dc11c7 100644 --- a/Easydict/Swift/Service/OpenAI/StreamService.swift +++ b/Easydict/Swift/Service/OpenAI/StreamService.swift @@ -223,6 +223,14 @@ public class StreamService: QueryService { serviceDefaultsKey(.serviceUsageStatus, defaultValue: .default) } + var thinkTagKey: Defaults.Key { + boolDefaultsKey(.thinkTag, defaultValue: true) + } + + var hideThinkTagContent: Bool { + Defaults[thinkTagKey] + } + // In general, LLM services need to observe these keys to enable validation button. var observeKeys: [Defaults.Key] { [ diff --git a/Easydict/Swift/Utility/Extensions/String/String+Regex.swift b/Easydict/Swift/Utility/Extensions/String/String+Regex.swift index acec725bf..6895a59b9 100644 --- a/Easydict/Swift/Utility/Extensions/String/String+Regex.swift +++ b/Easydict/Swift/Utility/Extensions/String/String+Regex.swift @@ -7,6 +7,7 @@ // import Foundation +import RegexBuilder extension String { func extract(withPattern pattern: String) -> String? { @@ -24,3 +25,37 @@ extension String { return nil } } + +extension String { + /// Filter ^... tag content. + /// Example: + /// - "hello" -> "" + /// - "hello" -> "hello" + /// - "helloworld" -> "world" + /// - "helloworld" -> "helloworld" + /// - "no tags here" -> "no tags here" + func filterThinkTagContent() -> String { + filterTagContent("think") + } + + func filterTagContent(_ tag: String) -> String { + let startTag = "<\(tag)>" + let endTag = "" + + // Tag pattern + let tagPattern = Regex { + Anchor.startOfSubject + startTag + ZeroOrMore { + // Match any character (non-greedy) until is found + NegativeLookahead(endTag) + CharacterClass.any + } + // Match the closing tag if it exists + Optionally(endTag) + } + + // Replace all matches with an empty string + return replacing(tagPattern, with: "") + } +} diff --git a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift index a6a22a617..512dd62a5 100644 --- a/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift +++ b/Easydict/Swift/View/SettingView/Tabs/ServiceConfigurationView/ConfigurationView/StreamConfigurationView.swift @@ -27,7 +27,8 @@ struct StreamConfigurationView: View { showTranslationToggle: Bool = true, showSentenceToggle: Bool = true, showDictionaryToggle: Bool = true, - showUsageStatusPicker: Bool = true + showUsageStatusPicker: Bool = true, + showThinkTagContent: Bool = true ) { self.service = service @@ -41,6 +42,7 @@ struct StreamConfigurationView: View { self.showSentenceToggle = showSentenceToggle self.showDictionaryToggle = showDictionaryToggle self.showUsageStatusPicker = showUsageStatusPicker + self.showThinkTagSection = showThinkTagContent // Disable user to edit built-in supported models. self.isEditable = service.serviceType() != .builtInAI @@ -66,6 +68,7 @@ struct StreamConfigurationView: View { let showSentenceToggle: Bool let showDictionaryToggle: Bool let showUsageStatusPicker: Bool + let showThinkTagSection: Bool var isEditable = true @@ -176,6 +179,13 @@ struct StreamConfigurationView: View { values: ServiceUsageStatus.allCases ) } + + if showThinkTagSection { + ToggleCell( + titleKey: "service.configuration.openai.hide_think_tag_content.title", + key: service.thinkTagKey + ) + } } } } diff --git a/EasydictSwiftTests/Test.swift b/EasydictSwiftTests/Test.swift index e1fe6964b..14b0d6b7f 100644 --- a/EasydictSwiftTests/Test.swift +++ b/EasydictSwiftTests/Test.swift @@ -91,3 +91,20 @@ import Translation } #expect(true, "Concurrent test getSelectedText completed without crash") } + +@Test func testFilterThinkTags() { + let testCases: [(input: String, expected: String)] = [ + ("hello", ""), + ("hello", "hello"), + ("helloworld", "world"), + ("helloworld", "helloworld"), + ("no tags here", "no tags here"), + ("", ""), + ] + + for (index, testCase) in testCases.enumerated() { + let result = testCase.input.filterThinkTagContent() + print("Test Case \(index + 1): \(result)") + #expect(result == testCase.expected) + } +}