diff --git a/idea-plugin/src/main/kotlin/com/itangcent/ai/DeepSeekService.kt b/idea-plugin/src/main/kotlin/com/itangcent/ai/DeepSeekService.kt index fb29cfaf..28b6e7b4 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/ai/DeepSeekService.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/ai/DeepSeekService.kt @@ -8,7 +8,6 @@ import com.itangcent.http.RawContentType import com.itangcent.http.contentType import com.itangcent.idea.plugin.condition.ConditionOnSetting import com.itangcent.idea.plugin.settings.helper.AISettingsHelper -import com.itangcent.idea.plugin.utils.AIUtils import com.itangcent.intellij.extend.sub import com.itangcent.intellij.logger.Logger import com.itangcent.suv.http.HttpClientProvider @@ -87,8 +86,7 @@ open class DeepSeekService : AIService { val jsonElement = GsonUtils.parseToJsonTree(responseBody) val content = jsonElement.sub("choices")?.asJsonArray?.firstOrNull() ?.asJsonObject?.sub("message")?.sub("content")?.asString - return content?.let { AIUtils.cleanMarkdownCodeBlocks(it) } - ?: throw AIApiException("Could not parse response from DeepSeek API") + return content ?: throw AIApiException("Could not parse response from DeepSeek API") } catch (e: AIException) { // Re-throw AI exceptions throw e diff --git a/idea-plugin/src/main/kotlin/com/itangcent/ai/LocalLLMClient.kt b/idea-plugin/src/main/kotlin/com/itangcent/ai/LocalLLMClient.kt index bf92e303..9281820b 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/ai/LocalLLMClient.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/ai/LocalLLMClient.kt @@ -4,7 +4,6 @@ import com.itangcent.common.utils.GsonUtils import com.itangcent.http.HttpClient import com.itangcent.http.RawContentType import com.itangcent.http.contentType -import com.itangcent.idea.plugin.utils.AIUtils import com.itangcent.intellij.extend.sub /** @@ -61,8 +60,7 @@ class LocalLLMClient( val content = jsonElement.sub("choices")?.asJsonArray?.firstOrNull() ?.asJsonObject?.sub("message")?.sub("content")?.asString val errMsg = jsonElement.sub("error")?.asString - return content?.let { AIUtils.cleanMarkdownCodeBlocks(it) } - ?: throw AIApiException(errMsg ?: "Could not parse response from Local LLM server") + return content ?: throw AIApiException(errMsg ?: "Could not parse response from Local LLM server") } catch (e: AIException) { throw e } catch (e: Exception) { diff --git a/idea-plugin/src/main/kotlin/com/itangcent/ai/OpenAIService.kt b/idea-plugin/src/main/kotlin/com/itangcent/ai/OpenAIService.kt index 51d809b6..6d46e3d8 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/ai/OpenAIService.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/ai/OpenAIService.kt @@ -6,7 +6,6 @@ import com.itangcent.common.logger.traceError import com.itangcent.idea.plugin.condition.ConditionOnSetting import com.itangcent.idea.plugin.settings.helper.AISettingsHelper import com.itangcent.idea.plugin.settings.helper.HttpSettingsHelper -import com.itangcent.idea.plugin.utils.AIUtils import com.itangcent.intellij.logger.Logger import com.openai.client.OpenAIClient import com.openai.client.okhttp.OpenAIOkHttpClient @@ -64,8 +63,7 @@ open class OpenAIService : AIService { val content = response.choices().firstOrNull()?.message()?.content() ?.orElse("") - return content?.let { AIUtils.cleanMarkdownCodeBlocks(it) } - ?: throw AIApiException("Empty response from OpenAI API") + return content ?: throw AIApiException("Empty response from OpenAI API") } catch (e: OpenAIException) { logger.traceError("OpenAI API error: ${e.message}", e) throw AIApiException("OpenAI API error: ${e.message}", e) diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/translation/APITranslationHelper.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/translation/APITranslationHelper.kt index 6b4accd3..9e4bbc87 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/translation/APITranslationHelper.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/translation/APITranslationHelper.kt @@ -12,6 +12,7 @@ import com.itangcent.common.model.Request import com.itangcent.common.utils.GsonUtils import com.itangcent.common.utils.notNullOrEmpty import com.itangcent.idea.plugin.settings.helper.AISettingsHelper +import com.itangcent.idea.plugin.utils.AIUtils import com.itangcent.intellij.logger.Logger import java.util.concurrent.ConcurrentHashMap @@ -375,10 +376,10 @@ class APITranslationHelper { "Return only the translated text without any explanations or additional formatting." // Send translation request to AI - val translatedText = aiService.sendPrompt(systemMessage, text) + val translatedText = AIUtils.getGeneralContent(aiService.sendPrompt(systemMessage, text)) // Cache the result - if (translatedText.notNullOrEmpty()) { + if (translatedText.isNotEmpty()) { translationCache[cacheKey] = translatedText return translatedText } @@ -434,10 +435,10 @@ class APITranslationHelper { """.trimIndent() // Send translation request to AI - val translatedJson = aiService.sendPrompt(systemMessage, jsonString) + val translatedJson = AIUtils.getGeneralContent(aiService.sendPrompt(systemMessage, jsonString)) // Cache the result if it's valid JSON - if (translatedJson.notNullOrEmpty() && isValidJson(translatedJson)) { + if (translatedJson.isNotEmpty() && isValidJson(translatedJson)) { return translatedJson } diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/infer/AIMethodInferHelper.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/infer/AIMethodInferHelper.kt index 62297cbd..24a1327f 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/infer/AIMethodInferHelper.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/infer/AIMethodInferHelper.kt @@ -12,6 +12,7 @@ import com.itangcent.cache.withoutCache import com.itangcent.common.logger.Log import com.itangcent.common.logger.traceError import com.itangcent.common.utils.GsonUtils +import com.itangcent.idea.plugin.utils.AIUtils import com.itangcent.intellij.context.ActionContext import com.itangcent.intellij.context.ThreadFlag import com.itangcent.intellij.extend.isNotActive @@ -371,14 +372,18 @@ class AIMethodInferHelper : MethodInferHelper { // Call the AI API with the system message and prompt val aiResponse = if (currentRetry > 0) { cacheSwitcher.withoutCache { - aiService.sendPrompt(AIPromptFormatter.METHOD_RETURN_TYPE_INFERENCE_MESSAGE, prompt) + AIUtils.getGeneralContent( + aiService.sendPrompt(AIPromptFormatter.METHOD_RETURN_TYPE_INFERENCE_MESSAGE, prompt) + ) } } else { - aiService.sendPrompt(AIPromptFormatter.METHOD_RETURN_TYPE_INFERENCE_MESSAGE, prompt) + AIUtils.getGeneralContent( + aiService.sendPrompt(AIPromptFormatter.METHOD_RETURN_TYPE_INFERENCE_MESSAGE, prompt) + ) } // Check if the response is valid before parsing - if (aiResponse.isBlank()) { + if (aiResponse.isEmpty()) { logger.warn("Empty AI response received for method ${methodInfo.className}.${methodInfo.methodName}, attempt ${currentRetry + 1}/$maxRetries") currentRetry++ continue diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/utils/AIUtils.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/utils/AIUtils.kt index 5f54c469..cc544c13 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/utils/AIUtils.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/utils/AIUtils.kt @@ -13,19 +13,66 @@ object AIUtils { */ fun cleanMarkdownCodeBlocks(content: String): String { val trimmedContent = content.trim() - + // Check if content is surrounded by code block delimiters if (trimmedContent.startsWith("```") && trimmedContent.endsWith("```")) { // Remove starting delimiter (with optional language identifier) val withoutStart = trimmedContent.replaceFirst(Regex("^```\\w*", RegexOption.MULTILINE), "") - + // Remove ending delimiter - handles both with and without newline val result = withoutStart.replace(Regex("(\\n)?```$", RegexOption.MULTILINE), "") - + // Trim any leading/trailing whitespace return result.trim() } - + return trimmedContent } + + /** + * Extracts the first code block from content, including the language identifier if present + * + * @param content The content that may contain one or more code blocks + * @return The first code block found, or null if no code block is found + */ + fun extractFirstCodeBlock(content: String): String? { + // First try to find a multi-line code block + val multiLinePattern = Regex("^```\\w*\\n([\\s\\S]*?)```$", RegexOption.MULTILINE) + val multiLineMatch = multiLinePattern.find(content) + if (multiLineMatch != null) { + return multiLineMatch.groupValues[1].trim() + } + + // If no multi-line block found, try single-line + val singleLinePattern = Regex("```([^\\n]+?)```", RegexOption.MULTILINE) + val singleLineMatch = singleLinePattern.find(content) + if (singleLineMatch != null) { + return singleLineMatch.groupValues[1].trim() + } + + return null + } + + /** + * Extracts the first code block of a specific language type from content + * + * @param content The content that may contain one or more code blocks + * @param languageType The specific language type to look for (e.g., "json", "java", "kotlin") + * @return The first code block found with the specified language type, or null if no matching code block is found + */ + fun extractFirstCodeBlockByType(content: String, languageType: String): String? { + val codeBlockPattern = Regex("^```$languageType\\n([\\s\\S]*?)```$", RegexOption.MULTILINE) + return codeBlockPattern.find(content)?.groupValues?.get(1)?.trim() + } + + /** + * Gets the general content by first trying to extract a code block. + * If no code block is found, returns the input content. + * + * @param content The content that may contain code blocks + * @return The extracted code block content if found, otherwise the original content + */ + fun getGeneralContent(content: String): String { + return extractFirstCodeBlock(content) ?: content + } } \ No newline at end of file diff --git a/idea-plugin/src/test/kotlin/com/itangcent/idea/plugin/utils/AIUtilsTest.kt b/idea-plugin/src/test/kotlin/com/itangcent/idea/plugin/utils/AIUtilsTest.kt new file mode 100644 index 00000000..ff2635c2 --- /dev/null +++ b/idea-plugin/src/test/kotlin/com/itangcent/idea/plugin/utils/AIUtilsTest.kt @@ -0,0 +1,276 @@ +package com.itangcent.idea.plugin.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * Test cases for [AIUtils] + */ +class AIUtilsTest { + + @Test + fun testCleanMarkdownCodeBlocks() { + // Test with no code blocks + assertEquals("plain text", AIUtils.cleanMarkdownCodeBlocks("plain text")) + assertEquals("This is not in a code block", AIUtils.cleanMarkdownCodeBlocks("This is not in a code block")) + + // Test with simple code block + assertEquals("code content", AIUtils.cleanMarkdownCodeBlocks("```\ncode content\n```")) + assertEquals( + "This is a code block without language specification", + AIUtils.cleanMarkdownCodeBlocks("```\nThis is a code block without language specification\n```") + ) + + // Test with language identifier + assertEquals("java code", AIUtils.cleanMarkdownCodeBlocks("```java\njava code\n```")) + assertEquals( + """ + fun test() { + println("Hello, World!") + } + """.trimIndent(), AIUtils.cleanMarkdownCodeBlocks( + """ + ```kotlin + fun test() { + println("Hello, World!") + } + ``` + """.trimIndent() + ) + ) + + // Test with extra whitespace + assertEquals("code content", AIUtils.cleanMarkdownCodeBlocks(" ```\n code content\n ``` ")) + + // Test with no newline after opening delimiter + assertEquals("content", AIUtils.cleanMarkdownCodeBlocks("```code content\n```")) + + // Test with no newline before closing delimiter + assertEquals("code content", AIUtils.cleanMarkdownCodeBlocks("```\ncode content```")) + assertEquals("val x = 10", AIUtils.cleanMarkdownCodeBlocks("```kotlin\nval x = 10\n```")) + + // Test with mixed content (should not clean if not entire content is a code block) + val mixedContent = """ + Some text before + + ```kotlin + val x = 10 + ``` + + Some text after + """.trimIndent() + assertEquals(mixedContent, AIUtils.cleanMarkdownCodeBlocks(mixedContent)) + + // Test with empty content + assertEquals("", AIUtils.cleanMarkdownCodeBlocks("")) + + // Test with only delimiters + assertEquals("", AIUtils.cleanMarkdownCodeBlocks("``````")) + } + + @Test + fun testExtractFirstCodeBlock() { + // Test with no code blocks + assertNull(AIUtils.extractFirstCodeBlock("plain text")) + + // Test with single code block + assertEquals("code content", AIUtils.extractFirstCodeBlock("```\ncode content\n```")) + + // Test with single code block + assertEquals("code content", AIUtils.extractFirstCodeBlock("```text\ncode content\n```")) + + // Test with multiple code blocks + assertEquals( + "first block", AIUtils.extractFirstCodeBlock( + """ + ``` + first block + ``` + ``` + second block + ``` + """.trimIndent() + ) + ) + + // Test with language identifier + assertEquals("java code", AIUtils.extractFirstCodeBlock("```java\njava code\n```")) + + // Test with text before and after code block + assertEquals( + "code content", AIUtils.extractFirstCodeBlock( + """ + some text + ``` + code content + ``` + more text + """.trimIndent() + ) + ) + + // Test with content on same line as delimiters + assertEquals("content", AIUtils.extractFirstCodeBlock("```content```")) + + // Test with no newline after opening delimiter + assertEquals("", AIUtils.extractFirstCodeBlock("```content\n```")) + + // Test with no newline before closing delimiter + assertEquals("code content", AIUtils.extractFirstCodeBlock("```\ncode content```")) + + // Test with language identifier and no newline after opening delimiter (should not match) + assertNull(AIUtils.extractFirstCodeBlock("```java content\n```")) + + // Test with both single-line and multi-line code blocks (should get multi-line) + assertEquals( + "multi line content", AIUtils.extractFirstCodeBlock( + """ + ```single line content``` + ```text + multi line content + ``` + """.trimIndent() + ) + ) + + // Test with both single-line and multi-line code blocks in reverse order (should get multi-line) + assertEquals( + "multi line content", AIUtils.extractFirstCodeBlock( + """ + ```text + multi line content + ``` + ```single line content``` + """.trimIndent() + ) + ) + + // Test with multiple single-line and one multi-line code block (should get multi-line) + assertEquals( + "multi line content", AIUtils.extractFirstCodeBlock( + """ + ```first single line``` + ```second single line``` + ```text + multi line content + ``` + ```third single line``` + """.trimIndent() + ) + ) + } + + @Test + fun testExtractFirstCodeBlockByType() { + // Test with no code blocks + assertNull(AIUtils.extractFirstCodeBlockByType("plain text", "json")) + + // Test with matching language type + assertEquals( + "{\"key\": \"value\"}", + AIUtils.extractFirstCodeBlockByType("```json\n{\"key\": \"value\"}\n```", "json") + ) + + // Test with non-matching language type + assertNull(AIUtils.extractFirstCodeBlockByType("```java\nSystem.out.println();\n```", "json")) + + // Test with multiple code blocks + assertEquals( + "{\"first\": true}", + AIUtils.extractFirstCodeBlockByType( + """ + ```json + {"first": true} + ``` + ```json + {"second": true} + ``` + """.trimIndent(), "json" + ) + ) + + // Test with text before and after code block + assertEquals( + "System.out.println();", + AIUtils.extractFirstCodeBlockByType( + """ + some text + ```java + System.out.println(); + ``` + more text + """.trimIndent(), "java" + ) + ) + + // Test with case sensitivity + assertNull(AIUtils.extractFirstCodeBlockByType("```JSON\n{}\n```", "json")) + + // Test with content on same line as delimiters + assertEquals("content", AIUtils.extractFirstCodeBlockByType("```json\ncontent\n```", "json")) + } + + @Test + fun testGetGeneralContent() { + // Test with no code blocks + assertEquals("plain text", AIUtils.getGeneralContent("plain text")) + assertEquals("This is not in a code block", AIUtils.getGeneralContent("This is not in a code block")) + + // Test with simple code block + assertEquals("code content", AIUtils.getGeneralContent("```\ncode content\n```")) + assertEquals( + "This is a code block without language specification", + AIUtils.getGeneralContent("```\nThis is a code block without language specification\n```") + ) + + // Test with language identifier + assertEquals("java code", AIUtils.getGeneralContent("```java\njava code\n```")) + assertEquals( + """ + fun test() { + println("Hello, World!") + } + """.trimIndent(), AIUtils.getGeneralContent( + """ + ```kotlin + fun test() { + println("Hello, World!") + } + ``` + """.trimIndent() + ) + ) + + // Test with mixed content (should get first code block) + assertEquals( + "first block", AIUtils.getGeneralContent( + """ + some text + ``` + first block + ``` + ``` + second block + ``` + more text + """.trimIndent() + ) + ) + + // Test with content on same line as delimiters + assertEquals("content", AIUtils.getGeneralContent("```content```")) + + // Test with no newline after opening delimiter + assertEquals("", AIUtils.getGeneralContent("```content\n```")) + + // Test with no newline before closing delimiter + assertEquals("code content", AIUtils.getGeneralContent("```\ncode content```")) + + // Test with empty content + assertEquals("", AIUtils.getGeneralContent("")) + + // Test with only delimiters + assertEquals("``````", AIUtils.getGeneralContent("``````")) + } +} \ No newline at end of file diff --git a/idea-plugin/src/test/kotlin/com/itangcent/idea/utils/AIUtilsTest.kt b/idea-plugin/src/test/kotlin/com/itangcent/idea/utils/AIUtilsTest.kt deleted file mode 100644 index 85b8901d..00000000 --- a/idea-plugin/src/test/kotlin/com/itangcent/idea/utils/AIUtilsTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.itangcent.idea.utils - -import com.itangcent.idea.plugin.utils.AIUtils -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -/** - * Test cases for [AIUtils] - */ -class AIUtilsTest { - - @Test - fun testCleanMarkdownCodeBlocksWithLanguage() { - val input = """ - ```kotlin - fun test() { - println("Hello, World!") - } - ``` - """.trimIndent() - - val expected = """ - fun test() { - println("Hello, World!") - } - """.trimIndent() - - assertEquals(expected, AIUtils.cleanMarkdownCodeBlocks(input)) - } - - @Test - fun testCleanMarkdownCodeBlocksWithoutLanguage() { - val input = """ - ``` - This is a code block without language specification - ``` - """.trimIndent() - - val expected = "This is a code block without language specification" - - assertEquals(expected, AIUtils.cleanMarkdownCodeBlocks(input)) - } - - @Test - fun testCleanMarkdownCodeBlocksWithoutNewlineBeforeClosingDelimiter() { - val input = "```kotlin\nval x = 10\n```" - val expected = "val x = 10" - - assertEquals(expected, AIUtils.cleanMarkdownCodeBlocks(input)) - } - - @Test - fun testCleanMarkdownCodeBlocksWithContentNotInCodeBlock() { - val input = "This is not in a code block" - val expected = "This is not in a code block" - - assertEquals(expected, AIUtils.cleanMarkdownCodeBlocks(input)) - } - - @Test - fun testCleanMarkdownCodeBlocksWithMixedContent() { - val input = """ - Some text before - - ```kotlin - val x = 10 - ``` - - Some text after - """.trimIndent() - - // The function should only clean if the entire content is a code block - assertEquals(input, AIUtils.cleanMarkdownCodeBlocks(input)) - } - - @Test - fun testCleanMarkdownCodeBlocksWithEmptyContent() { - val input = "" - val expected = "" - - assertEquals(expected, AIUtils.cleanMarkdownCodeBlocks(input)) - } - - @Test - fun testCleanMarkdownCodeBlocksWithOnlyDelimiters() { - val input = "``````" - val expected = "" - - assertEquals(expected, AIUtils.cleanMarkdownCodeBlocks(input)) - } -} \ No newline at end of file