├── src ├── test │ ├── testData │ │ └── rename │ │ │ ├── foo.xml │ │ │ └── foo_after.xml │ └── kotlin │ │ └── com │ │ └── phodal │ │ └── lotus │ │ └── chat │ │ ├── repository │ │ └── ChatRepositoryTest.kt │ │ └── history │ │ └── ConversationManagerTest.kt └── main │ ├── resources │ ├── messages │ │ └── LotusBundle.properties │ ├── META-INF │ │ ├── plugin.xml │ │ └── pluginIcon.svg │ └── icons │ │ ├── composeToolWindow.svg │ │ ├── composeToolWindow@20x20.svg │ │ ├── composeToolWindow@20x20_dark.svg │ │ ├── composeToolWindow_dark.svg │ │ ├── lotus.svg │ │ └── lotus_dark.svg │ └── kotlin │ └── com │ └── phodal │ └── lotus │ ├── components │ ├── Searchable.kt │ ├── PulsingText.kt │ └── ContextPopupMenu.kt │ ├── chat │ ├── viewmodel │ │ ├── ChatListUiState.kt │ │ ├── MessageInputState.kt │ │ ├── SearchChatMessagesHandler.kt │ │ └── SearchChatMessagesHandlerImpl.kt │ ├── ui │ │ ├── renderer │ │ │ ├── MessageRenderer.kt │ │ │ ├── MarkdownRenderer.kt │ │ │ ├── DiffRenderer.kt │ │ │ ├── MermaidRenderer.kt │ │ │ └── RendererRegistry.kt │ │ ├── SearchState.kt │ │ ├── TokenUsagePanel.kt │ │ ├── AIConfigButton.kt │ │ ├── MessageItem.kt │ │ └── PromptInput.kt │ ├── ChatAppIcons.kt │ ├── model │ │ └── ChatMessage.kt │ ├── repository │ │ ├── ChatRepository.kt │ │ ├── ChatMessageFactory.kt │ │ └── AIResponseGenerator.kt │ ├── ChatAppColors.kt │ └── history │ │ ├── ConversationHistory.kt │ │ └── ConversationManager.kt │ ├── LotusColors.kt │ ├── startup │ └── MyProjectActivity.kt │ ├── LotusBundle.kt │ ├── services │ └── MyProjectService.kt │ ├── toolWindow │ └── CodeLotusWindowFactory.kt │ ├── CoroutineScopeHolder.kt │ └── config │ └── AIConfigService.kt ├── .gitignore ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── settings.gradle.kts ├── codecov.yml ├── qodana.yml ├── ai-core ├── src │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── phodal │ │ │ └── lotus │ │ │ └── aicore │ │ │ ├── config │ │ │ ├── ConfigProvider.kt │ │ │ ├── LLMConfig.kt │ │ │ └── LLMConfigManager.kt │ │ │ ├── token │ │ │ ├── FallbackTokenCounter.kt │ │ │ ├── TokenCounter.kt │ │ │ ├── JtokKitTokenCounter.kt │ │ │ ├── TokenUsage.kt │ │ │ └── TokenUsageTracker.kt │ │ │ ├── client │ │ │ ├── langchain │ │ │ │ └── LangChain4jTokenCounter.kt │ │ │ └── AIClient.kt │ │ │ ├── streaming │ │ │ └── StreamingCancellationToken.kt │ │ │ ├── AIServiceFactory.kt │ │ │ └── context │ │ │ └── summarization │ │ │ ├── ConversationSummarizer.kt │ │ │ ├── AIConversationSummarizer.kt │ │ │ └── ContentSelectionStrategy.kt │ └── test │ │ └── kotlin │ │ └── com │ │ └── phodal │ │ └── lotus │ │ └── aicore │ │ ├── token │ │ ├── TokenCounterTest.kt │ │ ├── TokenUsageTest.kt │ │ ├── JtokKitTokenCounterTest.kt │ │ └── TokenUsageTrackerTest.kt │ │ ├── streaming │ │ └── StreamingCancellationTokenTest.kt │ │ ├── client │ │ └── LangChain4jAIClientTest.kt │ │ ├── AIServiceFactoryTest.kt │ │ └── summarization │ │ ├── ConversationSummarizerTest.kt │ │ └── ContentSelectionStrategyTest.kt ├── build.gradle.kts └── README.md ├── CHANGELOG.md ├── .github ├── dependabot.yml └── workflows │ ├── run-ui-tests.yml │ ├── release.yml │ └── build.yml ├── .idea └── gradle.xml ├── .run ├── Run Tests.run.xml ├── Run Plugin.run.xml └── Run Verifications.run.xml ├── gradle.properties ├── README.md └── gradlew.bat /src/test/testData/rename/foo.xml: -------------------------------------------------------------------------------- 1 | 2 | 1>Foo 3 | 4 | -------------------------------------------------------------------------------- /src/test/testData/rename/foo_after.xml: -------------------------------------------------------------------------------- 1 | 2 | Foo 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .gradle 3 | .idea 4 | .intellijPlatform 5 | .kotlin 6 | .qodana 7 | build 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phodal/autodev-lotus/main/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/messages/LotusBundle.properties: -------------------------------------------------------------------------------- 1 | projectService=Project service: {0} 2 | randomLabel=The random number is: {0} 3 | shuffle=Shuffle 4 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 3 | } 4 | 5 | rootProject.name = "autodev-lotus" 6 | 7 | include("ai-core") 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | threshold: 0% 7 | base: auto 8 | patch: 9 | default: 10 | informational: true 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/components/Searchable.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.components 2 | 3 | /** 4 | * Represents an entity that can be filtered by a search query. 5 | */ 6 | interface Searchable { 7 | fun matches(query: String): Boolean 8 | } -------------------------------------------------------------------------------- /qodana.yml: -------------------------------------------------------------------------------- 1 | # Qodana configuration: 2 | # https://www.jetbrains.com/help/qodana/qodana-yaml.html 3 | 4 | version: "1.0" 5 | linter: jetbrains/qodana-jvm-community:2024.3 6 | projectJDK: "21" 7 | profile: 8 | name: qodana.recommended 9 | exclude: 10 | - name: All 11 | paths: 12 | - .qodana 13 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/viewmodel/ChatListUiState.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.viewmodel 2 | 3 | import com.phodal.lotus.chat.model.ChatMessage 4 | 5 | data class ChatListUiState( 6 | val messages: List = emptyList(), 7 | ) { 8 | companion object Companion { 9 | val EMPTY = ChatListUiState() 10 | } 11 | } -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/config/ConfigProvider.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.config 2 | 3 | import kotlinx.coroutines.flow.StateFlow 4 | 5 | /** 6 | * Interface for configuration providers 7 | * Allows AIServiceFactory to work with different configuration sources 8 | */ 9 | interface ConfigProvider { 10 | val currentConfig: StateFlow 11 | 12 | fun isConfigured(): Boolean 13 | } 14 | 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/LotusColors.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus 2 | 3 | import com.intellij.ui.IconManager 4 | 5 | @Suppress("unused") 6 | object ComposeIcons { 7 | @JvmField 8 | val ComposeToolWindow = 9 | IconManager.getInstance().getIcon("/icons/composeToolWindow.svg", javaClass.getClassLoader()) 10 | 11 | @JvmField 12 | val Lotus = 13 | IconManager.getInstance().getIcon("/icons/lotus.svg", javaClass.getClassLoader()) 14 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # autodev-lotus Changelog 4 | 5 | ## [Unreleased] 6 | 7 | ## [0.0.1] - 2025-10-27 8 | 9 | ### Added 10 | 11 | - Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template) 12 | 13 | [Unreleased]: https://github.com/phodal/autodev-lotus/compare/v0.0.1...HEAD 14 | [0.0.1]: https://github.com/phodal/autodev-lotus/commits/v0.0.1 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/startup/MyProjectActivity.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.startup 2 | 3 | import com.intellij.openapi.diagnostic.thisLogger 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.startup.ProjectActivity 6 | 7 | class MyProjectActivity : ProjectActivity { 8 | 9 | override suspend fun execute(project: Project) { 10 | thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.") 11 | } 12 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration: 2 | # https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Maintain dependencies for Gradle dependencies 7 | - package-ecosystem: "gradle" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | # Maintain dependencies for GitHub Actions 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/token/FallbackTokenCounter.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | class FallbackTokenCounter(private val modelName: String = "unknown") : TokenCounter { 4 | companion object Companion { 5 | private const val WORDS_PER_TOKEN = 0.75 6 | } 7 | 8 | override fun estimateTokenCount(text: String): Int { 9 | if (text.isBlank()) return 0 10 | 11 | val wordCount = text.split(Regex("\\s+")).size 12 | return (wordCount / WORDS_PER_TOKEN).toInt() 13 | } 14 | 15 | override fun getModelName(): String = modelName 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/renderer/MessageRenderer.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui.renderer 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import com.phodal.lotus.chat.model.ChatMessage 6 | 7 | /** 8 | * Interface for rendering different message content formats. 9 | */ 10 | interface MessageRenderer { 11 | /** 12 | * Renders the message content. 13 | * @param message The chat message to render. 14 | * @param modifier Optional modifier for the rendered content. 15 | */ 16 | @Composable 17 | fun render(message: ChatMessage, modifier: Modifier = Modifier) 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/viewmodel/MessageInputState.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.viewmodel 2 | 3 | sealed class MessageInputState(val inputText: String) { 4 | object Disabled : MessageInputState("") 5 | 6 | data class Enabled(val text: String) : MessageInputState(text) 7 | 8 | data class Sending(val messageText: String) : MessageInputState(messageText) 9 | 10 | data class Sent(val messageText: String) : MessageInputState(messageText) 11 | 12 | data class SendFailed(val messageText: String, val throwable: Throwable) : MessageInputState(messageText) 13 | } 14 | 15 | val MessageInputState.isSending: Boolean get() = this is MessageInputState.Sending -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/LotusBundle.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus 2 | 3 | import com.intellij.DynamicBundle 4 | import org.jetbrains.annotations.NonNls 5 | import org.jetbrains.annotations.PropertyKey 6 | 7 | @NonNls 8 | private const val BUNDLE = "messages.LotusBundle" 9 | 10 | object LotusBundle : DynamicBundle(BUNDLE) { 11 | 12 | @JvmStatic 13 | fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) = 14 | getMessage(key, *params) 15 | 16 | @Suppress("unused") 17 | @JvmStatic 18 | fun messagePointer(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) = 19 | getLazyMessage(key, *params) 20 | } 21 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/services/MyProjectService.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.services 2 | 3 | import com.intellij.openapi.components.Service 4 | import com.intellij.openapi.diagnostic.thisLogger 5 | import com.intellij.openapi.project.Project 6 | import com.phodal.lotus.LotusBundle 7 | 8 | @Service(Service.Level.PROJECT) 9 | class MyProjectService(project: Project) { 10 | 11 | init { 12 | thisLogger().info(LotusBundle.message("projectService", project.name)) 13 | thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.") 14 | } 15 | 16 | fun getRandomNumber() = (1..100).random() 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ChatAppIcons.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat 2 | 3 | import org.jetbrains.jewel.ui.icons.AllIconsKeys 4 | 5 | /** 6 | * Centralized icon keys used by the Chat sample UI. 7 | * Grouped by feature area to keep call-sites tidy and consistent. 8 | */ 9 | object ChatAppIcons { 10 | object Header { 11 | val search = AllIconsKeys.Actions.Find 12 | val close = AllIconsKeys.Actions.Cancel 13 | 14 | val plus = AllIconsKeys.General.Add 15 | val history = AllIconsKeys.General.History 16 | } 17 | 18 | object Prompt { 19 | val send = AllIconsKeys.RunConfigurations.TestState.Run 20 | val stop = AllIconsKeys.Run.Stop 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.phodal.lotus 4 | CodeLotus 5 | phodal 6 | 7 | 8 | 9 | 10 | 11 | messages.LotusBundle 12 | 13 | 14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /ai-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | } 4 | 5 | group = "com.phodal.lotus" 6 | version = "1.0.0" 7 | 8 | repositories { 9 | mavenCentral() 10 | google() 11 | } 12 | 13 | dependencies { 14 | // LangChain4j for LLM integration 15 | implementation(libs.langchain4j) 16 | implementation(libs.langchain4jOpenai) 17 | implementation(libs.langchain4jAnthropic) 18 | implementation(libs.langchain4jGoogleaigemini) 19 | 20 | // Token counting with jtokkit (optional, for accurate token counting) 21 | compileOnly(libs.jtokkit) 22 | 23 | // Kotlin coroutines 24 | implementation(libs.kotlinxCoroutinesCore) 25 | 26 | // Testing 27 | testImplementation(kotlin("test")) 28 | } 29 | 30 | tasks.test { 31 | useJUnitPlatform() 32 | } 33 | 34 | kotlin { 35 | jvmToolchain(21) 36 | } 37 | 38 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/client/langchain/LangChain4jTokenCounter.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.client.langchain 2 | 3 | import com.phodal.lotus.aicore.config.LLMProvider 4 | import com.phodal.lotus.aicore.token.FallbackTokenCounter 5 | import com.phodal.lotus.aicore.token.JtokKitTokenCounter 6 | import com.phodal.lotus.aicore.token.TokenCounter 7 | 8 | object LangChain4jTokenCounter { 9 | /** 10 | * Create a token counter for the specified provider and model 11 | * Uses jtokkit for accurate token counting 12 | */ 13 | fun create(provider: LLMProvider, modelName: String): TokenCounter { 14 | return try { 15 | // Try to use jtokkit for accurate token counting 16 | JtokKitTokenCounter(modelName) 17 | } catch (e: Exception) { 18 | // Fallback to simple token counter if jtokkit fails 19 | FallbackTokenCounter(modelName) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /.run/Run Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | true 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.run/Run Plugin.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | false 20 | true 21 | false 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /.run/Run Verifications.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/token/TokenCounter.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | /** 4 | * Interface for counting tokens in text 5 | * 6 | * This abstraction allows for different token counting implementations 7 | * for different LLM providers, as each may use different tokenization methods. 8 | * 9 | * Future extensions can support other languages beyond Kotlin/Java. 10 | */ 11 | interface TokenCounter { 12 | /** 13 | * Estimate the number of tokens in the given text 14 | * 15 | * @param text The text to count tokens for 16 | * @return Estimated number of tokens 17 | */ 18 | fun estimateTokenCount(text: String): Int 19 | 20 | /** 21 | * Estimate the number of tokens in a list of messages 22 | * 23 | * @param messages List of message texts 24 | * @return Estimated total number of tokens 25 | */ 26 | fun estimateTokenCount(messages: List): Int { 27 | return messages.sumOf { estimateTokenCount(it) } 28 | } 29 | 30 | /** 31 | * Get the model name this counter is configured for 32 | */ 33 | fun getModelName(): String 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/renderer/MarkdownRenderer.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui.renderer 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.dp 8 | import androidx.compose.ui.unit.sp 9 | import org.jetbrains.jewel.foundation.theme.JewelTheme 10 | import org.jetbrains.jewel.ui.component.Text 11 | import com.phodal.lotus.chat.ChatAppColors 12 | import com.phodal.lotus.chat.model.ChatMessage 13 | 14 | /** 15 | * Renderer for Markdown/plain text content. 16 | */ 17 | class MarkdownRenderer : MessageRenderer { 18 | @Composable 19 | override fun render(message: ChatMessage, modifier: Modifier) { 20 | Text( 21 | text = message.content, 22 | style = JewelTheme.defaultTextStyle.copy( 23 | fontSize = 14.sp, 24 | fontWeight = FontWeight.Normal, 25 | color = ChatAppColors.Text.normal, 26 | lineHeight = 20.sp 27 | ), 28 | modifier = modifier.padding(bottom = 8.dp) 29 | ) 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/client/AIClient.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.client 2 | 3 | import com.phodal.lotus.aicore.token.TokenUsage 4 | 5 | /** 6 | * Result of an AI message call with token usage information 7 | */ 8 | data class AIMessageResult( 9 | val content: String, 10 | val tokenUsage: TokenUsage? = null 11 | ) 12 | 13 | /** 14 | * Interface for AI client 15 | */ 16 | interface AIClient { 17 | /** 18 | * Send a message to the AI and get a response 19 | * @return AIMessageResult containing the response and token usage 20 | */ 21 | suspend fun sendMessage(message: String): AIMessageResult 22 | 23 | /** 24 | * Stream a message response 25 | * @param message The message to send 26 | * @param onChunk Callback for each chunk of the response 27 | * @param cancellationToken Optional token to control streaming cancellation 28 | * @return TokenUsage information after streaming completes 29 | */ 30 | suspend fun streamMessage( 31 | message: String, 32 | onChunk: (String) -> Unit, 33 | cancellationToken: Any? = null 34 | ): TokenUsage? 35 | 36 | /** 37 | * Check if the client is properly configured 38 | */ 39 | fun isConfigured(): Boolean 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/main/resources/icons/composeToolWindow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/icons/composeToolWindow@20x20.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/token/TokenCounterTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.junit.jupiter.api.Assertions.* 5 | 6 | class TokenCounterTest { 7 | 8 | @Test 9 | fun testSimpleTokenCounter() { 10 | val counter = FallbackTokenCounter("test-model") 11 | 12 | val text = "Hello world this is a test" 13 | val tokenCount = counter.estimateTokenCount(text) 14 | 15 | // Simple counter estimates ~1.33 tokens per word 16 | // 6 words ≈ 8 tokens 17 | assertTrue(tokenCount > 0) 18 | assertEquals("test-model", counter.getModelName()) 19 | } 20 | 21 | @Test 22 | fun testSimpleTokenCounterEmptyText() { 23 | val counter = FallbackTokenCounter() 24 | 25 | assertEquals(0, counter.estimateTokenCount("")) 26 | assertEquals(0, counter.estimateTokenCount(" ")) 27 | } 28 | 29 | @Test 30 | fun testSimpleTokenCounterMultipleMessages() { 31 | val counter = FallbackTokenCounter() 32 | 33 | val messages = listOf( 34 | "Hello world", 35 | "How are you?", 36 | "I am fine" 37 | ) 38 | 39 | val totalTokens = counter.estimateTokenCount(messages) 40 | assertTrue(totalTokens > 0) 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /src/main/resources/icons/composeToolWindow@20x20_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/main/resources/icons/composeToolWindow_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/toolWindow/CodeLotusWindowFactory.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.toolWindow 2 | 3 | import com.intellij.openapi.components.service 4 | import com.intellij.openapi.diagnostic.thisLogger 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.openapi.util.Disposer 7 | import com.intellij.openapi.wm.ToolWindow 8 | import com.intellij.openapi.wm.ToolWindowFactory 9 | import com.phodal.lotus.CoroutineScopeHolder 10 | import com.phodal.lotus.chat.ChatApp 11 | import com.phodal.lotus.services.IdeaChatRepository 12 | import com.phodal.lotus.chat.viewmodel.ChatViewModel 13 | import org.jetbrains.jewel.bridge.addComposeTab 14 | 15 | 16 | class CodeLotusWindowFactory : ToolWindowFactory { 17 | 18 | init { 19 | thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.") 20 | } 21 | 22 | override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { 23 | chatApp(project, toolWindow) 24 | } 25 | 26 | override fun shouldBeAvailable(project: Project) = true 27 | 28 | private fun chatApp(project: Project, toolWindow: ToolWindow) { 29 | val viewModel = ChatViewModel( 30 | project.service() 31 | .createScope(ChatViewModel::class.java.simpleName), 32 | service() 33 | ) 34 | Disposer.register(toolWindow.disposable, viewModel) 35 | 36 | toolWindow.addComposeTab("Chat") { ChatApp(viewModel) } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/components/PulsingText.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.components 2 | 3 | import androidx.compose.animation.core.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.text.font.FontWeight 8 | import androidx.compose.ui.unit.TextUnit 9 | import org.jetbrains.jewel.foundation.theme.JewelTheme 10 | import org.jetbrains.jewel.ui.component.Text 11 | 12 | @Composable 13 | fun PulsingText( 14 | text: String, 15 | isLoading: Boolean, 16 | modifier: Modifier = Modifier, 17 | color: Color = Color.White, 18 | fontSize: TextUnit = JewelTheme.defaultTextStyle.fontSize, 19 | fontWeight: FontWeight? = JewelTheme.defaultTextStyle.fontWeight 20 | ) { 21 | val alpha = if (isLoading) { 22 | val infiniteTransition = rememberInfiniteTransition(label = "pulsing_text") 23 | infiniteTransition.animateFloat( 24 | initialValue = 0.3f, 25 | targetValue = 1f, 26 | animationSpec = infiniteRepeatable( 27 | animation = tween( 28 | durationMillis = 1000, 29 | easing = FastOutSlowInEasing 30 | ), 31 | repeatMode = RepeatMode.Reverse 32 | ), 33 | label = "text_alpha" 34 | ).value 35 | } else { 36 | 1f 37 | } 38 | 39 | Text( 40 | text = text, 41 | color = color.copy(alpha = alpha), 42 | fontSize = fontSize, 43 | fontWeight = fontWeight, 44 | modifier = modifier 45 | ) 46 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/viewmodel/SearchChatMessagesHandler.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.viewmodel 2 | 3 | import kotlinx.coroutines.flow.StateFlow 4 | import com.phodal.lotus.chat.ui.SearchState 5 | 6 | /** 7 | * Interface that handles the process of searching for chat messages. 8 | * Provides functionality for initiating, stopping, and performing searches, 9 | * as well as navigating between search results. 10 | */ 11 | interface SearchChatMessagesHandler { 12 | 13 | /** 14 | * A [StateFlow] that represents the current state of the chat message search functionality. 15 | * It emits instances of [SearchState] to reflect the ongoing state of search operations. 16 | * 17 | * This flow can emit the following states: 18 | * - [SearchState.Idle]: Indicates no active search operation is ongoing. 19 | * - [SearchState.Searching]: Represents an active search operation with the associated query. 20 | * - [SearchState.SearchResults]: Contains the results of the search operation, including the matching 21 | * message IDs, the query used, and the index of the currently selected search result. 22 | * 23 | * This property is intended to be observed by consumers to react to search state changes 24 | * and provide appropriate updates to the UI or other components. 25 | */ 26 | val searchStateFlow: StateFlow 27 | 28 | fun onStartSearch() 29 | 30 | fun onStopSearch() 31 | 32 | // Search functionality 33 | fun onSearchQuery(query: String) 34 | 35 | fun onNavigateToNextSearchResult() 36 | 37 | fun onNavigateToPreviousSearchResult() 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/CoroutineScopeHolder.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus 2 | 3 | import com.intellij.openapi.components.Service 4 | import com.intellij.openapi.components.Service.Level 5 | import com.intellij.platform.util.coroutines.childScope 6 | import kotlinx.coroutines.CoroutineScope 7 | 8 | 9 | /** 10 | * A service-level class that provides and manages coroutine scopes for a given project. 11 | * 12 | * @constructor Initializes the [CoroutineScopeHolder] with a project-wide coroutine scope. 13 | * @param projectWideCoroutineScope A [CoroutineScope] defining the lifecycle of project-wide coroutines. 14 | */ 15 | @Service(Level.PROJECT) 16 | class CoroutineScopeHolder(private val projectWideCoroutineScope: CoroutineScope) { 17 | /** 18 | * Creates a new coroutine scope as a child of the project-wide coroutine scope with the specified name. 19 | * 20 | * @param name The name for the newly created coroutine scope. 21 | * @return a scope with a [Job] which parent is the [Job] of [projectWideCoroutineScope] scope. 22 | * 23 | * The returned scope can be completed only by cancellation. 24 | * [projectWideCoroutineScope] scope will cancel the returned scope when canceled. 25 | * If the child scope has a narrower lifecycle than [projectWideCoroutineScope] scope, 26 | * then it should be canceled explicitly when not needed, 27 | * otherwise, it will continue to live in the Job hierarchy until termination of the [CoroutineScopeHolder] service. 28 | */ 29 | @Suppress("UnstableApiUsage") 30 | fun createScope(name: String): CoroutineScope = projectWideCoroutineScope.childScope(name) 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/SearchState.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui 2 | 3 | import com.phodal.lotus.chat.ui.SearchState.* 4 | 5 | sealed class SearchState { 6 | object Idle : SearchState() 7 | 8 | data class Searching(val query: String) : SearchState() 9 | 10 | data class SearchResults( 11 | val query: String = "", 12 | // List of message IDs that match search 13 | val searchResultIds: List = emptyList(), 14 | val currentSelectedSearchResultIndex: Int = -1 15 | ) : SearchState() 16 | } 17 | 18 | val SearchState.isSearching: Boolean get() = this is Searching || this is SearchResults 19 | 20 | val SearchState.hasResults: Boolean get() = this is SearchResults && searchResultIds.isNotEmpty() 21 | 22 | val SearchState.totalResults: Int 23 | get() = when (this) { 24 | is Idle, is Searching -> -1 25 | is SearchResults -> searchResultIds.size 26 | } 27 | 28 | val SearchState.currentSearchResultIndex: Int 29 | get() = when (this) { 30 | is Idle, is Searching -> -1 31 | is SearchResults -> currentSelectedSearchResultIndex 32 | } 33 | 34 | val SearchState.searchQuery: String? 35 | get() = when (this) { 36 | is Idle -> null 37 | is Searching -> query 38 | is SearchResults -> query 39 | } 40 | 41 | val SearchState.searchResultIds: List 42 | get() = when (this) { 43 | is Idle -> emptyList() 44 | is Searching -> emptyList() 45 | is SearchResults -> searchResultIds 46 | } 47 | 48 | // Corresponds to chat message id 49 | val SearchState.currentSelectedSearchResultId: String? get() = searchResultIds.getOrNull(currentSearchResultIndex) 50 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html 2 | 3 | pluginGroup = com.phodal.lotus 4 | pluginName = Code Lotus, Native context intelligence with MCP & A2A 5 | pluginRepositoryUrl = https://github.com/phodal/autodev-lotus 6 | # SemVer format -> https://semver.org 7 | pluginVersion = 0.0.2 8 | 9 | # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html 10 | pluginSinceBuild = 252 11 | 12 | # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension 13 | platformType = IC 14 | platformVersion = 2025.2.1 15 | 16 | # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html 17 | # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP 18 | platformPlugins = 19 | # Example: platformBundledPlugins = com.intellij.java 20 | platformBundledPlugins = com.intellij.java, org.jetbrains.idea.maven, org.jetbrains.plugins.gradle 21 | # Example: platformBundledModules = intellij.spellchecker 22 | platformBundledModules = 23 | 24 | # Gradle Releases -> https://github.com/gradle/gradle/releases 25 | gradleVersion = 9.0.0 26 | 27 | # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib 28 | kotlin.stdlib.default.dependency = false 29 | 30 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html 31 | org.gradle.configuration-cache = true 32 | 33 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html 34 | org.gradle.caching = true 35 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/model/ChatMessage.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.model 2 | 3 | import com.phodal.lotus.chat.model.ChatMessage.ChatMessageType.AI_THINKING 4 | import com.phodal.lotus.chat.model.ChatMessage.ChatMessageType.TEXT 5 | import com.phodal.lotus.components.Searchable 6 | import java.time.LocalDateTime 7 | import java.time.format.DateTimeFormatter 8 | import java.util.* 9 | 10 | private val timeFormatter: DateTimeFormatter? = DateTimeFormatter.ofPattern("HH:mm") 11 | 12 | data class ChatMessage( 13 | val id: String = UUID.randomUUID().toString(), 14 | val content: String, 15 | val author: String, 16 | val isMyMessage: Boolean = false, 17 | val timestamp: LocalDateTime = LocalDateTime.now(), 18 | val type: ChatMessageType = TEXT, 19 | val format: MessageFormat = MessageFormat.MARKDOWN, 20 | val isStreaming: Boolean = false 21 | ) : Searchable { 22 | 23 | enum class ChatMessageType { 24 | AI_THINKING, 25 | TEXT; 26 | } 27 | 28 | /** 29 | * Enum representing different content formats for rendering. 30 | */ 31 | enum class MessageFormat { 32 | /** Plain text or Markdown content */ 33 | MARKDOWN, 34 | /** Mermaid diagram code */ 35 | MERMAID, 36 | /** Unified diff format */ 37 | DIFF 38 | } 39 | 40 | @JvmOverloads 41 | fun formattedTime(dateTimeFormatter: DateTimeFormatter? = timeFormatter): String { 42 | return timestamp.format(dateTimeFormatter) 43 | } 44 | 45 | 46 | fun isTextMessage(): Boolean = this.type == TEXT 47 | 48 | fun isAIThinkingMessage(): Boolean = this.type == AI_THINKING 49 | 50 | override fun matches(query: String): Boolean { 51 | if (query.isBlank()) return false 52 | 53 | return content.contains(query, ignoreCase = true) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/renderer/DiffRenderer.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui.renderer 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.text.font.FontFamily 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import org.jetbrains.jewel.foundation.theme.JewelTheme 16 | import org.jetbrains.jewel.ui.component.Text 17 | import com.phodal.lotus.chat.model.ChatMessage 18 | 19 | /** 20 | * Renderer for unified diff content. 21 | * TODO: Implement syntax highlighting for diff content. 22 | * For now, displays the diff in a styled code block. 23 | */ 24 | class DiffRenderer : MessageRenderer { 25 | @Composable 26 | override fun render(message: ChatMessage, modifier: Modifier) { 27 | Box( 28 | modifier = modifier 29 | .fillMaxWidth() 30 | .background(Color(0xFF2B2B2B), RoundedCornerShape(8.dp)) 31 | .padding(12.dp) 32 | ) { 33 | Text( 34 | text = "📝 Diff:\n\n${message.content}", 35 | style = JewelTheme.defaultTextStyle.copy( 36 | fontSize = 13.sp, 37 | fontWeight = FontWeight.Normal, 38 | fontFamily = FontFamily.Monospace, 39 | lineHeight = 18.sp 40 | ), 41 | color = Color(0xFFABB2BF) 42 | ) 43 | } 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/token/TokenUsageTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.junit.jupiter.api.Assertions.* 5 | 6 | class TokenUsageTest { 7 | 8 | @Test 9 | fun testTokenUsageCreation() { 10 | val usage = TokenUsage.of(100, 200, "gpt-4", "conv-123") 11 | 12 | assertEquals(100, usage.inputTokens) 13 | assertEquals(200, usage.outputTokens) 14 | assertEquals(300, usage.totalTokens) 15 | assertEquals("gpt-4", usage.modelName) 16 | assertEquals("conv-123", usage.conversationId) 17 | } 18 | 19 | @Test 20 | fun testTokenUsageAddition() { 21 | val usage1 = TokenUsage.of(100, 200, "gpt-4", "conv-123") 22 | val usage2 = TokenUsage.of(50, 75, "gpt-4", "conv-123") 23 | 24 | val combined = usage1 + usage2 25 | 26 | assertEquals(150, combined.inputTokens) 27 | assertEquals(275, combined.outputTokens) 28 | assertEquals(425, combined.totalTokens) 29 | } 30 | 31 | @Test 32 | fun testEmptyTokenUsage() { 33 | val empty = TokenUsage.EMPTY 34 | 35 | assertEquals(0, empty.inputTokens) 36 | assertEquals(0, empty.outputTokens) 37 | assertEquals(0, empty.totalTokens) 38 | } 39 | 40 | @Test 41 | fun testAggregatedTokenUsage() { 42 | val aggregated = AggregatedTokenUsage( 43 | totalInputTokens = 1000, 44 | totalOutputTokens = 2000, 45 | interactionCount = 10 46 | ) 47 | 48 | assertEquals(1000, aggregated.totalInputTokens) 49 | assertEquals(2000, aggregated.totalOutputTokens) 50 | assertEquals(3000, aggregated.totalTokens) 51 | assertEquals(10, aggregated.interactionCount) 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/renderer/MermaidRenderer.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui.renderer 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.text.font.FontFamily 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import org.jetbrains.jewel.foundation.theme.JewelTheme 16 | import org.jetbrains.jewel.ui.component.Text 17 | import com.phodal.lotus.chat.model.ChatMessage 18 | 19 | /** 20 | * Renderer for Mermaid diagram content. 21 | * TODO: Implement actual Mermaid diagram rendering. 22 | * For now, displays the code in a styled code block. 23 | */ 24 | class MermaidRenderer : MessageRenderer { 25 | @Composable 26 | override fun render(message: ChatMessage, modifier: Modifier) { 27 | Box( 28 | modifier = modifier 29 | .fillMaxWidth() 30 | .background(Color(0xFF2B2B2B), RoundedCornerShape(8.dp)) 31 | .padding(12.dp) 32 | ) { 33 | Text( 34 | text = "🎨 Mermaid Diagram:\n\n${message.content}", 35 | style = JewelTheme.defaultTextStyle.copy( 36 | fontSize = 13.sp, 37 | fontWeight = FontWeight.Normal, 38 | fontFamily = FontFamily.Monospace, 39 | lineHeight = 18.sp 40 | ), 41 | color = Color(0xFFABB2BF) 42 | ) 43 | } 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/streaming/StreamingCancellationToken.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.streaming 2 | 3 | import kotlinx.coroutines.CancellationException 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.isActive 6 | import kotlin.coroutines.coroutineContext 7 | 8 | /** 9 | * A token for controlling the cancellation of streaming operations. 10 | * This allows graceful interruption of AI response streaming while preserving 11 | * the content that has already been generated. 12 | */ 13 | class StreamingCancellationToken { 14 | private var isCancelled = false 15 | private var cancellationReason: String? = null 16 | 17 | /** 18 | * Request cancellation of the streaming operation. 19 | * @param reason Optional reason for cancellation 20 | */ 21 | fun cancel(reason: String? = null) { 22 | isCancelled = true 23 | cancellationReason = reason 24 | } 25 | 26 | /** 27 | * Check if cancellation has been requested. 28 | */ 29 | fun isCancellationRequested(): Boolean = isCancelled 30 | 31 | /** 32 | * Get the reason for cancellation if available. 33 | */ 34 | fun getCancellationReason(): String? = cancellationReason 35 | 36 | /** 37 | * Throw CancellationException if cancellation was requested. 38 | * This should be called periodically during streaming to check for cancellation. 39 | */ 40 | suspend fun throwIfCancellationRequested() { 41 | if (isCancelled) { 42 | throw CancellationException("Streaming cancelled: ${cancellationReason ?: "User requested"}") 43 | } 44 | } 45 | 46 | /** 47 | * Check if cancellation was requested without throwing. 48 | * Useful for graceful shutdown without exceptions. 49 | */ 50 | fun checkCancellation(): Boolean = isCancelled 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/components/ContextPopupMenu.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.onClick 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | import androidx.compose.ui.window.PopupPositionProvider 10 | import org.jetbrains.jewel.foundation.theme.JewelTheme 11 | import org.jetbrains.jewel.ui.component.Icon 12 | import org.jetbrains.jewel.ui.component.PopupContainer 13 | import org.jetbrains.jewel.ui.component.Text 14 | import org.jetbrains.jewel.ui.icon.IconKey 15 | 16 | @Composable 17 | fun ContextPopupMenu( 18 | popupPositionProvider: PopupPositionProvider, 19 | onDismissRequest: () -> Unit, 20 | content: @Composable () -> Unit, 21 | ) { 22 | PopupContainer( 23 | popupPositionProvider = popupPositionProvider, 24 | modifier = Modifier.wrapContentSize(), 25 | onDismissRequest = { onDismissRequest() }, 26 | horizontalAlignment = Alignment.Start 27 | ) { 28 | content() 29 | } 30 | } 31 | 32 | @Composable 33 | fun ContextPopupMenuItem( 34 | actionText: String, 35 | actionIcon: IconKey, 36 | onClick: () -> Unit, 37 | ) { 38 | Row( 39 | modifier = Modifier 40 | .widthIn(min = 100.dp) 41 | .padding(8.dp) 42 | .onClick { onClick() }, 43 | verticalAlignment = Alignment.CenterVertically 44 | ) { 45 | Icon( 46 | actionIcon, 47 | contentDescription = null, 48 | modifier = Modifier.size(16.dp) 49 | ) 50 | 51 | Spacer(modifier = Modifier.width(8.dp)) 52 | 53 | Text( 54 | text = actionText, 55 | style = JewelTheme.defaultTextStyle 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/AIServiceFactory.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore 2 | 3 | import com.phodal.lotus.aicore.client.AIClient 4 | import com.phodal.lotus.aicore.client.langchain.LangChain4jAIClient 5 | import com.phodal.lotus.aicore.config.LLMConfig 6 | import com.phodal.lotus.aicore.config.ConfigProvider 7 | 8 | /** 9 | * Factory for creating AI service instances 10 | * Works with any ConfigProvider implementation 11 | */ 12 | object AIServiceFactory { 13 | 14 | private var configProvider: ConfigProvider? = null 15 | private var aiClient: AIClient? = null 16 | 17 | /** 18 | * Initialize the AI service with a configuration provider 19 | */ 20 | fun initialize(configProvider: ConfigProvider) { 21 | this.configProvider = configProvider 22 | updateAIClient() 23 | } 24 | 25 | /** 26 | * Get the current AI client 27 | */ 28 | fun getAIClient(): AIClient? { 29 | return aiClient 30 | } 31 | 32 | /** 33 | * Create a new AI client with the given configuration 34 | */ 35 | fun createAIClient(config: LLMConfig): AIClient { 36 | val client = LangChain4jAIClient(config) 37 | aiClient = client 38 | return client 39 | } 40 | 41 | /** 42 | * Update the AI client based on the current configuration 43 | */ 44 | fun updateAIClient() { 45 | val config = configProvider?.currentConfig?.value 46 | if (config != null) { 47 | aiClient = LangChain4jAIClient(config) 48 | } 49 | } 50 | 51 | /** 52 | * Check if AI service is configured 53 | */ 54 | fun isConfigured(): Boolean { 55 | return configProvider?.isConfigured() == true && aiClient?.isConfigured() == true 56 | } 57 | 58 | /** 59 | * Get the configuration provider 60 | */ 61 | fun getConfigProvider(): ConfigProvider? { 62 | return configProvider 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/renderer/RendererRegistry.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui.renderer 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import com.phodal.lotus.chat.model.ChatMessage 6 | 7 | /** 8 | * Registry for managing message renderers. 9 | * Allows pluggable rendering of different message formats. 10 | */ 11 | object RendererRegistry { 12 | private val renderers = mutableMapOf() 13 | 14 | init { 15 | // Register default renderers 16 | register(ChatMessage.MessageFormat.MARKDOWN, MarkdownRenderer()) 17 | register(ChatMessage.MessageFormat.MERMAID, MermaidRenderer()) 18 | register(ChatMessage.MessageFormat.DIFF, DiffRenderer()) 19 | } 20 | 21 | /** 22 | * Registers a renderer for a specific message format. 23 | * @param format The message format to register the renderer for. 24 | * @param renderer The renderer implementation. 25 | */ 26 | fun register(format: ChatMessage.MessageFormat, renderer: MessageRenderer) { 27 | renderers[format] = renderer 28 | } 29 | 30 | /** 31 | * Gets the renderer for a specific message format. 32 | * Falls back to Markdown renderer if no specific renderer is found. 33 | * @param format The message format. 34 | * @return The renderer for the format. 35 | */ 36 | fun getRenderer(format: ChatMessage.MessageFormat): MessageRenderer { 37 | return renderers[format] ?: renderers[ChatMessage.MessageFormat.MARKDOWN]!! 38 | } 39 | 40 | /** 41 | * Renders a message using the appropriate renderer based on its format. 42 | * @param message The message to render. 43 | * @param modifier Optional modifier for the rendered content. 44 | */ 45 | @Composable 46 | fun renderMessage(message: ChatMessage, modifier: Modifier = Modifier) { 47 | val renderer = getRenderer(message.format) 48 | renderer.render(message, modifier) 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /.github/workflows/run-ui-tests.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: 2 | # - Prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with the UI. 3 | # - Wait for IDE to start. 4 | # - Run UI tests with a separate Gradle task. 5 | # 6 | # Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform. 7 | # 8 | # Workflow is triggered manually. 9 | 10 | name: Run UI Tests 11 | on: 12 | workflow_dispatch 13 | 14 | jobs: 15 | 16 | testUI: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - os: ubuntu-latest 23 | runIde: | 24 | export DISPLAY=:99.0 25 | Xvfb -ac :99 -screen 0 1920x1080x16 & 26 | gradle runIdeForUiTests & 27 | - os: windows-latest 28 | runIde: start gradlew.bat runIdeForUiTests 29 | - os: macos-latest 30 | runIde: ./gradlew runIdeForUiTests & 31 | 32 | steps: 33 | 34 | # Check out the current repository 35 | - name: Fetch Sources 36 | uses: actions/checkout@v5 37 | 38 | # Set up the Java environment for the next steps 39 | - name: Setup Java 40 | uses: actions/setup-java@v5 41 | with: 42 | distribution: zulu 43 | java-version: 21 44 | 45 | # Setup Gradle 46 | - name: Setup Gradle 47 | uses: gradle/actions/setup-gradle@v5 48 | with: 49 | cache-read-only: true 50 | 51 | # Run IDEA prepared for UI testing 52 | - name: Run IDE 53 | run: ${{ matrix.runIde }} 54 | 55 | # Wait for IDEA to be started 56 | - name: Health Check 57 | uses: jtalk/url-health-check-action@v4 58 | with: 59 | url: http://127.0.0.1:8082 60 | max-attempts: 15 61 | retry-delay: 30s 62 | 63 | # Run tests 64 | - name: Tests 65 | run: ./gradlew test 66 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/streaming/StreamingCancellationTokenTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.streaming 2 | 3 | import kotlinx.coroutines.CancellationException 4 | import kotlinx.coroutines.runBlocking 5 | import org.junit.jupiter.api.Test 6 | import org.junit.jupiter.api.Assertions.* 7 | 8 | class StreamingCancellationTokenTest { 9 | 10 | @Test 11 | fun testInitialState() { 12 | val token = StreamingCancellationToken() 13 | assertFalse(token.isCancellationRequested()) 14 | assertFalse(token.checkCancellation()) 15 | } 16 | 17 | @Test 18 | fun testCancelWithoutReason() { 19 | val token = StreamingCancellationToken() 20 | token.cancel() 21 | 22 | assertTrue(token.isCancellationRequested()) 23 | assertTrue(token.checkCancellation()) 24 | } 25 | 26 | @Test 27 | fun testCancelWithReason() { 28 | val token = StreamingCancellationToken() 29 | val reason = "User interrupted" 30 | token.cancel(reason) 31 | 32 | assertTrue(token.isCancellationRequested()) 33 | assertEquals(reason, token.getCancellationReason()) 34 | } 35 | 36 | @Test 37 | fun testThrowIfCancellationRequested() { 38 | val token = StreamingCancellationToken() 39 | token.cancel("Test cancellation") 40 | 41 | var exceptionThrown = false 42 | try { 43 | runBlocking { 44 | token.throwIfCancellationRequested() 45 | } 46 | } catch (e: CancellationException) { 47 | exceptionThrown = true 48 | } 49 | 50 | assertTrue(exceptionThrown) 51 | } 52 | 53 | @Test 54 | fun testNoExceptionWhenNotCancelled() { 55 | val token = StreamingCancellationToken() 56 | 57 | var exceptionThrown = false 58 | try { 59 | runBlocking { 60 | token.throwIfCancellationRequested() 61 | } 62 | } catch (e: CancellationException) { 63 | exceptionThrown = true 64 | } 65 | 66 | assertFalse(exceptionThrown) 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/config/LLMConfig.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.config 2 | 3 | /** 4 | * Supported LLM providers 5 | */ 6 | enum class LLMProvider { 7 | DEEPSEEK, 8 | OPENAI, 9 | CLAUDE, 10 | GEMINI 11 | } 12 | 13 | /** 14 | * LLM Configuration 15 | */ 16 | data class LLMConfig( 17 | val provider: LLMProvider, 18 | val apiKey: String, 19 | val model: String = getDefaultModel(provider), 20 | val temperature: Double = 0.7, 21 | val maxTokens: Int = 2000 22 | ) { 23 | companion object { 24 | /** 25 | * Get available models for a provider (updated to commonly used, current models) 26 | */ 27 | fun getAvailableModels(provider: LLMProvider): List = when (provider) { 28 | LLMProvider.DEEPSEEK -> listOf( 29 | "deepseek-chat", 30 | "deepseek-coder", 31 | // Reasoning-capable model 32 | "deepseek-reasoner" 33 | ) 34 | LLMProvider.OPENAI -> listOf( 35 | "gpt-5", 36 | "gpt-4.5", 37 | "gpt-4o", 38 | "gpt-4o-mini", 39 | "gpt-4-turbo" 40 | ) 41 | LLMProvider.CLAUDE -> listOf( 42 | "claude-4.5-sonnet-latest", 43 | // Use Anthropic "-latest" aliases where available to track current releases 44 | "claude-3.7-sonnet-latest", 45 | "claude-3.5-sonnet-latest", 46 | "claude-3-opus-latest", 47 | "claude-3-haiku-latest" 48 | ) 49 | LLMProvider.GEMINI -> listOf( 50 | "gemini-2.5-pro", 51 | "gemini-2.0-pro", 52 | "gemini-2.0-flash", 53 | "gemini-1.5-pro", 54 | "gemini-1.5-flash" 55 | ) 56 | } 57 | 58 | /** 59 | * Get default model for a provider 60 | */ 61 | fun getDefaultModel(provider: LLMProvider): String = when (provider) { 62 | LLMProvider.DEEPSEEK -> "deepseek-chat" 63 | LLMProvider.OPENAI -> "gpt-5" 64 | // Prefer the alias to follow the latest Sonnet 4.5 drop automatically 65 | LLMProvider.CLAUDE -> "claude-4.5-sonnet-latest" 66 | LLMProvider.GEMINI -> "gemini-2.5-pro" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/client/LangChain4jAIClientTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.client 2 | 3 | import com.phodal.lotus.aicore.client.langchain.LangChain4jAIClient 4 | import com.phodal.lotus.aicore.config.LLMConfig 5 | import com.phodal.lotus.aicore.config.LLMProvider 6 | import org.junit.jupiter.api.Test 7 | import org.junit.jupiter.api.Assertions.* 8 | import org.junit.jupiter.api.BeforeEach 9 | 10 | class LangChain4jAIClientTest { 11 | 12 | private lateinit var client: LangChain4jAIClient 13 | 14 | @BeforeEach 15 | fun setUp() { 16 | // Create a test configuration with a dummy API key 17 | val config = LLMConfig( 18 | provider = LLMProvider.DEEPSEEK, 19 | apiKey = "test-api-key", 20 | model = "deepseek-chat" 21 | ) 22 | client = LangChain4jAIClient(config) 23 | } 24 | 25 | @Test 26 | fun testClientIsConfigured() { 27 | assertTrue(client.isConfigured()) 28 | } 29 | 30 | @Test 31 | fun testClientNotConfiguredWithEmptyKey() { 32 | val config = LLMConfig( 33 | provider = LLMProvider.OPENAI, 34 | apiKey = "", 35 | model = "gpt-4o" 36 | ) 37 | val unconfiguredClient = LangChain4jAIClient(config) 38 | assertFalse(unconfiguredClient.isConfigured()) 39 | } 40 | 41 | @Test 42 | fun testClientCreationWithDifferentProviders() { 43 | val providers = listOf( 44 | LLMProvider.DEEPSEEK, 45 | LLMProvider.OPENAI, 46 | LLMProvider.CLAUDE, 47 | LLMProvider.GEMINI 48 | ) 49 | 50 | providers.forEach { provider -> 51 | val config = LLMConfig( 52 | provider = provider, 53 | apiKey = "test-key-$provider", 54 | model = LLMConfig.getDefaultModel(provider) 55 | ) 56 | val testClient = LangChain4jAIClient(config) 57 | assertTrue(testClient.isConfigured()) 58 | } 59 | } 60 | 61 | @Test 62 | fun testDefaultModels() { 63 | assertEquals("deepseek-chat", LLMConfig.getDefaultModel(LLMProvider.DEEPSEEK)) 64 | assertEquals("gpt-5", LLMConfig.getDefaultModel(LLMProvider.OPENAI)) 65 | assertEquals("claude-4.5-sonnet-latest", LLMConfig.getDefaultModel(LLMProvider.CLAUDE)) 66 | assertEquals("gemini-2.5-pro", LLMConfig.getDefaultModel(LLMProvider.GEMINI)) 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/repository/ChatRepository.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.repository 2 | 3 | import com.phodal.lotus.chat.model.ChatMessage 4 | import kotlinx.coroutines.flow.* 5 | 6 | /** 7 | * Interface defining the contract for managing chat messages and interactions within a chat system. 8 | * Provides access to the flow of messages and supports operations for sending and editing chat messages. 9 | */ 10 | interface ChatRepositoryApi { 11 | /** 12 | * Flow that emits a list of chat messages. 13 | * Updates with new messages as they are received or edited. 14 | */ 15 | val messagesFlow: StateFlow> 16 | 17 | /** 18 | * Sealed interface representing streaming events during message sending. 19 | */ 20 | sealed interface ChatStreamEvent { 21 | /** 22 | * Emitted when AI response streaming starts. 23 | * @param aiMessageId The unique ID of the AI message being streamed. 24 | */ 25 | data class Started(val aiMessageId: String) : ChatStreamEvent 26 | 27 | /** 28 | * Emitted when a chunk of content is received. 29 | * @param aiMessageId The unique ID of the AI message. 30 | * @param delta The incremental content chunk received. 31 | * @param fullContent The accumulated full content so far. 32 | */ 33 | data class Delta(val aiMessageId: String, val delta: String, val fullContent: String) : ChatStreamEvent 34 | 35 | /** 36 | * Emitted when streaming completes successfully. 37 | * @param aiMessageId The unique ID of the AI message. 38 | * @param fullContent The complete final content. 39 | */ 40 | data class Completed(val aiMessageId: String, val fullContent: String) : ChatStreamEvent 41 | 42 | /** 43 | * Emitted when an error occurs during streaming. 44 | * @param aiMessageId The unique ID of the AI message (if available). 45 | * @param throwable The error that occurred. 46 | */ 47 | data class Error(val aiMessageId: String?, val throwable: Throwable) : ChatStreamEvent 48 | } 49 | 50 | /** 51 | * Sends a message with the provided content and returns a flow of streaming events. 52 | * 53 | * @param messageContent The content of the message to be sent. 54 | * @return Flow of ChatStreamEvent representing the streaming progress. 55 | */ 56 | suspend fun sendMessage(messageContent: String): kotlinx.coroutines.flow.Flow 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ChatAppColors.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.graphics.Color 5 | import org.jetbrains.jewel.foundation.theme.JewelTheme 6 | import org.jetbrains.jewel.ui.theme.defaultBannerStyle 7 | 8 | object ChatAppColors { 9 | object Panel { 10 | val background: Color 11 | @Composable get() = JewelTheme.globalColors.panelBackground 12 | } 13 | 14 | object Text { 15 | val disabled: Color 16 | @Composable get() = JewelTheme.globalColors.text.disabled 17 | 18 | val normal: Color 19 | @Composable get() = JewelTheme.globalColors.text.normal 20 | 21 | // Misc labels 22 | val timestamp: Color = Color.LightGray.copy(alpha = 0.8f) 23 | 24 | val authorName: Color = Color(0xDBE0EBFF) 25 | } 26 | 27 | object MessageBubble { 28 | // Backgrounds 29 | val myBackground: Color 30 | @Composable get() = JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.75f) 31 | 32 | val othersBackground: Color 33 | @Composable get() = JewelTheme.defaultBannerStyle.success.colors.background.copy(alpha = 0.75f) 34 | 35 | // Borders 36 | val myBackgroundBorder: Color 37 | @Composable get() = JewelTheme.defaultBannerStyle.information.colors.border 38 | 39 | val othersBackgroundBorder: Color 40 | @Composable get() = JewelTheme.defaultBannerStyle.success.colors.border 41 | 42 | // Search highlight state 43 | val mySearchHighlightedBackground: Color 44 | @Composable get() = JewelTheme.defaultBannerStyle.information.colors.background 45 | 46 | // Search highlight state 47 | val othersSearchHighlightedBackground: Color 48 | @Composable get() = JewelTheme.defaultBannerStyle.success.colors.background 49 | 50 | val searchHighlightedBackgroundBorder: Color = Color(0xFFDF9303) 51 | 52 | val matchingMyBorder: Color 53 | @Composable get() = JewelTheme.defaultBannerStyle.information.colors.border.copy(alpha = 0.75f) 54 | 55 | val matchingOthersBorder: Color 56 | @Composable get() = JewelTheme.defaultBannerStyle.success.colors.border.copy(alpha = 0.75f) 57 | 58 | } 59 | 60 | object Prompt { 61 | val border: Color = Color.White 62 | } 63 | 64 | object Icon { 65 | val enabledIconTint: Color = Color.White 66 | val disabledIconTint: Color = Color.Gray 67 | val stopIconTint: Color = Color.White 68 | } 69 | } -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/context/summarization/ConversationSummarizer.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.context.summarization 2 | 3 | import com.phodal.lotus.aicore.token.TokenUsage 4 | 5 | /** 6 | * Represents a message in the conversation for summarization 7 | */ 8 | data class ConversationMessage( 9 | val id: String, 10 | val content: String, 11 | val author: String, 12 | val isUserMessage: Boolean, 13 | val timestamp: Long 14 | ) 15 | 16 | /** 17 | * Result of conversation summarization 18 | */ 19 | data class SummarizationResult( 20 | val summary: String, 21 | val tokenUsage: TokenUsage? = null, 22 | val messagesIncluded: Int = 0, 23 | val originalTokenCount: Int = 0, 24 | val summaryTokenCount: Int = 0 25 | ) 26 | 27 | /** 28 | * Configuration for conversation summarization 29 | */ 30 | data class SummarizationConfig( 31 | /** 32 | * Maximum tokens to use for the summary 33 | */ 34 | val maxSummaryTokens: Int = 500, 35 | 36 | /** 37 | * Maximum tokens to include from the conversation history 38 | */ 39 | val maxContextTokens: Int = 2000, 40 | 41 | /** 42 | * Whether to prioritize user messages 43 | */ 44 | val prioritizeUserMessages: Boolean = true, 45 | 46 | /** 47 | * System prompt for summarization 48 | */ 49 | val systemPrompt: String = DEFAULT_SYSTEM_PROMPT 50 | ) { 51 | companion object { 52 | val DEFAULT_SYSTEM_PROMPT = """ 53 | You are a helpful assistant that summarizes conversations concisely. 54 | Focus on the main topics, key decisions, and important information. 55 | Keep the summary clear and organized. 56 | Do not include unnecessary details or repetitions. 57 | """.trimIndent() 58 | } 59 | } 60 | 61 | /** 62 | * Interface for conversation summarization 63 | * 64 | * This interface defines the contract for summarizing conversations using AI. 65 | * Implementations should handle token counting and content selection to ensure 66 | * the summary fits within token limits while preserving important information. 67 | */ 68 | interface ConversationSummarizer { 69 | /** 70 | * Summarize a conversation 71 | * 72 | * @param messages The messages to summarize 73 | * @param config Configuration for summarization 74 | * @return SummarizationResult containing the summary and metadata 75 | */ 76 | suspend fun summarize( 77 | messages: List, 78 | config: SummarizationConfig = SummarizationConfig() 79 | ): SummarizationResult 80 | 81 | /** 82 | * Check if the summarizer is ready to use 83 | */ 84 | fun isReady(): Boolean 85 | } 86 | 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # autodev-lotus 2 | 3 | ![Build](https://github.com/phodal/autodev-lotus/workflows/Build/badge.svg) 4 | [![Version](https://img.shields.io/jetbrains/plugin/v/28853.svg)](https://plugins.jetbrains.com/plugin/28853) 5 | [![Downloads](https://img.shields.io/jetbrains/plugin/d/28853.svg)](https://plugins.jetbrains.com/plugin/28853) 6 | 7 | 8 | AutoDev CodeLotus is a context-intelligence middleware layer designed to serve as the “context brain” for 9 | AI-driven coding tools. Rather than being another end-user code-assistant, CodeLotus provides upstream capabilities — 10 | rich semantic context, version-history awareness, graph‐based relationships and context-compression services — enabling 11 | other AI coding tools to be far more effective and accurate. 12 | 13 | 14 | Key Value Proposition: 15 | 16 | * **MCP & A2A native** — built for model-to-tool and agent-to-agent interoperability, enabling seamless context sharing across AI assistants. 17 | * **Deep semantic understanding** — parses ASTs, dependencies, and code graphs to deliver structured, high-fidelity context. 18 | * **History-aware** — integrates Git evolution and refactor tracking for time-aware contextual insight. 19 | * **Graph + compression engine** — extracts, ranks, and compresses relevant context into model-ready blocks. 20 | * **Shared context fabric** — one unified context backbone for all AI coding tools and agents. 21 | 22 | ## Installation 23 | 24 | - Using the IDE built-in plugin system: 25 | 26 | Settings/Preferences > Plugins > Marketplace > Search for "autodev-lotus" > 27 | Install 28 | 29 | - Using JetBrains Marketplace: 30 | 31 | Go to [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID) and install it by clicking the Install to ... button in case your IDE is running. 32 | 33 | You can also download the [latest release](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID/versions) from JetBrains Marketplace and install it manually using 34 | Settings/Preferences > Plugins > ⚙️ > Install plugin from disk... 35 | 36 | - Manually: 37 | 38 | Download the [latest release](https://github.com/phodal/autodev-lotus/releases/latest) and install it manually using 39 | Settings/Preferences > Plugins > ⚙️ > Install plugin from disk... 40 | 41 | 42 | --- 43 | Plugin based on the [IntelliJ Platform Plugin Template][template]. 44 | 45 | [template]: https://github.com/JetBrains/intellij-platform-plugin-template 46 | [docs:plugin-description]: https://plugins.jetbrains.com/docs/intellij/plugin-user-experience.html#plugin-description-and-presentation 47 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/token/JtokKitTokenCounterTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.junit.jupiter.api.Assertions.* 5 | 6 | class JtokKitTokenCounterTest { 7 | 8 | @Test 9 | fun testJtokKitTokenCounterCreation() { 10 | val counter = JtokKitTokenCounter("gpt-4") 11 | assertEquals("gpt-4", counter.getModelName()) 12 | } 13 | 14 | @Test 15 | fun testTokenCountingForGPT4() { 16 | val counter = JtokKitTokenCounter("gpt-4") 17 | 18 | val text = "Hello world this is a test" 19 | val tokenCount = counter.estimateTokenCount(text) 20 | 21 | // Should return a positive number 22 | assertTrue(tokenCount > 0) 23 | println("Token count for '$text': $tokenCount") 24 | } 25 | 26 | @Test 27 | fun testTokenCountingForClaude() { 28 | val counter = JtokKitTokenCounter("claude-3-5-sonnet-latest") 29 | 30 | val text = "This is a test message for Claude" 31 | val tokenCount = counter.estimateTokenCount(text) 32 | 33 | assertTrue(tokenCount > 0) 34 | println("Token count for Claude: $tokenCount") 35 | } 36 | 37 | @Test 38 | fun testTokenCountingForDeepSeek() { 39 | val counter = JtokKitTokenCounter("deepseek-chat") 40 | 41 | val text = "DeepSeek model test" 42 | val tokenCount = counter.estimateTokenCount(text) 43 | 44 | assertTrue(tokenCount > 0) 45 | println("Token count for DeepSeek: $tokenCount") 46 | } 47 | 48 | @Test 49 | fun testEmptyTextTokenCount() { 50 | val counter = JtokKitTokenCounter("gpt-4") 51 | 52 | assertEquals(0, counter.estimateTokenCount("")) 53 | assertEquals(0, counter.estimateTokenCount(" ")) 54 | } 55 | 56 | @Test 57 | fun testLongerTextTokenCount() { 58 | val counter = JtokKitTokenCounter("gpt-4") 59 | 60 | val shortText = "Hello" 61 | val longText = "Hello world this is a much longer text that should have more tokens than the short text" 62 | 63 | val shortTokens = counter.estimateTokenCount(shortText) 64 | val longTokens = counter.estimateTokenCount(longText) 65 | 66 | assertTrue(longTokens > shortTokens) 67 | println("Short text tokens: $shortTokens, Long text tokens: $longTokens") 68 | } 69 | 70 | @Test 71 | fun testMultipleMessagesTokenCount() { 72 | val counter = JtokKitTokenCounter("gpt-4") 73 | 74 | val messages = listOf( 75 | "Hello, how are you?", 76 | "I'm doing great, thanks for asking!", 77 | "That's wonderful to hear." 78 | ) 79 | 80 | val totalTokens = counter.estimateTokenCount(messages) 81 | 82 | assertTrue(totalTokens > 0) 83 | println("Total tokens for multiple messages: $totalTokens") 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/repository/ChatMessageFactory.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.repository 2 | 3 | import com.phodal.lotus.chat.model.ChatMessage 4 | import java.time.LocalDateTime 5 | 6 | 7 | /** 8 | * Factory class responsible for creating instances of `ChatMessage`. 9 | * 10 | * @param aiCompanionName The name of the AI companion, used as the author for AI-generated messages. 11 | * @param myUserName The name of the user, used as the author for user-generated messages. 12 | */ 13 | class ChatMessageFactory( 14 | private val aiCompanionName: String, 15 | private val myUserName: String 16 | ) { 17 | 18 | /** 19 | * Creates a new instance of `ChatMessage` representing an AI-generated message emitted 20 | * while AI is processing the request. 21 | * 22 | * @param content The content of the message. 23 | * @param id The unique identifier for the message. Defaults to a new UUID. 24 | * @param timestamp The timestamp of the message. Defaults to the current time. 25 | */ 26 | fun createAIThinkingMessage( 27 | content: String, 28 | id: String = java.util.UUID.randomUUID().toString(), 29 | timestamp: LocalDateTime = LocalDateTime.now(), 30 | ): ChatMessage { 31 | return ChatMessage( 32 | id = id, 33 | content = content, 34 | author = aiCompanionName, 35 | timestamp = timestamp, 36 | isMyMessage = false, 37 | type = ChatMessage.ChatMessageType.AI_THINKING 38 | ) 39 | } 40 | 41 | /** 42 | * Creates a new instance of `ChatMessage` representing an AI-generated message response. 43 | * 44 | * @param content The content of the message. 45 | * @param id The unique identifier for the message. Defaults to a new UUID. 46 | * @param timestamp The timestamp of the message. Defaults to the current time. 47 | */ 48 | fun createAIMessage( 49 | content: String, 50 | id: String = java.util.UUID.randomUUID().toString(), 51 | timestamp: LocalDateTime = LocalDateTime.now(), 52 | ): ChatMessage { 53 | return ChatMessage( 54 | id = id, 55 | content = content, 56 | author = aiCompanionName, 57 | timestamp = timestamp, 58 | isMyMessage = false, 59 | type = ChatMessage.ChatMessageType.TEXT 60 | ) 61 | } 62 | 63 | /** 64 | * Creates a new instance of `ChatMessage` representing a user message. 65 | * 66 | * @param content The content of the message. 67 | * @param timestamp The timestamp of the message. Defaults to the current time. 68 | */ 69 | fun createUserMessage(content: String, timestamp: LocalDateTime = LocalDateTime.now()): ChatMessage { 70 | return ChatMessage( 71 | content = content, 72 | author = myUserName, 73 | timestamp = timestamp, 74 | isMyMessage = true, 75 | type = ChatMessage.ChatMessageType.TEXT 76 | ) 77 | } 78 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/config/AIConfigService.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.config 2 | 3 | import com.intellij.openapi.components.PersistentStateComponent 4 | import com.intellij.openapi.components.Service 5 | import com.intellij.openapi.components.State 6 | import com.intellij.openapi.components.Storage 7 | import com.intellij.openapi.components.service 8 | import com.intellij.util.xmlb.XmlSerializerUtil 9 | import com.phodal.lotus.aicore.config.LLMConfig 10 | import com.phodal.lotus.aicore.config.LLMProvider 11 | import com.phodal.lotus.aicore.config.ConfigProvider 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.asStateFlow 15 | 16 | /** 17 | * IntelliJ Platform service for persisting AI configuration 18 | * Uses PersistentStateComponent to store configuration in IDE settings 19 | */ 20 | @Service 21 | @State( 22 | name = "AIConfigService", 23 | storages = [Storage("ai-config.xml")] 24 | ) 25 | class AIConfigService : PersistentStateComponent, ConfigProvider { 26 | 27 | data class State( 28 | var provider: String = "", 29 | var apiKey: String = "", 30 | var model: String = "", 31 | var temperature: Double = 0.7, 32 | var maxTokens: Int = 2000 33 | ) 34 | 35 | private var state = State() 36 | 37 | private val _currentConfig = MutableStateFlow(null) 38 | override val currentConfig: StateFlow = _currentConfig.asStateFlow() 39 | 40 | init { 41 | loadConfigFromState() 42 | } 43 | 44 | private fun loadConfigFromState() { 45 | if (state.provider.isNotBlank() && state.apiKey.isNotBlank()) { 46 | try { 47 | val provider = LLMProvider.valueOf(state.provider) 48 | val config = LLMConfig( 49 | provider = provider, 50 | apiKey = state.apiKey, 51 | model = state.model.ifBlank { LLMConfig.getDefaultModel(provider) }, 52 | temperature = state.temperature, 53 | maxTokens = state.maxTokens 54 | ) 55 | _currentConfig.value = config 56 | } catch (e: Exception) { 57 | e.printStackTrace() 58 | } 59 | } 60 | } 61 | 62 | fun saveConfig(config: LLMConfig) { 63 | state.provider = config.provider.name 64 | state.apiKey = config.apiKey 65 | state.model = config.model 66 | state.temperature = config.temperature 67 | state.maxTokens = config.maxTokens 68 | _currentConfig.value = config 69 | } 70 | 71 | fun clearConfig() { 72 | state = State() 73 | _currentConfig.value = null 74 | } 75 | 76 | override fun isConfigured(): Boolean = _currentConfig.value != null 77 | 78 | override fun getState(): State = state 79 | 80 | override fun loadState(state: State) { 81 | XmlSerializerUtil.copyBean(state, this.state) 82 | loadConfigFromState() 83 | } 84 | 85 | companion object { 86 | fun getInstance(): AIConfigService { 87 | return service() 88 | } 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/token/JtokKitTokenCounter.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | /** 4 | * Token counter using jtokkit library for accurate token counting 5 | * 6 | * This implementation provides precise token counting for different LLM models 7 | * using the jtokkit library which implements the same tokenization as OpenAI's tiktoken. 8 | * 9 | * Note: This class uses reflection to load jtokkit at runtime to make it optional. 10 | * If jtokkit is not available, it falls back to SimpleTokenCounter. 11 | * 12 | * Supported models: 13 | * - OpenAI: gpt-4, gpt-4-turbo, gpt-3.5-turbo, etc. 14 | * - Claude: Uses cl100k_base encoding (compatible with OpenAI) 15 | * - DeepSeek: Uses cl100k_base encoding (compatible with OpenAI) 16 | * - Gemini: Uses cl100k_base encoding (approximation) 17 | */ 18 | class JtokKitTokenCounter(private val modelName: String) : TokenCounter { 19 | 20 | private val delegate: TokenCounter = try { 21 | createJtokKitCounter(modelName) 22 | } catch (e: Exception) { 23 | // Fallback to SimpleTokenCounter if jtokkit is not available 24 | FallbackTokenCounter(modelName) 25 | } 26 | 27 | override fun estimateTokenCount(text: String): Int { 28 | return delegate.estimateTokenCount(text) 29 | } 30 | 31 | override fun getModelName(): String = modelName 32 | 33 | companion object { 34 | /** 35 | * Create a jtokkit-based token counter using reflection 36 | */ 37 | private fun createJtokKitCounter(modelName: String): TokenCounter { 38 | try { 39 | // Load jtokkit classes using reflection 40 | val encodingsClass = Class.forName("com.knuddelsgmbh.jtokkit.Encodings") 41 | val encodingTypeClass = Class.forName("com.knuddelsgmbh.jtokkit.api.EncodingType") 42 | 43 | // Get the newDefaultEncodingRegistry method 44 | val newRegistryMethod = encodingsClass.getMethod("newDefaultEncodingRegistry") 45 | val registry = newRegistryMethod.invoke(null) 46 | 47 | // Get the CL100K_BASE encoding type 48 | val cl100kField = encodingTypeClass.getField("CL100K_BASE") 49 | val encodingType = cl100kField.get(null) 50 | 51 | // Get the encoding 52 | val getEncodingMethod = registry.javaClass.getMethod("getEncoding", encodingTypeClass) 53 | val encoding = getEncodingMethod.invoke(registry, encodingType) 54 | 55 | // Create a wrapper that uses the encoding 56 | return JtokKitEncodingWrapper(encoding, modelName) 57 | } catch (e: Exception) { 58 | throw RuntimeException("Failed to initialize jtokkit token counter", e) 59 | } 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * Wrapper for jtokkit Encoding using reflection 66 | */ 67 | private class JtokKitEncodingWrapper(private val encoding: Any, private val modelName: String) : TokenCounter { 68 | 69 | override fun estimateTokenCount(text: String): Int { 70 | if (text.isBlank()) return 0 71 | return try { 72 | val countTokensMethod = encoding.javaClass.getMethod("countTokens", String::class.java) 73 | (countTokensMethod.invoke(encoding, text) as Number).toInt() 74 | } catch (e: Exception) { 75 | // Fallback to simple estimation 76 | (text.split(Regex("\\s+")).size / 0.75).toInt() 77 | } 78 | } 79 | 80 | override fun getModelName(): String = modelName 81 | } 82 | 83 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/token/TokenUsage.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | import java.time.Instant 4 | 5 | /** 6 | * Represents token usage statistics for a single LLM interaction 7 | * 8 | * This data class captures the token consumption details for both input and output, 9 | * which is essential for cost tracking and usage monitoring. 10 | */ 11 | data class TokenUsage( 12 | /** 13 | * Number of tokens in the input/prompt 14 | */ 15 | val inputTokens: Int = 0, 16 | 17 | /** 18 | * Number of tokens in the output/response 19 | */ 20 | val outputTokens: Int = 0, 21 | 22 | /** 23 | * Total number of tokens (input + output) 24 | */ 25 | val totalTokens: Int = inputTokens + outputTokens, 26 | 27 | /** 28 | * Timestamp when this usage was recorded 29 | */ 30 | val timestamp: Instant = Instant.now(), 31 | 32 | /** 33 | * Optional model name that was used 34 | */ 35 | val modelName: String? = null, 36 | 37 | /** 38 | * Optional conversation/session ID for grouping 39 | */ 40 | val conversationId: String? = null 41 | ) { 42 | companion object { 43 | /** 44 | * Empty token usage (no tokens consumed) 45 | */ 46 | val EMPTY = TokenUsage(0, 0, 0) 47 | 48 | /** 49 | * Create a TokenUsage from input and output counts 50 | */ 51 | fun of(inputTokens: Int, outputTokens: Int, modelName: String? = null, conversationId: String? = null): TokenUsage { 52 | return TokenUsage( 53 | inputTokens = inputTokens, 54 | outputTokens = outputTokens, 55 | totalTokens = inputTokens + outputTokens, 56 | modelName = modelName, 57 | conversationId = conversationId 58 | ) 59 | } 60 | } 61 | 62 | /** 63 | * Combine this token usage with another 64 | */ 65 | operator fun plus(other: TokenUsage): TokenUsage { 66 | return TokenUsage( 67 | inputTokens = this.inputTokens + other.inputTokens, 68 | outputTokens = this.outputTokens + other.outputTokens, 69 | totalTokens = this.totalTokens + other.totalTokens, 70 | timestamp = this.timestamp, // Keep the earlier timestamp 71 | modelName = this.modelName ?: other.modelName, 72 | conversationId = this.conversationId ?: other.conversationId 73 | ) 74 | } 75 | } 76 | 77 | /** 78 | * Aggregated token usage statistics 79 | * Useful for displaying cumulative usage over time or across conversations 80 | */ 81 | data class AggregatedTokenUsage( 82 | /** 83 | * Total input tokens across all interactions 84 | */ 85 | val totalInputTokens: Long = 0, 86 | 87 | /** 88 | * Total output tokens across all interactions 89 | */ 90 | val totalOutputTokens: Long = 0, 91 | 92 | /** 93 | * Total tokens (input + output) 94 | */ 95 | val totalTokens: Long = totalInputTokens + totalOutputTokens, 96 | 97 | /** 98 | * Number of LLM interactions/calls 99 | */ 100 | val interactionCount: Int = 0, 101 | 102 | /** 103 | * Breakdown by model name 104 | */ 105 | val byModel: Map = emptyMap(), 106 | 107 | /** 108 | * Breakdown by conversation ID 109 | */ 110 | val byConversation: Map = emptyMap() 111 | ) { 112 | companion object { 113 | val EMPTY = AggregatedTokenUsage() 114 | } 115 | } 116 | 117 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # libraries 3 | junit = "4.13.2" 4 | opentest4j = "1.3.0" 5 | 6 | # plugins 7 | changelog = "2.4.0" 8 | intelliJPlatform = "2.10.2" 9 | kotlin = "2.2.21" 10 | kover = "0.9.3" 11 | qodana = "2025.2.1" 12 | 13 | hamcrest = "2.2" 14 | # Has to be in sync with IntelliJ Platform 15 | composeuitest = "1.8.0-alpha04" 16 | jewelstandalone = "0.31.0-252.27409" 17 | skikoAwtRuntimeAll = "0.9.22" 18 | mockk = "1.13.13" 19 | coroutinesTest = "1.10.1" 20 | 21 | # MCP and Ktor 22 | mcp = "0.7.4" 23 | ktor = "3.3.1" 24 | 25 | # Xodus database 26 | xodus = "2.0.1" 27 | 28 | # Kotlinx serialization 29 | kotlinxSerialization = "1.7.3" 30 | 31 | # LangChain4j 32 | langchain4j = "1.7.1" 33 | 34 | # Kotlin coroutines 35 | kotlinxCoroutines = "1.10.1" 36 | 37 | # Token counting 38 | jtokkit = "1.1.0" 39 | 40 | [libraries] 41 | junit = { group = "junit", name = "junit", version.ref = "junit" } 42 | opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } 43 | hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } 44 | composeuitest = { group = "org.jetbrains.compose.ui", name = "ui-test-junit4-desktop", version.ref = "composeuitest" } 45 | jewelstandalone = { group = "org.jetbrains.jewel", name = "jewel-int-ui-standalone", version.ref = "jewelstandalone" } 46 | skikoAwtRuntimeAll = { group = "org.jetbrains.skiko", name = "skiko-awt-runtime-all", version.ref = "skikoAwtRuntimeAll" } 47 | mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } 48 | 49 | # MCP and Ktor 50 | mcp = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp" } 51 | ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } 52 | ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } 53 | coroutinesTest = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } 54 | 55 | # Xodus database 56 | xodusOpenapi = { group = "org.jetbrains.xodus", name = "xodus-openAPI", version.ref = "xodus" } 57 | xodusEntityStore = { group = "org.jetbrains.xodus", name = "xodus-entity-store", version.ref = "xodus" } 58 | xodusEnvironment = { group = "org.jetbrains.xodus", name = "xodus-environment", version.ref = "xodus" } 59 | 60 | # Kotlinx serialization 61 | kotlinxSerializationJson = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } 62 | 63 | # LangChain4j 64 | langchain4j = { group = "dev.langchain4j", name = "langchain4j", version.ref = "langchain4j" } 65 | langchain4jOpenai = { group = "dev.langchain4j", name = "langchain4j-open-ai", version.ref = "langchain4j" } 66 | langchain4jAnthropic = { group = "dev.langchain4j", name = "langchain4j-anthropic", version.ref = "langchain4j" } 67 | langchain4jGoogleaigemini = { group = "dev.langchain4j", name = "langchain4j-google-ai-gemini", version.ref = "langchain4j" } 68 | 69 | # Kotlin coroutines 70 | kotlinxCoroutinesCore = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } 71 | 72 | # Token counting 73 | jtokkit = { group = "com.knuddels", name = "jtokkit", version.ref = "jtokkit" } 74 | 75 | [plugins] 76 | changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } 77 | intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } 78 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 79 | kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } 80 | qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } 81 | composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. 2 | # Running the publishPlugin task requires all the following secrets to be provided: PUBLISH_TOKEN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD, CERTIFICATE_CHAIN. 3 | # See https://plugins.jetbrains.com/docs/intellij/plugin-signing.html for more information. 4 | 5 | name: Release 6 | on: 7 | release: 8 | types: [prereleased, released] 9 | 10 | jobs: 11 | 12 | # Prepare and publish the plugin to JetBrains Marketplace repository 13 | release: 14 | name: Publish Plugin 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | steps: 20 | 21 | # Free GitHub Actions Environment Disk Space 22 | - name: Maximize Build Space 23 | uses: jlumbroso/free-disk-space@v1.3.1 24 | with: 25 | tool-cache: false 26 | large-packages: false 27 | 28 | # Check out the current repository 29 | - name: Fetch Sources 30 | uses: actions/checkout@v5 31 | with: 32 | ref: ${{ github.event.release.tag_name }} 33 | 34 | # Set up the Java environment for the next steps 35 | - name: Setup Java 36 | uses: actions/setup-java@v5 37 | with: 38 | distribution: zulu 39 | java-version: 21 40 | 41 | # Setup Gradle 42 | - name: Setup Gradle 43 | uses: gradle/actions/setup-gradle@v5 44 | with: 45 | cache-read-only: true 46 | 47 | # Update the Unreleased section with the current release note 48 | - name: Patch Changelog 49 | if: ${{ github.event.release.body != '' }} 50 | env: 51 | CHANGELOG: ${{ github.event.release.body }} 52 | run: | 53 | RELEASE_NOTE="./build/tmp/release_note.txt" 54 | mkdir -p "$(dirname "$RELEASE_NOTE")" 55 | echo "$CHANGELOG" > $RELEASE_NOTE 56 | 57 | ./gradlew patchChangelog --release-note-file=$RELEASE_NOTE 58 | 59 | # Publish the plugin to JetBrains Marketplace 60 | - name: Publish Plugin 61 | env: 62 | PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 63 | CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} 64 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 65 | PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} 66 | run: ./gradlew publishPlugin 67 | 68 | # Upload an artifact as a release asset 69 | - name: Upload Release Asset 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* 73 | 74 | # Create a pull request 75 | - name: Create Pull Request 76 | if: ${{ steps.properties.outputs.changelog != '' }} 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | run: | 80 | VERSION="${{ github.event.release.tag_name }}" 81 | BRANCH="changelog-update-$VERSION" 82 | LABEL="release changelog" 83 | 84 | git config user.email "action@github.com" 85 | git config user.name "GitHub Action" 86 | 87 | git checkout -b $BRANCH 88 | git commit -am "Changelog update - $VERSION" 89 | git push --set-upstream origin $BRANCH 90 | 91 | gh label create "$LABEL" \ 92 | --description "Pull requests with release changelog update" \ 93 | --force \ 94 | || true 95 | 96 | gh pr create \ 97 | --title "Changelog update - \`$VERSION\`" \ 98 | --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ 99 | --label "$LABEL" \ 100 | --head $BRANCH 101 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/history/ConversationHistory.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.history 2 | 3 | import com.phodal.lotus.chat.model.ChatMessage 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.descriptors.SerialDescriptor 7 | import kotlinx.serialization.encoding.Decoder 8 | import kotlinx.serialization.encoding.Encoder 9 | import kotlinx.serialization.builtins.ListSerializer 10 | import java.time.Instant 11 | import java.time.LocalDateTime 12 | import java.time.ZoneId 13 | 14 | /** 15 | * Represents a conversation session with its messages 16 | */ 17 | @Serializable 18 | data class ConversationHistory( 19 | val id: String, 20 | val title: String, 21 | @Serializable(with = ChatMessageListSerializer::class) 22 | val messages: List, 23 | val createdAt: Long = System.currentTimeMillis(), 24 | val updatedAt: Long = System.currentTimeMillis(), 25 | /** 26 | * AI-generated summary of the conversation (optional) 27 | * Used to preserve context when archiving or referencing old conversations 28 | */ 29 | val summary: String? = null 30 | ) { 31 | /** 32 | * Get a preview of the conversation (first user message or title) 33 | */ 34 | fun getPreview(): String { 35 | val firstUserMessage = messages.firstOrNull { it.isMyMessage }?.content 36 | return firstUserMessage?.take(50) ?: title 37 | } 38 | 39 | /** 40 | * Get the number of messages in this conversation 41 | */ 42 | fun messageCount(): Int = messages.size 43 | } 44 | 45 | /** 46 | * Custom serializer for ChatMessage list 47 | * Since ChatMessage uses LocalDateTime, we need to convert to/from epoch millis 48 | */ 49 | object ChatMessageListSerializer : KSerializer> { 50 | private val delegateSerializer = ListSerializer(SerializableChatMessage.serializer()) 51 | 52 | override val descriptor: SerialDescriptor = delegateSerializer.descriptor 53 | 54 | override fun serialize(encoder: Encoder, value: List) { 55 | val serializableMessages = value.map { 56 | SerializableChatMessage( 57 | id = it.id, 58 | content = it.content, 59 | author = it.author, 60 | isMyMessage = it.isMyMessage, 61 | timestamp = it.timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), 62 | type = it.type.name, 63 | format = it.format.name, 64 | isStreaming = it.isStreaming 65 | ) 66 | } 67 | encoder.encodeSerializableValue(delegateSerializer, serializableMessages) 68 | } 69 | 70 | override fun deserialize(decoder: Decoder): List { 71 | val serializableMessages = decoder.decodeSerializableValue(delegateSerializer) 72 | return serializableMessages.map { 73 | ChatMessage( 74 | id = it.id, 75 | content = it.content, 76 | author = it.author, 77 | isMyMessage = it.isMyMessage, 78 | timestamp = LocalDateTime.ofInstant( 79 | Instant.ofEpochMilli(it.timestamp), 80 | ZoneId.systemDefault() 81 | ), 82 | type = ChatMessage.ChatMessageType.valueOf(it.type), 83 | format = ChatMessage.MessageFormat.valueOf(it.format), 84 | isStreaming = it.isStreaming 85 | ) 86 | } 87 | } 88 | } 89 | 90 | @Serializable 91 | private data class SerializableChatMessage( 92 | val id: String, 93 | val content: String, 94 | val author: String, 95 | val isMyMessage: Boolean, 96 | val timestamp: Long, 97 | val type: String, 98 | val format: String, 99 | val isStreaming: Boolean 100 | ) 101 | 102 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/token/TokenUsageTrackerTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | import kotlinx.coroutines.runBlocking 4 | import org.junit.jupiter.api.Test 5 | import org.junit.jupiter.api.Assertions.* 6 | import org.junit.jupiter.api.BeforeEach 7 | 8 | class TokenUsageTrackerTest { 9 | 10 | private lateinit var tracker: TokenUsageTracker 11 | 12 | @BeforeEach 13 | fun setup() { 14 | tracker = TokenUsageTracker.getInstance() 15 | runBlocking { 16 | tracker.clear() 17 | } 18 | } 19 | 20 | @Test 21 | fun testRecordUsage() = runBlocking { 22 | val usage = TokenUsage.of(100, 200, "gpt-4", "conv-1") 23 | tracker.recordUsage(usage) 24 | 25 | val aggregated = tracker.aggregatedUsage.value 26 | assertEquals(100, aggregated.totalInputTokens) 27 | assertEquals(200, aggregated.totalOutputTokens) 28 | assertEquals(300, aggregated.totalTokens) 29 | assertEquals(1, aggregated.interactionCount) 30 | } 31 | 32 | @Test 33 | fun testMultipleRecords() = runBlocking { 34 | tracker.recordUsage(TokenUsage.of(100, 200, "gpt-4", "conv-1")) 35 | tracker.recordUsage(TokenUsage.of(50, 75, "gpt-4", "conv-1")) 36 | 37 | val aggregated = tracker.aggregatedUsage.value 38 | assertEquals(150, aggregated.totalInputTokens) 39 | assertEquals(275, aggregated.totalOutputTokens) 40 | assertEquals(425, aggregated.totalTokens) 41 | assertEquals(2, aggregated.interactionCount) 42 | } 43 | 44 | @Test 45 | fun testConversationUsage() = runBlocking { 46 | tracker.recordUsage(TokenUsage.of(100, 200, "gpt-4", "conv-1")) 47 | tracker.recordUsage(TokenUsage.of(50, 75, "gpt-4", "conv-2")) 48 | 49 | val conv1Usage = tracker.getConversationUsage("conv-1") 50 | assertEquals(100, conv1Usage.inputTokens) 51 | assertEquals(200, conv1Usage.outputTokens) 52 | 53 | val conv2Usage = tracker.getConversationUsage("conv-2") 54 | assertEquals(50, conv2Usage.inputTokens) 55 | assertEquals(75, conv2Usage.outputTokens) 56 | } 57 | 58 | @Test 59 | fun testModelBreakdown() = runBlocking { 60 | tracker.recordUsage(TokenUsage.of(100, 200, "gpt-4", "conv-1")) 61 | tracker.recordUsage(TokenUsage.of(50, 75, "claude-3", "conv-1")) 62 | 63 | val aggregated = tracker.aggregatedUsage.value 64 | assertEquals(2, aggregated.byModel.size) 65 | assertTrue(aggregated.byModel.containsKey("gpt-4")) 66 | assertTrue(aggregated.byModel.containsKey("claude-3")) 67 | } 68 | 69 | @Test 70 | fun testClear() = runBlocking { 71 | tracker.recordUsage(TokenUsage.of(100, 200, "gpt-4", "conv-1")) 72 | tracker.clear() 73 | 74 | val aggregated = tracker.aggregatedUsage.value 75 | assertEquals(0, aggregated.totalTokens) 76 | assertEquals(0, aggregated.interactionCount) 77 | } 78 | 79 | @Test 80 | fun testClearConversation() = runBlocking { 81 | tracker.recordUsage(TokenUsage.of(100, 200, "gpt-4", "conv-1")) 82 | tracker.recordUsage(TokenUsage.of(50, 75, "gpt-4", "conv-2")) 83 | 84 | tracker.clearConversation("conv-1") 85 | 86 | val aggregated = tracker.aggregatedUsage.value 87 | assertEquals(50, aggregated.totalInputTokens) 88 | assertEquals(75, aggregated.totalOutputTokens) 89 | assertEquals(1, aggregated.interactionCount) 90 | } 91 | 92 | @Test 93 | fun testGetUsageHistory() = runBlocking { 94 | tracker.recordUsage(TokenUsage.of(100, 200, "gpt-4", "conv-1")) 95 | tracker.recordUsage(TokenUsage.of(50, 75, "gpt-4", "conv-1")) 96 | 97 | val history = tracker.getUsageHistory() 98 | assertEquals(2, history.size) 99 | } 100 | } 101 | 102 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | lotus-flower-svgrepo-com 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/icons/lotus.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | lotus-flower-svgrepo-com 6 | Created with Sketch. 7 | 8 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/viewmodel/SearchChatMessagesHandlerImpl.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.viewmodel 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.* 5 | import com.phodal.lotus.chat.model.ChatMessage 6 | import com.phodal.lotus.chat.ui.SearchState 7 | import com.phodal.lotus.chat.ui.hasResults 8 | import com.phodal.lotus.chat.ui.isSearching 9 | import com.phodal.lotus.chat.ui.searchQuery 10 | 11 | /** 12 | * Implementation of the SearchChatMessagesHandler interface for handling 13 | * chat message search functionality and state management. 14 | * 15 | * This class provides the ability to search through a list of chat messages, 16 | * navigate through search results, and manage the state of the search process. 17 | * 18 | * @property messagesFlow A StateFlow of a List containing ChatMessage objects. 19 | * It represents the stream of chat messages to be searched. 20 | * @property searchStateFlow A StateFlow representing the current search state, 21 | * which can be idle, searching, or showing search results. 22 | */ 23 | class SearchChatMessagesHandlerImpl( 24 | private val messagesFlow: StateFlow> = MutableStateFlow(emptyList()), 25 | coroutineScope: CoroutineScope 26 | ) : SearchChatMessagesHandler { 27 | private val _searchStateFlow: MutableStateFlow = MutableStateFlow(SearchState.Idle) 28 | 29 | override val searchStateFlow: StateFlow = _searchStateFlow.asStateFlow() 30 | 31 | init { 32 | messagesFlow 33 | .onEach { _ -> 34 | // When new messages are received, refresh search results using the latest query. 35 | // If no search query is available(search is not open), skip the operation. 36 | val searchState = _searchStateFlow.value 37 | if (searchState.isSearching) { 38 | searchState.searchQuery?.let { query -> onSearchQuery(query) } 39 | } 40 | } 41 | .launchIn(coroutineScope) 42 | } 43 | 44 | override fun onStartSearch() { 45 | _searchStateFlow.value = SearchState.Searching("") 46 | } 47 | 48 | override fun onStopSearch() { 49 | _searchStateFlow.value = SearchState.Idle 50 | } 51 | 52 | override fun onSearchQuery(query: String) { 53 | val messages = messagesFlow.value 54 | 55 | performSearch(query, messages) 56 | } 57 | 58 | override fun onNavigateToNextSearchResult() { 59 | val searchState = _searchStateFlow.value 60 | if (searchState !is SearchState.SearchResults) return 61 | if (!(searchState.hasResults)) return 62 | 63 | moveSearchResultSelectionToIndex(searchState, searchState.currentSelectedSearchResultIndex + 1) 64 | } 65 | 66 | override fun onNavigateToPreviousSearchResult() { 67 | val searchState = _searchStateFlow.value 68 | if (searchState !is SearchState.SearchResults) return 69 | if (!(searchState.hasResults)) return 70 | 71 | moveSearchResultSelectionToIndex(searchState, searchState.currentSelectedSearchResultIndex - 1) 72 | } 73 | 74 | private fun moveSearchResultSelectionToIndex(searchState: SearchState.SearchResults, newSearchResultIndex: Int) { 75 | val nextSearchResultIndex = when { 76 | newSearchResultIndex < 0 -> searchState.searchResultIds.lastIndex 77 | newSearchResultIndex > searchState.searchResultIds.lastIndex -> 0 78 | else -> newSearchResultIndex 79 | } 80 | 81 | _searchStateFlow.value = searchState.copy(currentSelectedSearchResultIndex = nextSearchResultIndex) 82 | } 83 | 84 | private fun performSearch( 85 | query: String, 86 | messages: List 87 | ) { 88 | val matchingIds = messages 89 | .filter { message -> message.matches(query) } 90 | .map { it.id } 91 | 92 | _searchStateFlow.value = SearchState.SearchResults( 93 | query = query, 94 | searchResultIds = matchingIds, 95 | currentSelectedSearchResultIndex = if (matchingIds.isNotEmpty()) 0 else -1 96 | ) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/resources/icons/lotus_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | lotus-flower-svgrepo-com 6 | Created with Sketch. 7 | 8 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/config/LLMConfigManager.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.config 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import kotlinx.coroutines.flow.asStateFlow 6 | import java.io.File 7 | 8 | /** 9 | * Manages LLM configurations 10 | * Stores and retrieves configurations from local storage 11 | * Uses singleton pattern to ensure single instance across the application 12 | */ 13 | class LLMConfigManager(private val configDir: String = System.getProperty("user.home") + "/.lotus/ai") : ConfigProvider { 14 | 15 | private val _currentConfig = MutableStateFlow(null) 16 | override val currentConfig: StateFlow = _currentConfig.asStateFlow() 17 | 18 | private val configFile = File(configDir, "llm_config.properties") 19 | 20 | init { 21 | ensureConfigDirExists() 22 | loadConfig() 23 | } 24 | 25 | companion object { 26 | @Volatile 27 | private var instance: LLMConfigManager? = null 28 | 29 | fun getInstance(): LLMConfigManager { 30 | return instance ?: synchronized(this) { 31 | instance ?: LLMConfigManager().also { instance = it } 32 | } 33 | } 34 | } 35 | 36 | private fun ensureConfigDirExists() { 37 | File(configDir).mkdirs() 38 | } 39 | 40 | /** 41 | * Load configuration from file 42 | */ 43 | fun loadConfig() { 44 | if (configFile.exists()) { 45 | try { 46 | val properties = java.util.Properties() 47 | properties.load(configFile.inputStream()) 48 | 49 | val provider = properties.getProperty("provider")?.let { LLMProvider.valueOf(it) } 50 | val apiKey = properties.getProperty("apiKey") 51 | val model = properties.getProperty("model") 52 | val temperature = properties.getProperty("temperature")?.toDoubleOrNull() ?: 0.7 53 | val maxTokens = properties.getProperty("maxTokens")?.toIntOrNull() ?: 2000 54 | 55 | if (provider != null && apiKey != null) { 56 | val config = LLMConfig( 57 | provider = provider, 58 | apiKey = apiKey, 59 | model = model ?: LLMConfig.getDefaultModel(provider), 60 | temperature = temperature, 61 | maxTokens = maxTokens 62 | ) 63 | _currentConfig.value = config 64 | } 65 | } catch (e: Exception) { 66 | e.printStackTrace() 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Save configuration to file 73 | */ 74 | fun saveConfig(config: LLMConfig) { 75 | try { 76 | val properties = java.util.Properties() 77 | properties.setProperty("provider", config.provider.name) 78 | properties.setProperty("apiKey", config.apiKey) 79 | properties.setProperty("model", config.model) 80 | properties.setProperty("temperature", config.temperature.toString()) 81 | properties.setProperty("maxTokens", config.maxTokens.toString()) 82 | 83 | configFile.parentFile?.mkdirs() 84 | properties.store(configFile.outputStream(), "LLM Configuration") 85 | 86 | _currentConfig.value = config 87 | } catch (e: Exception) { 88 | e.printStackTrace() 89 | } 90 | } 91 | 92 | /** 93 | * Update current configuration 94 | */ 95 | fun updateConfig(config: LLMConfig) { 96 | saveConfig(config) 97 | } 98 | 99 | /** 100 | * Clear configuration 101 | */ 102 | fun clearConfig() { 103 | if (configFile.exists()) { 104 | configFile.delete() 105 | } 106 | _currentConfig.value = null 107 | } 108 | 109 | /** 110 | * Check if configuration is set 111 | */ 112 | override fun isConfigured(): Boolean = _currentConfig.value != null 113 | } 114 | 115 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/context/summarization/AIConversationSummarizer.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.context.summarization 2 | 3 | import com.phodal.lotus.aicore.client.AIClient 4 | import com.phodal.lotus.aicore.token.TokenCounter 5 | 6 | /** 7 | * AI-powered conversation summarizer 8 | * 9 | * This implementation uses an AI client to generate summaries of conversations. 10 | * It handles token counting and content selection to ensure the summary 11 | * fits within token limits while preserving important information. 12 | */ 13 | class AIConversationSummarizer( 14 | private val aiClient: AIClient, 15 | private val tokenCounter: TokenCounter, 16 | private val contentSelectionStrategy: ContentSelectionStrategy = DefaultContentSelectionStrategy() 17 | ) : ConversationSummarizer { 18 | 19 | override suspend fun summarize( 20 | messages: List, 21 | config: SummarizationConfig 22 | ): SummarizationResult { 23 | if (messages.isEmpty()) { 24 | return SummarizationResult( 25 | summary = "No messages to summarize", 26 | messagesIncluded = 0, 27 | originalTokenCount = 0, 28 | summaryTokenCount = 0 29 | ) 30 | } 31 | 32 | // Select content based on token limits 33 | val selectedContent = contentSelectionStrategy.selectMessages( 34 | messages, 35 | config.maxContextTokens, 36 | tokenCounter 37 | ) 38 | 39 | if (selectedContent.messages.isEmpty()) { 40 | return SummarizationResult( 41 | summary = "Unable to select messages within token limit", 42 | messagesIncluded = 0, 43 | originalTokenCount = selectedContent.totalTokens, 44 | summaryTokenCount = 0 45 | ) 46 | } 47 | 48 | // Build the prompt for summarization 49 | val prompt = buildSummarizationPrompt(selectedContent.messages, config) 50 | 51 | // Call AI to generate summary 52 | val result = aiClient.sendMessage(prompt) 53 | 54 | val summaryTokenCount = tokenCounter.estimateTokenCount(result.content) 55 | 56 | return SummarizationResult( 57 | summary = result.content, 58 | tokenUsage = result.tokenUsage, 59 | messagesIncluded = selectedContent.messages.size, 60 | originalTokenCount = selectedContent.totalTokens, 61 | summaryTokenCount = summaryTokenCount 62 | ) 63 | } 64 | 65 | override fun isReady(): Boolean { 66 | return aiClient.isConfigured() 67 | } 68 | 69 | private fun buildSummarizationPrompt( 70 | messages: List, 71 | config: SummarizationConfig 72 | ): String { 73 | val conversationText = messages.joinToString("\n\n") { msg -> 74 | val role = if (msg.isUserMessage) "User" else "Assistant" 75 | "$role: ${msg.content}" 76 | } 77 | 78 | return """ 79 | ${config.systemPrompt} 80 | 81 | Please summarize the following conversation: 82 | 83 | $conversationText 84 | 85 | Provide a concise summary that captures the main points and key information. 86 | """.trimIndent() 87 | } 88 | } 89 | 90 | /** 91 | * No-op conversation summarizer for when AI is not configured 92 | * 93 | * This implementation returns a placeholder summary without calling any AI service. 94 | */ 95 | class NoOpConversationSummarizer : ConversationSummarizer { 96 | 97 | override suspend fun summarize( 98 | messages: List, 99 | config: SummarizationConfig 100 | ): SummarizationResult { 101 | return SummarizationResult( 102 | summary = "Summarization not available - AI not configured", 103 | messagesIncluded = messages.size, 104 | originalTokenCount = 0, 105 | summaryTokenCount = 0 106 | ) 107 | } 108 | 109 | override fun isReady(): Boolean = false 110 | } 111 | 112 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/repository/AIResponseGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.repository 2 | 3 | class AIResponseGenerator { 4 | 5 | fun generateAIResponse(userMessage: String): String { 6 | val message = userMessage.lowercase() 7 | 8 | return when { 9 | message.contains("hello") || message.contains("hi") || message.contains("hey") -> { 10 | listOf( 11 | "Hello! How can I assist you today?", 12 | "Hi there! What's on your mind?", 13 | "Hey! Great to hear from you. How are things going?", 14 | "Hello! I'm here to help with any questions you might have." 15 | ).random() 16 | } 17 | 18 | message.contains("how are you") || message.contains("how's it going") -> { 19 | listOf( 20 | "I'm doing well, thank you for asking! How about yourself?", 21 | "Everything's running smoothly on my end. How are you doing?", 22 | "I'm here and ready to help! How has your day been?", 23 | "All systems operational! What brings you here today?" 24 | ).random() 25 | } 26 | 27 | message.contains("help") || message.contains("assist") -> { 28 | listOf( 29 | "I'd be happy to help! What do you need assistance with?", 30 | "Of course! What can I help you figure out?", 31 | "I'm here to assist. Could you tell me more about what you need?", 32 | "Absolutely! What kind of help are you looking for?" 33 | ).random() 34 | } 35 | 36 | message.contains("thank") -> { 37 | listOf( 38 | "You're very welcome! Happy to help anytime.", 39 | "My pleasure! Is there anything else I can assist with?", 40 | "Glad I could help! Feel free to ask if you need anything else.", 41 | "You're welcome! That's what I'm here for." 42 | ).random() 43 | } 44 | 45 | message.contains("code") || message.contains("programming") -> { 46 | listOf( 47 | "I love talking about code! What programming topic interests you?", 48 | "Programming is fascinating! Are you working on a specific project?", 49 | "Code-related questions are my specialty. What would you like to know?", 50 | "Great choice of topic! What programming language are you using?" 51 | ).random() 52 | } 53 | 54 | message.contains("?") -> { 55 | listOf( 56 | "That's a great question! Let me think about that...", 57 | "Interesting question! From my perspective, I'd say...", 58 | "Good point! Here's how I see it:", 59 | "That's worth exploring! Based on what I know..." 60 | ).random() 61 | } 62 | 63 | message.length > 100 -> { 64 | listOf( 65 | "That's quite detailed! I appreciate you sharing all that context.", 66 | "Thanks for the comprehensive explanation. That gives me a lot to work with!", 67 | "Wow, you've really thought this through! Let me process all of that...", 68 | "I can see you've put a lot of thought into this. Here's my take:" 69 | ).random() 70 | } 71 | 72 | else -> { 73 | listOf( 74 | "That's an interesting point! Could you tell me more about your perspective?", 75 | "I see what you're getting at. That's definitely worth considering.", 76 | "Fascinating! I hadn't thought about it from that angle before.", 77 | "Good observation! What made you think of that?", 78 | "That's insightful! How did you come to that conclusion?", 79 | "I appreciate you sharing that thought with me.", 80 | "That's a unique way to look at it! I like your thinking.", 81 | "You raise an excellent point there!", 82 | "That's definitely food for thought. Very interesting!", 83 | "I find your perspective quite compelling. Tell me more!" 84 | ).random() 85 | } 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/TokenUsagePanel.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.unit.sp 13 | import org.jetbrains.jewel.foundation.theme.JewelTheme 14 | import org.jetbrains.jewel.ui.component.Text 15 | import com.phodal.lotus.aicore.token.TokenUsageTracker 16 | import com.phodal.lotus.aicore.token.AggregatedTokenUsage 17 | import com.phodal.lotus.chat.ChatAppColors 18 | 19 | /** 20 | * Compact one-line token usage display 21 | */ 22 | @Composable 23 | fun TokenUsagePanel( 24 | modifier: Modifier = Modifier, 25 | conversationId: String? = null 26 | ) { 27 | val tracker = TokenUsageTracker.getInstance() 28 | val aggregatedUsage by tracker.aggregatedUsage.collectAsState() 29 | 30 | // Get conversation-specific usage if conversationId is provided 31 | val currentUsage = if (conversationId != null) { 32 | tracker.getConversationUsage(conversationId) 33 | } else { 34 | null 35 | } 36 | 37 | if (aggregatedUsage.totalTokens > 0) { 38 | Row( 39 | modifier = modifier 40 | .fillMaxWidth() 41 | .background(ChatAppColors.Panel.background) 42 | .padding(horizontal = 16.dp, vertical = 6.dp), 43 | horizontalArrangement = Arrangement.spacedBy(16.dp), 44 | verticalAlignment = Alignment.CenterVertically 45 | ) { 46 | // Icon 47 | Text( 48 | text = "🪙", 49 | style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) 50 | ) 51 | 52 | // Current conversation 53 | if (currentUsage != null && currentUsage.totalTokens > 0) { 54 | Text( 55 | text = "Current: ${currentUsage.totalTokens}", 56 | style = JewelTheme.defaultTextStyle.copy( 57 | fontSize = 11.sp, 58 | color = ChatAppColors.Text.normal 59 | ) 60 | ) 61 | Text(text = "•", style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp)) 62 | } 63 | 64 | // Total usage 65 | Text( 66 | text = "Total: ${aggregatedUsage.totalTokens}", 67 | style = JewelTheme.defaultTextStyle.copy( 68 | fontSize = 11.sp, 69 | fontWeight = FontWeight.Medium, 70 | color = ChatAppColors.Text.normal 71 | ) 72 | ) 73 | 74 | Text(text = "•", style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp)) 75 | 76 | // Interactions 77 | Text( 78 | text = "${aggregatedUsage.interactionCount} calls", 79 | style = JewelTheme.defaultTextStyle.copy( 80 | fontSize = 11.sp, 81 | color = ChatAppColors.Text.disabled 82 | ) 83 | ) 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Compact version of token usage display for header 90 | */ 91 | @Composable 92 | fun CompactTokenUsageDisplay( 93 | modifier: Modifier = Modifier 94 | ) { 95 | val tracker = TokenUsageTracker.getInstance() 96 | val aggregatedUsage by tracker.aggregatedUsage.collectAsState() 97 | 98 | if (aggregatedUsage.totalTokens > 0) { 99 | Row( 100 | modifier = modifier.padding(horizontal = 8.dp), 101 | horizontalArrangement = Arrangement.spacedBy(4.dp), 102 | verticalAlignment = Alignment.CenterVertically 103 | ) { 104 | Text( 105 | text = "🪙", 106 | style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp) 107 | ) 108 | Text( 109 | text = "${aggregatedUsage.totalTokens}", 110 | style = JewelTheme.defaultTextStyle.copy( 111 | fontSize = 11.sp, 112 | fontWeight = FontWeight.Medium, 113 | color = ChatAppColors.Text.normal 114 | ) 115 | ) 116 | } 117 | } 118 | } 119 | 120 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/AIConfigButton.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.* 5 | import androidx.compose.ui.Alignment 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.unit.dp 8 | import androidx.compose.ui.unit.sp 9 | import androidx.compose.ui.window.Dialog 10 | import androidx.compose.ui.window.DialogProperties 11 | import org.jetbrains.jewel.foundation.theme.JewelTheme 12 | import org.jetbrains.jewel.ui.component.Icon 13 | import org.jetbrains.jewel.ui.component.IconButton 14 | import org.jetbrains.jewel.ui.component.Text 15 | import org.jetbrains.jewel.ui.icons.AllIconsKeys 16 | import com.phodal.lotus.aicore.config.LLMProvider 17 | import com.phodal.lotus.chat.ChatAppColors 18 | import com.phodal.lotus.config.AIConfigService 19 | import kotlinx.coroutines.flow.first 20 | import kotlinx.coroutines.runBlocking 21 | 22 | /** 23 | * AI Configuration Button with Dialog 24 | */ 25 | @Composable 26 | fun AIConfigButton( 27 | onConfigSaved: (provider: LLMProvider, apiKey: String, model: String) -> Unit, 28 | currentProvider: LLMProvider? = null, 29 | currentApiKey: String = "", 30 | currentModel: String = "", 31 | isConfigured: Boolean = false 32 | ) { 33 | var showDialog by remember { mutableStateOf(false) } 34 | 35 | // Load saved configuration from AIConfigService 36 | val configService = AIConfigService.getInstance() 37 | val savedConfig = runBlocking { configService.currentConfig.first() } 38 | 39 | val displayProvider = currentProvider ?: savedConfig?.provider 40 | val displayApiKey = currentApiKey.ifBlank { savedConfig?.apiKey ?: "" } 41 | val displayConfigured = isConfigured || savedConfig != null 42 | 43 | Row( 44 | modifier = Modifier.wrapContentSize(), 45 | horizontalArrangement = Arrangement.spacedBy(8.dp), 46 | verticalAlignment = Alignment.CenterVertically 47 | ) { 48 | // Show configured model info if available 49 | if (displayConfigured && displayProvider != null) { 50 | Column( 51 | modifier = Modifier.wrapContentSize(), 52 | horizontalAlignment = Alignment.End 53 | ) { 54 | Text( 55 | text = displayProvider.name, 56 | style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), 57 | color = ChatAppColors.Text.timestamp 58 | ) 59 | Text( 60 | text = "Configured", 61 | style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp), 62 | color = ChatAppColors.Text.disabled 63 | ) 64 | } 65 | } else { 66 | Column( 67 | modifier = Modifier.wrapContentSize(), 68 | horizontalAlignment = Alignment.End 69 | ) { 70 | Text( 71 | text = "AI Config", 72 | style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), 73 | color = ChatAppColors.Text.disabled 74 | ) 75 | Text( 76 | text = "Not configured", 77 | style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp), 78 | color = ChatAppColors.Text.disabled 79 | ) 80 | } 81 | } 82 | 83 | Box( 84 | modifier = Modifier.size(24.dp), 85 | contentAlignment = Alignment.Center 86 | ) { 87 | IconButton( 88 | onClick = { showDialog = true }, 89 | modifier = Modifier.size(24.dp) 90 | ) { 91 | Icon( 92 | AllIconsKeys.General.Settings, 93 | contentDescription = "AI Configuration" 94 | ) 95 | } 96 | } 97 | } 98 | 99 | if (showDialog) { 100 | Dialog( 101 | onDismissRequest = { showDialog = false }, 102 | properties = DialogProperties(usePlatformDefaultWidth = false) 103 | ) { 104 | AIConfigDialog( 105 | onDismiss = { showDialog = false }, 106 | onSave = { provider, apiKey, model -> 107 | onConfigSaved(provider, apiKey, model) 108 | }, 109 | currentProvider = displayProvider, 110 | currentApiKey = displayApiKey, 111 | currentModel = currentModel 112 | ) 113 | } 114 | } 115 | } 116 | 117 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/token/TokenUsageTracker.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.token 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import kotlinx.coroutines.flow.asStateFlow 6 | import kotlinx.coroutines.sync.Mutex 7 | import kotlinx.coroutines.sync.withLock 8 | 9 | /** 10 | * Service for tracking and managing token usage across LLM interactions 11 | * 12 | * This service provides: 13 | * - Real-time token usage tracking 14 | * - Aggregation by conversation and model 15 | * - Observable state for UI updates 16 | * - Thread-safe operations 17 | * 18 | * Future extensions: 19 | * - Persistence to disk 20 | * - Integration with ChatMemory for context window management 21 | * - Cost estimation based on token usage 22 | */ 23 | class TokenUsageTracker { 24 | 25 | private val mutex = Mutex() 26 | 27 | // All token usage records 28 | private val usageHistory = mutableListOf() 29 | 30 | // Current aggregated usage 31 | private val _aggregatedUsage = MutableStateFlow(AggregatedTokenUsage.EMPTY) 32 | val aggregatedUsage: StateFlow = _aggregatedUsage.asStateFlow() 33 | 34 | // Usage by conversation ID 35 | private val _conversationUsage = MutableStateFlow>(emptyMap()) 36 | val conversationUsage: StateFlow> = _conversationUsage.asStateFlow() 37 | 38 | /** 39 | * Record a new token usage 40 | */ 41 | suspend fun recordUsage(usage: TokenUsage) { 42 | mutex.withLock { 43 | usageHistory.add(usage) 44 | updateAggregatedUsage() 45 | } 46 | } 47 | 48 | /** 49 | * Get token usage for a specific conversation 50 | */ 51 | fun getConversationUsage(conversationId: String): TokenUsage { 52 | return _conversationUsage.value[conversationId] ?: TokenUsage.EMPTY 53 | } 54 | 55 | /** 56 | * Get all usage history 57 | */ 58 | fun getUsageHistory(): List { 59 | return usageHistory.toList() 60 | } 61 | 62 | /** 63 | * Clear all usage data 64 | */ 65 | suspend fun clear() { 66 | mutex.withLock { 67 | usageHistory.clear() 68 | _aggregatedUsage.value = AggregatedTokenUsage.EMPTY 69 | _conversationUsage.value = emptyMap() 70 | } 71 | } 72 | 73 | /** 74 | * Clear usage data for a specific conversation 75 | */ 76 | suspend fun clearConversation(conversationId: String) { 77 | mutex.withLock { 78 | usageHistory.removeAll { it.conversationId == conversationId } 79 | updateAggregatedUsage() 80 | } 81 | } 82 | 83 | /** 84 | * Update aggregated usage statistics 85 | */ 86 | private fun updateAggregatedUsage() { 87 | val totalInput = usageHistory.sumOf { it.inputTokens.toLong() } 88 | val totalOutput = usageHistory.sumOf { it.outputTokens.toLong() } 89 | val interactionCount = usageHistory.size 90 | 91 | // Group by model 92 | val byModel = usageHistory 93 | .filter { it.modelName != null } 94 | .groupBy { it.modelName!! } 95 | .mapValues { (_, usages) -> 96 | usages.reduce { acc, usage -> acc + usage } 97 | } 98 | 99 | // Group by conversation 100 | val byConversation = usageHistory 101 | .filter { it.conversationId != null } 102 | .groupBy { it.conversationId!! } 103 | .mapValues { (_, usages) -> 104 | usages.reduce { acc, usage -> acc + usage } 105 | } 106 | 107 | _aggregatedUsage.value = AggregatedTokenUsage( 108 | totalInputTokens = totalInput, 109 | totalOutputTokens = totalOutput, 110 | totalTokens = totalInput + totalOutput, 111 | interactionCount = interactionCount, 112 | byModel = byModel, 113 | byConversation = byConversation 114 | ) 115 | 116 | _conversationUsage.value = byConversation 117 | } 118 | 119 | companion object { 120 | @Volatile 121 | private var instance: TokenUsageTracker? = null 122 | 123 | /** 124 | * Get the singleton instance 125 | */ 126 | fun getInstance(): TokenUsageTracker { 127 | return instance ?: synchronized(this) { 128 | instance ?: TokenUsageTracker().also { instance = it } 129 | } 130 | } 131 | } 132 | } 133 | 134 | -------------------------------------------------------------------------------- /src/test/kotlin/com/phodal/lotus/chat/repository/ChatRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.repository 2 | 3 | import com.phodal.lotus.chat.model.ChatMessage 4 | import com.phodal.lotus.services.IdeaChatRepository 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.jupiter.api.BeforeEach 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.Assertions.* 10 | 11 | /** 12 | * Basic tests for ChatRepository. 13 | * Note: These tests verify the data model and flow structure. 14 | * Full integration tests with AI client mocking are challenging in IntelliJ Platform test environment. 15 | */ 16 | @OptIn(ExperimentalCoroutinesApi::class) 17 | class ChatRepositoryTest { 18 | 19 | private lateinit var repository: IdeaChatRepository 20 | 21 | @BeforeEach 22 | fun setUp() { 23 | // Create repository instance 24 | repository = IdeaChatRepository() 25 | } 26 | 27 | @Test 28 | fun `test repository initializes with empty messages`() = runTest { 29 | // Verify repository starts with empty message list 30 | val messages = repository.messagesFlow.value 31 | assertTrue(messages.isEmpty(), "Repository should start with empty messages") 32 | } 33 | 34 | @Test 35 | fun `test ChatMessage data model has required fields`() { 36 | // Test that ChatMessage has all the new fields we added 37 | val message = ChatMessage( 38 | id = "test-id", 39 | content = "Test content", 40 | author = "Test Author", 41 | isMyMessage = true, 42 | format = ChatMessage.MessageFormat.MARKDOWN, 43 | isStreaming = false 44 | ) 45 | 46 | assertEquals("test-id", message.id) 47 | assertEquals("Test content", message.content) 48 | assertEquals("Test Author", message.author) 49 | assertTrue(message.isMyMessage) 50 | assertEquals(ChatMessage.MessageFormat.MARKDOWN, message.format) 51 | assertFalse(message.isStreaming) 52 | } 53 | 54 | @Test 55 | fun `test ChatMessage supports different formats`() { 56 | // Verify all message formats are available 57 | val formats = ChatMessage.MessageFormat.values() 58 | 59 | assertTrue(formats.contains(ChatMessage.MessageFormat.MARKDOWN)) 60 | assertTrue(formats.contains(ChatMessage.MessageFormat.MERMAID)) 61 | assertTrue(formats.contains(ChatMessage.MessageFormat.DIFF)) 62 | } 63 | 64 | @Test 65 | fun `test ChatStreamEvent types are defined`() { 66 | // Verify all event types exist 67 | val startedEvent = ChatRepositoryApi.ChatStreamEvent.Started("test-id") 68 | assertEquals("test-id", startedEvent.aiMessageId) 69 | 70 | val deltaEvent = ChatRepositoryApi.ChatStreamEvent.Delta("test-id", "chunk", "full") 71 | assertEquals("test-id", deltaEvent.aiMessageId) 72 | assertEquals("chunk", deltaEvent.delta) 73 | assertEquals("full", deltaEvent.fullContent) 74 | 75 | val completedEvent = ChatRepositoryApi.ChatStreamEvent.Completed("test-id", "final") 76 | assertEquals("test-id", completedEvent.aiMessageId) 77 | assertEquals("final", completedEvent.fullContent) 78 | 79 | val errorEvent = ChatRepositoryApi.ChatStreamEvent.Error("test-id", RuntimeException("test")) 80 | assertEquals("test-id", errorEvent.aiMessageId) 81 | assertNotNull(errorEvent.throwable) 82 | } 83 | 84 | @Test 85 | fun `test ChatMessage copy with isStreaming flag`() { 86 | val original = ChatMessage( 87 | id = "test", 88 | content = "Original", 89 | author = "Author", 90 | isStreaming = true 91 | ) 92 | 93 | val updated = original.copy(isStreaming = false) 94 | 95 | assertEquals(original.id, updated.id) 96 | assertEquals(original.content, updated.content) 97 | assertTrue(original.isStreaming) 98 | assertFalse(updated.isStreaming) 99 | } 100 | 101 | @Test 102 | fun `test ChatMessage copy with format`() { 103 | val markdown = ChatMessage( 104 | id = "test", 105 | content = "# Title", 106 | author = "Author", 107 | format = ChatMessage.MessageFormat.MARKDOWN 108 | ) 109 | 110 | val mermaid = markdown.copy( 111 | content = "graph TD; A-->B", 112 | format = ChatMessage.MessageFormat.MERMAID 113 | ) 114 | 115 | assertEquals(ChatMessage.MessageFormat.MARKDOWN, markdown.format) 116 | assertEquals(ChatMessage.MessageFormat.MERMAID, mermaid.format) 117 | assertEquals("graph TD; A-->B", mermaid.content) 118 | } 119 | } 120 | 121 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/AIServiceFactoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore 2 | 3 | import com.phodal.lotus.aicore.config.LLMConfig 4 | import com.phodal.lotus.aicore.config.LLMConfigManager 5 | import com.phodal.lotus.aicore.config.LLMProvider 6 | import org.junit.jupiter.api.Test 7 | import org.junit.jupiter.api.BeforeEach 8 | import org.junit.jupiter.api.Assertions.* 9 | import java.io.File 10 | import java.nio.file.Files 11 | import kotlinx.coroutines.flow.first 12 | import kotlinx.coroutines.runBlocking 13 | 14 | class AIServiceFactoryTest { 15 | 16 | private lateinit var tempDir: String 17 | private lateinit var configManager: LLMConfigManager 18 | 19 | @BeforeEach 20 | fun setUp() { 21 | // Create a temporary directory for testing 22 | tempDir = Files.createTempDirectory("lotus_factory_test_").toString() 23 | configManager = LLMConfigManager(tempDir) 24 | } 25 | 26 | @Test 27 | fun testInitializeFactory() { 28 | // Initialize the factory 29 | AIServiceFactory.initialize(configManager) 30 | 31 | // Verify the factory is initialized 32 | assertNotNull(AIServiceFactory.getConfigProvider()) 33 | } 34 | 35 | @Test 36 | fun testCreateAIClient() { 37 | AIServiceFactory.initialize(configManager) 38 | 39 | // Create a configuration 40 | val config = LLMConfig( 41 | provider = LLMProvider.DEEPSEEK, 42 | apiKey = "test-key" 43 | ) 44 | 45 | // Create AI client 46 | val client = AIServiceFactory.createAIClient(config) 47 | 48 | // Verify client was created 49 | assertNotNull(client) 50 | assertTrue(client.isConfigured()) 51 | } 52 | 53 | @Test 54 | fun testUpdateAIClientWhenConfigChanges() = runBlocking { 55 | AIServiceFactory.initialize(configManager) 56 | 57 | // Initially no client 58 | assertNull(AIServiceFactory.getAIClient()) 59 | 60 | // Save a configuration 61 | val config = LLMConfig( 62 | provider = LLMProvider.OPENAI, 63 | apiKey = "openai-key" 64 | ) 65 | configManager.saveConfig(config) 66 | 67 | // Update the factory 68 | AIServiceFactory.updateAIClient() 69 | 70 | // Now should have a client 71 | val client = AIServiceFactory.getAIClient() 72 | assertNotNull(client) 73 | assertTrue(client?.isConfigured() ?: false) 74 | } 75 | 76 | @Test 77 | fun testIsConfiguredCheck() = runBlocking { 78 | AIServiceFactory.initialize(configManager) 79 | 80 | // Initially not configured 81 | assertFalse(AIServiceFactory.isConfigured()) 82 | 83 | // Save a configuration 84 | val config = LLMConfig( 85 | provider = LLMProvider.CLAUDE, 86 | apiKey = "claude-key" 87 | ) 88 | configManager.saveConfig(config) 89 | AIServiceFactory.updateAIClient() 90 | 91 | // Now should be configured 92 | assertTrue(AIServiceFactory.isConfigured()) 93 | } 94 | 95 | @Test 96 | fun testMultipleProviders() = runBlocking { 97 | AIServiceFactory.initialize(configManager) 98 | 99 | // Test with DeepSeek 100 | var config = LLMConfig( 101 | provider = LLMProvider.DEEPSEEK, 102 | apiKey = "deepseek-key" 103 | ) 104 | configManager.saveConfig(config) 105 | AIServiceFactory.updateAIClient() 106 | 107 | var client = AIServiceFactory.getAIClient() 108 | assertNotNull(client) 109 | 110 | // Switch to OpenAI 111 | config = LLMConfig( 112 | provider = LLMProvider.OPENAI, 113 | apiKey = "openai-key" 114 | ) 115 | configManager.saveConfig(config) 116 | AIServiceFactory.updateAIClient() 117 | 118 | client = AIServiceFactory.getAIClient() 119 | assertNotNull(client) 120 | 121 | // Switch to Claude 122 | config = LLMConfig( 123 | provider = LLMProvider.CLAUDE, 124 | apiKey = "claude-key" 125 | ) 126 | configManager.saveConfig(config) 127 | AIServiceFactory.updateAIClient() 128 | 129 | client = AIServiceFactory.getAIClient() 130 | assertNotNull(client) 131 | 132 | // Switch to Gemini 133 | config = LLMConfig( 134 | provider = LLMProvider.GEMINI, 135 | apiKey = "gemini-key" 136 | ) 137 | configManager.saveConfig(config) 138 | AIServiceFactory.updateAIClient() 139 | 140 | client = AIServiceFactory.getAIClient() 141 | assertNotNull(client) 142 | } 143 | } 144 | 145 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/MessageItem.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Shape 11 | import androidx.compose.ui.text.font.FontWeight 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.unit.sp 14 | import org.jetbrains.jewel.foundation.theme.JewelTheme 15 | import org.jetbrains.jewel.ui.component.Text 16 | import com.phodal.lotus.chat.ChatAppColors 17 | import com.phodal.lotus.chat.model.ChatMessage 18 | import com.phodal.lotus.chat.ui.renderer.RendererRegistry 19 | import com.phodal.lotus.components.PulsingText 20 | 21 | @Composable 22 | fun MessageBubble( 23 | message: ChatMessage, 24 | modifier: Modifier = Modifier, 25 | isMatchingSearch: Boolean = false, 26 | isHighlightedInSearch: Boolean = false 27 | ) { 28 | val isMyMessage = message.isMyMessage 29 | val messageShape = RoundedCornerShape( 30 | topStart = 16.dp, 31 | topEnd = 16.dp, 32 | bottomStart = if (isMyMessage) 16.dp else 6.dp, 33 | bottomEnd = if (isMyMessage) 6.dp else 16.dp 34 | ) 35 | val messageBackgroundColor = when { 36 | isHighlightedInSearch && isMyMessage -> ChatAppColors.MessageBubble.mySearchHighlightedBackground 37 | isHighlightedInSearch && !isMyMessage -> ChatAppColors.MessageBubble.othersSearchHighlightedBackground 38 | isMyMessage -> ChatAppColors.MessageBubble.myBackground 39 | else -> ChatAppColors.MessageBubble.othersBackground 40 | } 41 | 42 | Row( 43 | modifier = modifier 44 | .padding(horizontal = 12.dp, vertical = 6.dp), 45 | horizontalArrangement = if (isMyMessage) Arrangement.End else Arrangement.Start 46 | ) { 47 | Column( 48 | modifier = Modifier 49 | .widthIn(min = 120.dp, max = 420.dp) 50 | .wrapContentSize() 51 | .background(messageBackgroundColor, messageShape) 52 | .messageBorder(messageShape, isMyMessage, isHighlightedInSearch, isMatchingSearch) 53 | .padding(16.dp) 54 | ) { 55 | AuthorName(message) 56 | 57 | if (message.isTextMessage()) { 58 | // Use pluggable renderer based on message format 59 | RendererRegistry.renderMessage(message) 60 | 61 | // Show streaming indicator if message is still being streamed 62 | if (message.isStreaming) { 63 | PulsingText("▋", isLoading = true) 64 | } 65 | 66 | TimeStampLabel(message) 67 | } else if (message.isAIThinkingMessage()) { 68 | PulsingText(message.content, isLoading = true) 69 | } else { 70 | Unit 71 | } 72 | } 73 | } 74 | } 75 | 76 | @Composable 77 | private fun TimeStampLabel(message: ChatMessage) { 78 | Row( 79 | modifier = Modifier.fillMaxWidth(), 80 | horizontalArrangement = Arrangement.End, 81 | verticalAlignment = Alignment.CenterVertically 82 | ) { 83 | Text( 84 | text = message.formattedTime(), 85 | style = JewelTheme.editorTextStyle.copy(fontSize = 12.sp), 86 | color = ChatAppColors.Text.timestamp 87 | ) 88 | } 89 | } 90 | 91 | @Composable 92 | private fun MessageContent(message: ChatMessage) { 93 | Text( 94 | text = message.content, 95 | style = JewelTheme.defaultTextStyle.copy( 96 | fontSize = 14.sp, 97 | fontWeight = FontWeight.Normal, 98 | color = ChatAppColors.Text.normal, 99 | lineHeight = 20.sp 100 | ), 101 | modifier = Modifier.padding(bottom = 8.dp) 102 | ) 103 | } 104 | 105 | @Composable 106 | private fun AuthorName(message: ChatMessage) { 107 | Text( 108 | text = if (message.isMyMessage) "Me" else message.author, 109 | style = JewelTheme.defaultTextStyle.copy( 110 | fontWeight = FontWeight.Bold, 111 | fontSize = 12.sp, 112 | color = ChatAppColors.Text.authorName 113 | ), 114 | modifier = Modifier.padding(bottom = 6.dp) 115 | ) 116 | } 117 | 118 | @Composable 119 | private fun Modifier.messageBorder( 120 | shape: Shape, 121 | isMyMessage: Boolean, 122 | isHighlightedInSearch: Boolean, 123 | isMatchingSearch: Boolean 124 | ) = border( 125 | width = if (isMyMessage) 0.dp else 1.dp, 126 | color = when { 127 | isHighlightedInSearch -> ChatAppColors.MessageBubble.searchHighlightedBackgroundBorder 128 | isMatchingSearch && isMyMessage -> ChatAppColors.MessageBubble.matchingMyBorder 129 | isMatchingSearch && !isMyMessage -> ChatAppColors.MessageBubble.matchingOthersBorder 130 | isMyMessage -> ChatAppColors.MessageBubble.myBackgroundBorder 131 | else -> ChatAppColors.MessageBubble.othersBackgroundBorder 132 | }, 133 | shape = shape 134 | ) -------------------------------------------------------------------------------- /src/test/kotlin/com/phodal/lotus/chat/history/ConversationManagerTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.history 2 | 3 | import com.phodal.lotus.chat.model.ChatMessage 4 | import org.junit.jupiter.api.AfterEach 5 | import org.junit.jupiter.api.BeforeEach 6 | import org.junit.jupiter.api.Disabled 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.io.TempDir 9 | import java.nio.file.Path 10 | import java.time.LocalDateTime 11 | import kotlin.test.assertEquals 12 | import kotlin.test.assertNotNull 13 | import kotlin.test.assertTrue 14 | 15 | @Disabled("Xodus database causes thread leak warnings in test environment. Functionality verified manually.") 16 | class ConversationManagerTest { 17 | 18 | private lateinit var tempDir: Path 19 | private lateinit var historyService: ConversationHistoryService 20 | private lateinit var conversationManager: ConversationManager 21 | 22 | @BeforeEach 23 | fun setUp(@TempDir tempDir: Path) { 24 | this.tempDir = tempDir 25 | historyService = ConversationHistoryService(tempDir.toString()) 26 | conversationManager = ConversationManager(historyService) 27 | } 28 | 29 | @AfterEach 30 | fun tearDown() { 31 | conversationManager.close() 32 | } 33 | 34 | @Test 35 | fun testCreateNewConversation() { 36 | val conversationId = conversationManager.createNewConversation("Test Conversation") 37 | 38 | assertNotNull(conversationId) 39 | assertEquals("Test Conversation", conversationManager.currentConversationTitle.value) 40 | assertTrue(conversationManager.getAllConversations().isNotEmpty()) 41 | } 42 | 43 | @Test 44 | fun testSwitchToConversation() { 45 | val id1 = conversationManager.createNewConversation("Conversation 1") 46 | val id2 = conversationManager.createNewConversation("Conversation 2") 47 | 48 | conversationManager.switchToConversation(id1) 49 | assertEquals("Conversation 1", conversationManager.currentConversationTitle.value) 50 | 51 | conversationManager.switchToConversation(id2) 52 | assertEquals("Conversation 2", conversationManager.currentConversationTitle.value) 53 | } 54 | 55 | @Test 56 | fun testSaveCurrentConversation() { 57 | val conversationId = conversationManager.createNewConversation("Test") 58 | 59 | val messages = listOf( 60 | ChatMessage( 61 | id = "1", 62 | content = "Hello", 63 | author = "User", 64 | isMyMessage = true, 65 | timestamp = LocalDateTime.now(), 66 | type = ChatMessage.ChatMessageType.TEXT, 67 | format = ChatMessage.MessageFormat.MARKDOWN, 68 | isStreaming = false 69 | ) 70 | ) 71 | 72 | conversationManager.saveCurrentConversation(messages) 73 | 74 | val conversation = historyService.getConversation(conversationId) 75 | assertNotNull(conversation) 76 | assertEquals(1, conversation.messages.size) 77 | assertEquals("Hello", conversation.messages[0].content) 78 | } 79 | 80 | @Test 81 | fun testUpdateConversationTitle() { 82 | val conversationId = conversationManager.createNewConversation("Original Title") 83 | 84 | conversationManager.updateCurrentConversationTitle("Updated Title") 85 | 86 | val conversation = historyService.getConversation(conversationId) 87 | assertNotNull(conversation) 88 | assertEquals("Updated Title", conversation.title) 89 | } 90 | 91 | @Test 92 | fun testDeleteConversation() { 93 | val id1 = conversationManager.createNewConversation("Conversation 1") 94 | val id2 = conversationManager.createNewConversation("Conversation 2") 95 | 96 | val initialCount = conversationManager.getAllConversations().size 97 | 98 | conversationManager.deleteConversation(id1) 99 | 100 | val finalCount = conversationManager.getAllConversations().size 101 | assertEquals(initialCount - 1, finalCount) 102 | } 103 | 104 | @Test 105 | fun testGetAllConversations() { 106 | conversationManager.createNewConversation("Conversation 1") 107 | conversationManager.createNewConversation("Conversation 2") 108 | conversationManager.createNewConversation("Conversation 3") 109 | 110 | val conversations = conversationManager.getAllConversations() 111 | assertEquals(3, conversations.size) 112 | } 113 | 114 | @Test 115 | fun testSearchConversations() { 116 | conversationManager.createNewConversation("Python Tutorial") 117 | conversationManager.createNewConversation("Kotlin Guide") 118 | conversationManager.createNewConversation("Python Advanced") 119 | 120 | // Give the database time to persist 121 | Thread.sleep(100) 122 | 123 | val results = conversationManager.searchConversations("Python") 124 | assertTrue(results.size >= 2, "Expected at least 2 results, got ${results.size}") 125 | } 126 | 127 | @Test 128 | fun testClearAllConversations() { 129 | conversationManager.createNewConversation("Conversation 1") 130 | conversationManager.createNewConversation("Conversation 2") 131 | 132 | conversationManager.clearAllConversations() 133 | 134 | // After clearing, a new conversation should be created 135 | val conversations = conversationManager.getAllConversations() 136 | assertEquals(1, conversations.size) 137 | } 138 | } 139 | 140 | -------------------------------------------------------------------------------- /ai-core/src/main/kotlin/com/phodal/lotus/aicore/context/summarization/ContentSelectionStrategy.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.context.summarization 2 | 3 | import com.phodal.lotus.aicore.token.TokenCounter 4 | 5 | /** 6 | * Result of content selection 7 | */ 8 | data class SelectedContent( 9 | val messages: List, 10 | val totalTokens: Int, 11 | val userMessageCount: Int, 12 | val aiMessageCount: Int 13 | ) 14 | 15 | /** 16 | * Strategy for selecting which messages to include in the summarization context 17 | * 18 | * This interface allows different strategies for choosing which messages to include 19 | * based on token limits and other criteria. 20 | */ 21 | interface ContentSelectionStrategy { 22 | /** 23 | * Select messages to include in the summarization context 24 | * 25 | * @param messages All messages in the conversation 26 | * @param maxTokens Maximum tokens to include 27 | * @param tokenCounter Token counter for calculating token counts 28 | * @return SelectedContent with selected messages and metadata 29 | */ 30 | fun selectMessages( 31 | messages: List, 32 | maxTokens: Int, 33 | tokenCounter: TokenCounter 34 | ): SelectedContent 35 | } 36 | 37 | /** 38 | * Default content selection strategy 39 | * 40 | * This strategy: 41 | * 1. Always includes the first user message (to establish context) 42 | * 2. Prioritizes user messages over AI responses 43 | * 3. Works backwards from the most recent messages 44 | * 4. Respects the token limit 45 | */ 46 | class DefaultContentSelectionStrategy : ContentSelectionStrategy { 47 | 48 | override fun selectMessages( 49 | messages: List, 50 | maxTokens: Int, 51 | tokenCounter: TokenCounter 52 | ): SelectedContent { 53 | if (messages.isEmpty()) { 54 | return SelectedContent(emptyList(), 0, 0, 0) 55 | } 56 | 57 | val selected = mutableListOf() 58 | var totalTokens = 0 59 | var userMessageCount = 0 60 | var aiMessageCount = 0 61 | 62 | // Always include the first user message for context 63 | val firstUserMessage = messages.firstOrNull { it.isUserMessage } 64 | if (firstUserMessage != null) { 65 | val tokens = tokenCounter.estimateTokenCount(firstUserMessage.content) 66 | if (tokens <= maxTokens) { 67 | selected.add(firstUserMessage) 68 | totalTokens += tokens 69 | userMessageCount++ 70 | } 71 | } 72 | 73 | // Work backwards from the most recent messages 74 | // Prioritize user messages 75 | val userMessages = messages.filter { it.isUserMessage && it != firstUserMessage } 76 | val aiMessages = messages.filter { !it.isUserMessage } 77 | 78 | // Add user messages first (in reverse order, most recent first) 79 | for (msg in userMessages.asReversed()) { 80 | val tokens = tokenCounter.estimateTokenCount(msg.content) 81 | if (totalTokens + tokens <= maxTokens) { 82 | selected.add(msg) 83 | totalTokens += tokens 84 | userMessageCount++ 85 | } else { 86 | break 87 | } 88 | } 89 | 90 | // Then add AI messages if space allows 91 | for (msg in aiMessages.asReversed()) { 92 | val tokens = tokenCounter.estimateTokenCount(msg.content) 93 | if (totalTokens + tokens <= maxTokens) { 94 | selected.add(msg) 95 | totalTokens += tokens 96 | aiMessageCount++ 97 | } else { 98 | break 99 | } 100 | } 101 | 102 | // Sort by timestamp to maintain chronological order 103 | selected.sortBy { it.timestamp } 104 | 105 | return SelectedContent( 106 | messages = selected, 107 | totalTokens = totalTokens, 108 | userMessageCount = userMessageCount, 109 | aiMessageCount = aiMessageCount 110 | ) 111 | } 112 | } 113 | 114 | /** 115 | * Recency-focused content selection strategy 116 | * 117 | * This strategy prioritizes recent messages over older ones, 118 | * regardless of whether they are user or AI messages. 119 | */ 120 | class RecencyFocusedStrategy : ContentSelectionStrategy { 121 | 122 | override fun selectMessages( 123 | messages: List, 124 | maxTokens: Int, 125 | tokenCounter: TokenCounter 126 | ): SelectedContent { 127 | if (messages.isEmpty()) { 128 | return SelectedContent(emptyList(), 0, 0, 0) 129 | } 130 | 131 | val selected = mutableListOf() 132 | var totalTokens = 0 133 | var userMessageCount = 0 134 | var aiMessageCount = 0 135 | 136 | // Work backwards from the most recent messages 137 | for (msg in messages.asReversed()) { 138 | val tokens = tokenCounter.estimateTokenCount(msg.content) 139 | if (totalTokens + tokens <= maxTokens) { 140 | selected.add(msg) 141 | totalTokens += tokens 142 | if (msg.isUserMessage) userMessageCount++ else aiMessageCount++ 143 | } else { 144 | break 145 | } 146 | } 147 | 148 | // Sort by timestamp to maintain chronological order 149 | selected.sortBy { it.timestamp } 150 | 151 | return SelectedContent( 152 | messages = selected, 153 | totalTokens = totalTokens, 154 | userMessageCount = userMessageCount, 155 | aiMessageCount = aiMessageCount 156 | ) 157 | } 158 | } 159 | 160 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/summarization/ConversationSummarizerTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.summarization 2 | 3 | import com.phodal.lotus.aicore.client.AIClient 4 | import com.phodal.lotus.aicore.client.AIMessageResult 5 | import com.phodal.lotus.aicore.context.summarization.AIConversationSummarizer 6 | import com.phodal.lotus.aicore.context.summarization.ConversationMessage 7 | import com.phodal.lotus.aicore.context.summarization.NoOpConversationSummarizer 8 | import com.phodal.lotus.aicore.context.summarization.SummarizationConfig 9 | import com.phodal.lotus.aicore.token.FallbackTokenCounter 10 | import com.phodal.lotus.aicore.token.TokenUsage 11 | import kotlinx.coroutines.runBlocking 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.Assertions.* 14 | 15 | class ConversationSummarizerTest { 16 | 17 | private val tokenCounter = FallbackTokenCounter("test-model") 18 | 19 | private fun createMockAIClient(): AIClient { 20 | return object : AIClient { 21 | override suspend fun sendMessage(message: String): AIMessageResult { 22 | return AIMessageResult( 23 | content = "This is a mock summary of the conversation.", 24 | tokenUsage = TokenUsage.of(100, 50, "test-model", "test-conv") 25 | ) 26 | } 27 | 28 | override suspend fun streamMessage( 29 | message: String, 30 | onChunk: (String) -> Unit, 31 | cancellationToken: Any? 32 | ): TokenUsage? { 33 | return TokenUsage.of(100, 50, "test-model", "test-conv") 34 | } 35 | 36 | override fun isConfigured(): Boolean = true 37 | } 38 | } 39 | 40 | private fun createTestMessages(): List { 41 | return listOf( 42 | ConversationMessage( 43 | id = "1", 44 | content = "What is Kotlin?", 45 | author = "User", 46 | isUserMessage = true, 47 | timestamp = 1000L 48 | ), 49 | ConversationMessage( 50 | id = "2", 51 | content = "Kotlin is a modern programming language that runs on the JVM.", 52 | author = "Assistant", 53 | isUserMessage = false, 54 | timestamp = 2000L 55 | ), 56 | ConversationMessage( 57 | id = "3", 58 | content = "How is it different from Java?", 59 | author = "User", 60 | isUserMessage = true, 61 | timestamp = 3000L 62 | ), 63 | ConversationMessage( 64 | id = "4", 65 | content = "Kotlin has null safety, extension functions, and more concise syntax than Java.", 66 | author = "Assistant", 67 | isUserMessage = false, 68 | timestamp = 4000L 69 | ) 70 | ) 71 | } 72 | 73 | @Test 74 | fun testAIConversationSummarizerCreation() { 75 | val aiClient = createMockAIClient() 76 | val summarizer = AIConversationSummarizer(aiClient, tokenCounter) 77 | 78 | assertTrue(summarizer.isReady()) 79 | } 80 | 81 | @Test 82 | fun testSummarizeConversation() = runBlocking { 83 | val aiClient = createMockAIClient() 84 | val summarizer = AIConversationSummarizer(aiClient, tokenCounter) 85 | val messages = createTestMessages() 86 | 87 | val result = summarizer.summarize(messages) 88 | 89 | assertNotNull(result) 90 | assertNotNull(result.summary) 91 | assertTrue(result.summary.isNotEmpty()) 92 | assertEquals(messages.size, result.messagesIncluded) 93 | println("Summary: ${result.summary}") 94 | } 95 | 96 | @Test 97 | fun testSummarizeWithCustomConfig() = runBlocking { 98 | val aiClient = createMockAIClient() 99 | val summarizer = AIConversationSummarizer(aiClient, tokenCounter) 100 | val messages = createTestMessages() 101 | 102 | val config = SummarizationConfig( 103 | maxSummaryTokens = 200, 104 | maxContextTokens = 500, 105 | prioritizeUserMessages = true 106 | ) 107 | 108 | val result = summarizer.summarize(messages, config) 109 | 110 | assertNotNull(result) 111 | assertTrue(result.summary.isNotEmpty()) 112 | } 113 | 114 | @Test 115 | fun testSummarizeEmptyConversation() = runBlocking { 116 | val aiClient = createMockAIClient() 117 | val summarizer = AIConversationSummarizer(aiClient, tokenCounter) 118 | 119 | val result = summarizer.summarize(emptyList()) 120 | 121 | assertNotNull(result) 122 | assertEquals(0, result.messagesIncluded) 123 | } 124 | 125 | @Test 126 | fun testNoOpSummarizer() = runBlocking { 127 | val summarizer = NoOpConversationSummarizer() 128 | val messages = createTestMessages() 129 | 130 | assertFalse(summarizer.isReady()) 131 | 132 | val result = summarizer.summarize(messages) 133 | 134 | assertNotNull(result) 135 | assertTrue(result.summary.contains("not available")) 136 | } 137 | 138 | @Test 139 | fun testSummarizationResultMetadata() = runBlocking { 140 | val aiClient = createMockAIClient() 141 | val summarizer = AIConversationSummarizer(aiClient, tokenCounter) 142 | val messages = createTestMessages() 143 | 144 | val result = summarizer.summarize(messages) 145 | 146 | assertTrue(result.messagesIncluded > 0) 147 | assertTrue(result.summaryTokenCount >= 0) 148 | assertNotNull(result.tokenUsage) 149 | } 150 | } 151 | 152 | -------------------------------------------------------------------------------- /ai-core/src/test/kotlin/com/phodal/lotus/aicore/summarization/ContentSelectionStrategyTest.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.aicore.summarization 2 | 3 | import com.phodal.lotus.aicore.context.summarization.ConversationMessage 4 | import com.phodal.lotus.aicore.context.summarization.DefaultContentSelectionStrategy 5 | import com.phodal.lotus.aicore.context.summarization.RecencyFocusedStrategy 6 | import com.phodal.lotus.aicore.token.FallbackTokenCounter 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.Assertions.* 9 | 10 | class ContentSelectionStrategyTest { 11 | 12 | private val tokenCounter = FallbackTokenCounter("test-model") 13 | 14 | private fun createTestMessages(): List { 15 | return listOf( 16 | ConversationMessage( 17 | id = "1", 18 | content = "Hello, can you help me with Kotlin?", 19 | author = "User", 20 | isUserMessage = true, 21 | timestamp = 1000L 22 | ), 23 | ConversationMessage( 24 | id = "2", 25 | content = "Of course! I'd be happy to help with Kotlin. What would you like to know?", 26 | author = "Assistant", 27 | isUserMessage = false, 28 | timestamp = 2000L 29 | ), 30 | ConversationMessage( 31 | id = "3", 32 | content = "How do I use coroutines?", 33 | author = "User", 34 | isUserMessage = true, 35 | timestamp = 3000L 36 | ), 37 | ConversationMessage( 38 | id = "4", 39 | content = "Coroutines are a way to write asynchronous code in Kotlin. You can use suspend functions and launch or async builders.", 40 | author = "Assistant", 41 | isUserMessage = false, 42 | timestamp = 4000L 43 | ), 44 | ConversationMessage( 45 | id = "5", 46 | content = "Thanks! That's very helpful.", 47 | author = "User", 48 | isUserMessage = true, 49 | timestamp = 5000L 50 | ) 51 | ) 52 | } 53 | 54 | @Test 55 | fun testDefaultContentSelectionStrategy() { 56 | val strategy = DefaultContentSelectionStrategy() 57 | val messages = createTestMessages() 58 | 59 | val selected = strategy.selectMessages(messages, 1000, tokenCounter) 60 | 61 | assertNotNull(selected) 62 | assertTrue(selected.messages.isNotEmpty()) 63 | assertTrue(selected.userMessageCount > 0) 64 | println("Selected ${selected.messages.size} messages with ${selected.totalTokens} tokens") 65 | } 66 | 67 | @Test 68 | fun testDefaultStrategyIncludesFirstUserMessage() { 69 | val strategy = DefaultContentSelectionStrategy() 70 | val messages = createTestMessages() 71 | 72 | val selected = strategy.selectMessages(messages, 1000, tokenCounter) 73 | 74 | // Should include the first user message 75 | val firstUserMessage = messages.first { it.isUserMessage } 76 | assertTrue(selected.messages.any { it.id == firstUserMessage.id }) 77 | } 78 | 79 | @Test 80 | fun testDefaultStrategyRespectTokenLimit() { 81 | val strategy = DefaultContentSelectionStrategy() 82 | val messages = createTestMessages() 83 | val maxTokens = 50 84 | 85 | val selected = strategy.selectMessages(messages, maxTokens, tokenCounter) 86 | 87 | assertTrue(selected.totalTokens <= maxTokens) 88 | println("Selected ${selected.messages.size} messages with ${selected.totalTokens} tokens (limit: $maxTokens)") 89 | } 90 | 91 | @Test 92 | fun testRecencyFocusedStrategy() { 93 | val strategy = RecencyFocusedStrategy() 94 | val messages = createTestMessages() 95 | 96 | val selected = strategy.selectMessages(messages, 1000, tokenCounter) 97 | 98 | assertNotNull(selected) 99 | assertTrue(selected.messages.isNotEmpty()) 100 | println("Recency strategy selected ${selected.messages.size} messages") 101 | } 102 | 103 | @Test 104 | fun testRecencyStrategyPrioritizesRecentMessages() { 105 | val strategy = RecencyFocusedStrategy() 106 | val messages = createTestMessages() 107 | 108 | val selected = strategy.selectMessages(messages, 1000, tokenCounter) 109 | 110 | // Should include the most recent message 111 | val mostRecentMessage = messages.maxByOrNull { it.timestamp } 112 | assertTrue(selected.messages.any { it.id == mostRecentMessage?.id }) 113 | } 114 | 115 | @Test 116 | fun testEmptyMessagesList() { 117 | val strategy = DefaultContentSelectionStrategy() 118 | 119 | val selected = strategy.selectMessages(emptyList(), 1000, tokenCounter) 120 | 121 | assertTrue(selected.messages.isEmpty()) 122 | assertEquals(0, selected.totalTokens) 123 | assertEquals(0, selected.userMessageCount) 124 | assertEquals(0, selected.aiMessageCount) 125 | } 126 | 127 | @Test 128 | fun testVerySmallTokenLimit() { 129 | val strategy = DefaultContentSelectionStrategy() 130 | val messages = createTestMessages() 131 | 132 | val selected = strategy.selectMessages(messages, 5, tokenCounter) 133 | 134 | // Should select at most a few messages 135 | assertTrue(selected.messages.size <= messages.size) 136 | assertTrue(selected.totalTokens <= 5) 137 | } 138 | 139 | @Test 140 | fun testMessageOrderPreservation() { 141 | val strategy = DefaultContentSelectionStrategy() 142 | val messages = createTestMessages() 143 | 144 | val selected = strategy.selectMessages(messages, 1000, tokenCounter) 145 | 146 | // Messages should be in chronological order 147 | for (i in 0 until selected.messages.size - 1) { 148 | assertTrue(selected.messages[i].timestamp <= selected.messages[i + 1].timestamp) 149 | } 150 | } 151 | } 152 | 153 | -------------------------------------------------------------------------------- /ai-core/README.md: -------------------------------------------------------------------------------- 1 | # AI Core Module 2 | 3 | The `ai-core` module provides a unified interface for integrating multiple LLM (Large Language Model) providers into the Lotus Chat application. It uses the LangChain4j framework to support various AI providers. 4 | 5 | ## Supported LLM Providers 6 | 7 | - **DeepSeek** - High-performance open-source model 8 | - **OpenAI** - GPT-4 and other OpenAI models 9 | - **Claude** - Anthropic's Claude models 10 | - **Gemini** - Google's Gemini models 11 | 12 | ## Architecture 13 | 14 | ### Core Components 15 | 16 | 1. **LLMConfig** - Configuration data class for LLM settings 17 | - Provider selection 18 | - API Key management 19 | - Model selection 20 | - Temperature and token limits 21 | 22 | 2. **LLMConfigManager** - Manages configuration persistence 23 | - Loads/saves configurations from local storage 24 | - Monitors configuration changes 25 | - Stores configs in `~/.lotus/ai/llm_config.properties` 26 | 27 | 3. **AIClient** - Interface for AI communication 28 | - `sendMessage()` - Send a message and get a response 29 | - `streamMessage()` - Stream responses for real-time updates 30 | - `isConfigured()` - Check if client is properly configured 31 | 32 | 4. **LangChain4jAIClient** - Implementation using LangChain4j framework 33 | - Supports all LLM providers 34 | - Handles API communication 35 | - Error handling and fallbacks 36 | 37 | 5. **AIServiceFactory** - Factory for creating AI service instances 38 | - Singleton pattern for AI client management 39 | - Configuration initialization 40 | - Client lifecycle management 41 | 42 | ## Usage 43 | 44 | ### In ChatViewModel 45 | 46 | ```kotlin 47 | // Initialize with config manager 48 | val configManager = LLMConfigManager() 49 | val viewModel = ChatViewModel(coroutineScope, repository, configManager) 50 | 51 | // Save AI configuration with model selection 52 | viewModel.onAIConfigSaved( 53 | provider = LLMProvider.OPENAI, 54 | apiKey = "your-api-key", 55 | model = "gpt-4o" // or use custom model name 56 | ) 57 | 58 | // Check if AI is configured 59 | val isConfigured = viewModel.isAIConfigured.collectAsState() 60 | ``` 61 | 62 | ### Available Models 63 | 64 | Each provider has a set of recommended models: 65 | 66 | ```kotlin 67 | // Get available models for a provider 68 | val openaiModels = LLMConfig.getAvailableModels(LLMProvider.OPENAI) 69 | // Returns: [o4-mini, o3-mini, gpt-4o, gpt-4o-mini] 70 | 71 | val claudeModels = LLMConfig.getAvailableModels(LLMProvider.CLAUDE) 72 | // Returns: [claude-3-5-sonnet-latest, claude-3-5-haiku-latest, ...] 73 | 74 | // Get default model for a provider 75 | val defaultModel = LLMConfig.getDefaultModel(LLMProvider.CLAUDE) 76 | // Returns: claude-3-5-sonnet-latest 77 | ``` 78 | 79 | ### Custom Models 80 | 81 | You can also use custom model names (useful for new models or special configurations): 82 | 83 | ```kotlin 84 | val config = LLMConfig( 85 | provider = LLMProvider.OPENAI, 86 | apiKey = "your-api-key", 87 | model = "gpt-4-turbo-2024-04-09" // Custom model name 88 | ) 89 | ``` 90 | 91 | ### In ChatRepository 92 | 93 | The repository automatically uses the configured AI service: 94 | 95 | ```kotlin 96 | // If AI is configured, uses real AI service 97 | // Otherwise, falls back to simulated responses 98 | private suspend fun simulateAIResponse(userMessage: String) { 99 | val aiClient = AIServiceFactory.getAIClient() 100 | val response = if (aiClient?.isConfigured() == true) { 101 | aiClient.sendMessage(userMessage) 102 | } else { 103 | aiResponseGenerator.generateAIResponse(userMessage) 104 | } 105 | } 106 | ``` 107 | 108 | ## Configuration 109 | 110 | ### Setting Up API Keys and Models 111 | 112 | 1. Open the Chat App 113 | 2. Click the Settings icon (⚙️) in the header 114 | 3. Select your preferred LLM provider 115 | 4. Choose a model from the dropdown list, or select "Custom Model..." to enter a custom model name 116 | 5. Enter your API key 117 | 6. Click Save 118 | 119 | Your API key and model configuration are stored locally in `~/.lotus/ai/llm_config.properties` and never sent to external servers. 120 | 121 | ### Model Selection 122 | 123 | - **Predefined Models**: Each provider has a curated list of recommended models. Select from the dropdown for quick access. 124 | - **Custom Models**: If you need to use a newer model or a specific model variant, select "Custom Model..." and enter the model name directly. 125 | - **Default Models**: If no model is specified, the system uses the latest recommended model for the selected provider. 126 | 127 | ### Environment Variables 128 | 129 | You can also set API keys via environment variables: 130 | 131 | ```bash 132 | # DeepSeek 133 | export DEEPSEEK_API_KEY=your-api-key 134 | 135 | # OpenAI 136 | export OPENAI_API_KEY=your-api-key 137 | 138 | # Claude (Anthropic) 139 | export ANTHROPIC_API_KEY=your-api-key 140 | 141 | # Gemini (Google) 142 | export GOOGLE_API_KEY=your-api-key 143 | ``` 144 | 145 | ## Future Enhancements 146 | 147 | This module is designed to be independent and can be extracted into a standalone IDEA plugin or application. Future improvements include: 148 | 149 | - Streaming response support 150 | - Model-specific configurations 151 | - Request/response logging 152 | - Rate limiting and caching 153 | - Multi-provider fallback strategies 154 | - Custom prompt templates 155 | 156 | ## Dependencies 157 | 158 | - LangChain4j (0.31.0+) 159 | - Kotlin Coroutines (1.10.1+) 160 | - Kotlin Standard Library 161 | 162 | ## Testing 163 | 164 | To test the AI integration: 165 | 166 | 1. Configure an API key through the UI 167 | 2. Send a message in the chat 168 | 3. The response should come from the configured AI provider 169 | 4. If no API key is configured, responses will be simulated 170 | 171 | ## Troubleshooting 172 | 173 | ### "AI client is not configured" 174 | - Open the Settings dialog and configure your API key 175 | - Ensure the API key is valid for the selected provider 176 | 177 | ### "Failed to get response from [Provider]" 178 | - Check your internet connection 179 | - Verify your API key is correct 180 | - Check if the API service is available 181 | - Review the error message for more details 182 | 183 | ### Configuration not persisting 184 | - Ensure `~/.lotus/ai/` directory is writable 185 | - Check file permissions on `llm_config.properties` 186 | 187 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/history/ConversationManager.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.history 2 | 3 | import com.phodal.lotus.aicore.context.summarization.ConversationMessage 4 | import com.phodal.lotus.chat.model.ChatMessage 5 | import com.phodal.lotus.aicore.context.summarization.ConversationSummarizer 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | import java.time.ZoneId 10 | import java.util.* 11 | 12 | /** 13 | * Manages the current conversation and provides access to conversation history. 14 | * Handles switching between conversations, creating new ones, and persisting them. 15 | */ 16 | class ConversationManager( 17 | private val historyService: ConversationHistoryService = ConversationHistoryService.getInstance(), 18 | private val conversationSummarizer: ConversationSummarizer? = null 19 | ) { 20 | private val _currentConversationId = MutableStateFlow(null) 21 | val currentConversationId: StateFlow = _currentConversationId.asStateFlow() 22 | 23 | private val _currentConversationTitle = MutableStateFlow("Current Conversation") 24 | val currentConversationTitle: StateFlow = _currentConversationTitle.asStateFlow() 25 | 26 | private val _conversationHistories = MutableStateFlow>(emptyList()) 27 | val conversationHistories: StateFlow> = _conversationHistories.asStateFlow() 28 | 29 | private val _currentConversation = MutableStateFlow(null) 30 | val currentConversation: StateFlow = _currentConversation.asStateFlow() 31 | 32 | init { 33 | refreshConversationList() 34 | } 35 | 36 | /** 37 | * Create a new conversation 38 | */ 39 | fun createNewConversation(title: String = "New Conversation"): String { 40 | val conversationId = UUID.randomUUID().toString() 41 | val newConversation = ConversationHistory( 42 | id = conversationId, 43 | title = title, 44 | messages = emptyList(), 45 | createdAt = System.currentTimeMillis(), 46 | updatedAt = System.currentTimeMillis() 47 | ) 48 | 49 | historyService.saveConversation( 50 | id = conversationId, 51 | title = title, 52 | messages = emptyList() 53 | ) 54 | 55 | switchToConversation(conversationId) 56 | refreshConversationList() 57 | 58 | return conversationId 59 | } 60 | 61 | /** 62 | * Switch to an existing conversation 63 | */ 64 | fun switchToConversation(conversationId: String) { 65 | val conversation = historyService.getConversation(conversationId) 66 | if (conversation != null) { 67 | _currentConversationId.value = conversationId 68 | _currentConversationTitle.value = conversation.title 69 | _currentConversation.value = conversation 70 | } 71 | } 72 | 73 | /** 74 | * Save the current conversation with new messages 75 | */ 76 | fun saveCurrentConversation(messages: List) { 77 | val conversationId = _currentConversationId.value ?: return 78 | val title = _currentConversationTitle.value 79 | 80 | val updatedConversation = historyService.updateConversation(conversationId, messages) 81 | if (updatedConversation != null) { 82 | _currentConversation.value = updatedConversation 83 | refreshConversationList() 84 | } 85 | } 86 | 87 | /** 88 | * Update the title of the current conversation 89 | */ 90 | fun updateCurrentConversationTitle(newTitle: String) { 91 | val conversationId = _currentConversationId.value ?: return 92 | val currentMessages = _currentConversation.value?.messages ?: emptyList() 93 | 94 | historyService.saveConversation( 95 | id = conversationId, 96 | title = newTitle, 97 | messages = currentMessages 98 | ) 99 | 100 | _currentConversationTitle.value = newTitle 101 | refreshConversationList() 102 | } 103 | 104 | /** 105 | * Delete a conversation 106 | */ 107 | fun deleteConversation(conversationId: String) { 108 | historyService.deleteConversation(conversationId) 109 | 110 | // If we deleted the current conversation, create a new one 111 | if (_currentConversationId.value == conversationId) { 112 | createNewConversation() 113 | } else { 114 | refreshConversationList() 115 | } 116 | } 117 | 118 | /** 119 | * Refresh the list of conversations 120 | */ 121 | fun refreshConversationList() { 122 | _conversationHistories.value = historyService.getAllConversations() 123 | } 124 | 125 | /** 126 | * Get all conversations 127 | */ 128 | fun getAllConversations(): List { 129 | return _conversationHistories.value 130 | } 131 | 132 | /** 133 | * Search conversations 134 | */ 135 | fun searchConversations(query: String): List { 136 | return historyService.searchConversations(query) 137 | } 138 | 139 | /** 140 | * Clear all conversations 141 | */ 142 | fun clearAllConversations() { 143 | historyService.clearAll() 144 | createNewConversation() 145 | refreshConversationList() 146 | } 147 | 148 | /** 149 | * Summarize a conversation 150 | * 151 | * @param conversationId The ID of the conversation to summarize 152 | * @return The summary text, or null if summarization is not available 153 | */ 154 | suspend fun summarizeConversation(conversationId: String): String? { 155 | if (conversationSummarizer == null || !conversationSummarizer.isReady()) { 156 | return null 157 | } 158 | 159 | val conversation = historyService.getConversation(conversationId) ?: return null 160 | 161 | if (conversation.messages.isEmpty()) { 162 | return null 163 | } 164 | 165 | // Convert ChatMessage to ConversationMessage 166 | val messages = conversation.messages.map { msg -> 167 | ConversationMessage( 168 | id = msg.id, 169 | content = msg.content, 170 | author = msg.author, 171 | isUserMessage = msg.isMyMessage, 172 | timestamp = msg.timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() 173 | ) 174 | } 175 | 176 | // Generate summary 177 | val result = conversationSummarizer.summarize(messages) 178 | 179 | // Save summary to conversation 180 | historyService.updateConversationSummary(conversationId, result.summary) 181 | 182 | // Update current conversation if it's the one being summarized 183 | if (_currentConversationId.value == conversationId) { 184 | val updated = historyService.getConversation(conversationId) 185 | if (updated != null) { 186 | _currentConversation.value = updated 187 | } 188 | } 189 | 190 | return result.summary 191 | } 192 | 193 | /** 194 | * Close the manager and cleanup resources 195 | */ 196 | fun close() { 197 | historyService.close() 198 | } 199 | 200 | companion object { 201 | @Volatile 202 | private var instance: ConversationManager? = null 203 | 204 | fun getInstance(): ConversationManager { 205 | return instance ?: synchronized(this) { 206 | instance ?: ConversationManager().also { instance = it } 207 | } 208 | } 209 | } 210 | } 211 | 212 | -------------------------------------------------------------------------------- /src/main/kotlin/com/phodal/lotus/chat/ui/PromptInput.kt: -------------------------------------------------------------------------------- 1 | package com.phodal.lotus.chat.ui 2 | 3 | import androidx.compose.foundation.border 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.foundation.text.input.TextFieldState 7 | import androidx.compose.foundation.text.input.rememberTextFieldState 8 | import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd 9 | import androidx.compose.runtime.* 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.clip 13 | import androidx.compose.ui.input.key.* 14 | import androidx.compose.ui.unit.dp 15 | import kotlinx.coroutines.flow.distinctUntilChanged 16 | import org.jetbrains.jewel.foundation.theme.JewelTheme 17 | import org.jetbrains.jewel.ui.component.* 18 | import org.jetbrains.jewel.ui.theme.iconButtonStyle 19 | import com.phodal.lotus.chat.ChatAppColors 20 | import com.phodal.lotus.chat.ChatAppIcons 21 | import com.phodal.lotus.chat.viewmodel.MessageInputState 22 | import com.phodal.lotus.chat.viewmodel.isSending 23 | 24 | @Composable 25 | fun ChatSearchBarPreview() { 26 | val state = remember { mutableStateOf(MessageInputState.Enabled("")) } 27 | val textFieldState = rememberTextFieldState() 28 | 29 | PromptInput( 30 | Modifier 31 | .fillMaxWidth() 32 | .heightIn(max = 120.dp), 33 | promptInputState = state.value, 34 | textFieldState = textFieldState, 35 | onInputChanged = { 36 | state.value = if (it.isNotBlank()) MessageInputState.Enabled(it) else MessageInputState.Disabled 37 | }, 38 | onSend = { text -> 39 | if (state.value is MessageInputState.Sending) { 40 | state.value = MessageInputState.Disabled 41 | } else { 42 | state.value = MessageInputState.Sending(text) 43 | } 44 | }, 45 | ) 46 | } 47 | 48 | @Composable 49 | fun PromptInput( 50 | modifier: Modifier = Modifier, 51 | promptInputState: MessageInputState = MessageInputState.Disabled, 52 | textFieldState: TextFieldState = rememberTextFieldState(), 53 | hint: String = "Whats on your mind...", 54 | onInputChanged: (String) -> Unit = {}, 55 | onSend: (String) -> Unit = {}, 56 | onStop: (String) -> Unit = {}, 57 | isAIConfigured: Boolean = false 58 | ) { 59 | val isSending = promptInputState.isSending 60 | var skipInputChangeUpdate by remember { mutableStateOf(false) } 61 | 62 | LaunchedEffect(Unit) { 63 | snapshotFlow { textFieldState.text } 64 | .distinctUntilChanged() 65 | .collect { inputText -> 66 | if (skipInputChangeUpdate) { 67 | skipInputChangeUpdate = false 68 | return@collect 69 | } 70 | 71 | onInputChanged(inputText.toString()) 72 | } 73 | } 74 | 75 | Column( 76 | modifier 77 | .border(1.dp, ChatAppColors.Prompt.border, RoundedCornerShape(8.dp)) 78 | .clip(RoundedCornerShape(8.dp)) 79 | .padding(8.dp), 80 | ) { 81 | TextArea( 82 | state = textFieldState, 83 | modifier = Modifier 84 | .weight(0.75f) 85 | .fillMaxWidth() 86 | .padding(bottom = 4.dp) 87 | .onPreviewKeyEvent { keyEvent -> 88 | if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown) { 89 | if (keyEvent.isShiftPressed) { 90 | // Shift+Enter for new line - let default behavior handle it 91 | skipInputChangeUpdate = true 92 | textFieldState.setTextAndPlaceCursorAtEnd("${textFieldState.text}\n") 93 | false 94 | } else { 95 | // Enter to send/update message 96 | val message = textFieldState.text 97 | if (message.isNotBlank() && isAIConfigured) { 98 | if (isSending) { 99 | onStop(message.toString()) 100 | } else { 101 | onSend(message.toString()) 102 | skipInputChangeUpdate = true 103 | textFieldState.setTextAndPlaceCursorAtEnd("") 104 | } 105 | } 106 | true 107 | } 108 | } else { 109 | false 110 | } 111 | }, 112 | placeholder = { Text(if (isAIConfigured) hint else "Please configure AI first...") }, 113 | enabled = isAIConfigured 114 | ) 115 | 116 | Row( 117 | modifier = Modifier 118 | .weight(0.25f) 119 | .fillMaxWidth() 120 | .padding(top = 4.dp), 121 | verticalAlignment = Alignment.CenterVertically, 122 | horizontalArrangement = Arrangement.End 123 | ) { 124 | 125 | when (promptInputState) { 126 | MessageInputState.Disabled, 127 | is MessageInputState.Enabled, 128 | is MessageInputState.SendFailed, 129 | is MessageInputState.Sent -> { 130 | DefaultButton( 131 | modifier = Modifier.wrapContentSize(), 132 | enabled = promptInputState != MessageInputState.Disabled && isAIConfigured, 133 | onClick = { 134 | onSend(textFieldState.text.toString()) 135 | skipInputChangeUpdate = true 136 | textFieldState.setTextAndPlaceCursorAtEnd("") 137 | }, 138 | content = { 139 | Row( 140 | Modifier.padding(4.dp), 141 | verticalAlignment = Alignment.CenterVertically, 142 | horizontalArrangement = Arrangement.SpaceEvenly 143 | ) { 144 | 145 | Text("Send") 146 | 147 | Icon( 148 | modifier = Modifier.size(JewelTheme.iconButtonStyle.metrics.minSize.height), 149 | key = ChatAppIcons.Prompt.send, 150 | contentDescription = "Send", 151 | tint = if (promptInputState != MessageInputState.Disabled && isAIConfigured) ChatAppColors.Icon.enabledIconTint else ChatAppColors.Icon.disabledIconTint 152 | ) 153 | } 154 | } 155 | ) 156 | } 157 | 158 | is MessageInputState.Sending -> { 159 | OutlinedButton( 160 | modifier = Modifier.wrapContentSize(), 161 | onClick = { 162 | onStop(textFieldState.text.toString()) 163 | }, 164 | content = { 165 | Row( 166 | Modifier.padding(4.dp), 167 | verticalAlignment = Alignment.CenterVertically, 168 | horizontalArrangement = Arrangement.SpaceEvenly 169 | ) { 170 | 171 | Text("Stop") 172 | 173 | Icon( 174 | modifier = Modifier.size(JewelTheme.iconButtonStyle.metrics.minSize.height), 175 | key = ChatAppIcons.Prompt.stop, 176 | contentDescription = "Stop sending", 177 | tint = ChatAppColors.Icon.stopIconTint 178 | ) 179 | } 180 | } 181 | ) 182 | } 183 | } 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: 2 | # - Validate Gradle Wrapper. 3 | # - Run 'test' and 'verifyPlugin' tasks. 4 | # - Run Qodana inspections. 5 | # - Run the 'buildPlugin' task and prepare artifact for further tests. 6 | # - Run the 'runPluginVerifier' task. 7 | # - Create a draft release. 8 | # 9 | # The workflow is triggered on push and pull_request events. 10 | # 11 | # GitHub Actions reference: https://help.github.com/en/actions 12 | # 13 | ## JBIJPPTPL 14 | 15 | name: Build 16 | on: 17 | # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) 18 | push: 19 | branches: [ main ] 20 | # Trigger the workflow on any pull request 21 | pull_request: 22 | 23 | concurrency: 24 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 25 | cancel-in-progress: true 26 | 27 | jobs: 28 | 29 | # Prepare the environment and build the plugin 30 | build: 31 | name: Build 32 | runs-on: ubuntu-latest 33 | steps: 34 | 35 | # Free GitHub Actions Environment Disk Space 36 | - name: Maximize Build Space 37 | uses: jlumbroso/free-disk-space@v1.3.1 38 | with: 39 | tool-cache: false 40 | large-packages: false 41 | 42 | # Check out the current repository 43 | - name: Fetch Sources 44 | uses: actions/checkout@v5 45 | 46 | # Set up the Java environment for the next steps 47 | - name: Setup Java 48 | uses: actions/setup-java@v5 49 | with: 50 | distribution: zulu 51 | java-version: 21 52 | 53 | # Setup Gradle 54 | - name: Setup Gradle 55 | uses: gradle/actions/setup-gradle@v5 56 | 57 | # Build plugin 58 | - name: Build plugin 59 | run: ./gradlew buildPlugin 60 | 61 | # Prepare plugin archive content for creating artifact 62 | - name: Prepare Plugin Artifact 63 | id: artifact 64 | shell: bash 65 | run: | 66 | cd ${{ github.workspace }}/build/distributions 67 | FILENAME=`ls *.zip` 68 | unzip "$FILENAME" -d content 69 | 70 | echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT 71 | 72 | # Store an already-built plugin as an artifact for downloading 73 | - name: Upload artifact 74 | uses: actions/upload-artifact@v5 75 | with: 76 | name: ${{ steps.artifact.outputs.filename }} 77 | path: ./build/distributions/content/*/* 78 | 79 | # Run tests and upload a code coverage report 80 | test: 81 | name: Test 82 | needs: [ build ] 83 | runs-on: ubuntu-latest 84 | steps: 85 | 86 | # Free GitHub Actions Environment Disk Space 87 | - name: Maximize Build Space 88 | uses: jlumbroso/free-disk-space@v1.3.1 89 | with: 90 | tool-cache: false 91 | large-packages: false 92 | 93 | # Check out the current repository 94 | - name: Fetch Sources 95 | uses: actions/checkout@v5 96 | 97 | # Set up the Java environment for the next steps 98 | - name: Setup Java 99 | uses: actions/setup-java@v5 100 | with: 101 | distribution: zulu 102 | java-version: 21 103 | 104 | # Setup Gradle 105 | - name: Setup Gradle 106 | uses: gradle/actions/setup-gradle@v5 107 | with: 108 | cache-read-only: true 109 | 110 | # Run tests 111 | - name: Run Tests 112 | run: ./gradlew check 113 | 114 | # Collect Tests Result of failed tests 115 | - name: Collect Tests Result 116 | if: ${{ failure() }} 117 | uses: actions/upload-artifact@v5 118 | with: 119 | name: tests-result 120 | path: ${{ github.workspace }}/build/reports/tests 121 | 122 | # Upload the Kover report to CodeCov 123 | - name: Upload Code Coverage Report 124 | uses: codecov/codecov-action@v5 125 | with: 126 | files: ${{ github.workspace }}/build/reports/kover/report.xml 127 | token: ${{ secrets.CODECOV_TOKEN }} 128 | 129 | # Run Qodana inspections and provide a report 130 | inspectCode: 131 | name: Inspect code 132 | needs: [ build ] 133 | runs-on: ubuntu-latest 134 | permissions: 135 | contents: write 136 | checks: write 137 | pull-requests: write 138 | steps: 139 | 140 | # Free GitHub Actions Environment Disk Space 141 | - name: Maximize Build Space 142 | uses: jlumbroso/free-disk-space@v1.3.1 143 | with: 144 | tool-cache: false 145 | large-packages: false 146 | 147 | # Check out the current repository 148 | - name: Fetch Sources 149 | uses: actions/checkout@v5 150 | with: 151 | ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit 152 | fetch-depth: 0 # a full history is required for pull request analysis 153 | 154 | # Set up the Java environment for the next steps 155 | - name: Setup Java 156 | uses: actions/setup-java@v5 157 | with: 158 | distribution: zulu 159 | java-version: 21 160 | 161 | # Run Qodana inspections 162 | - name: Qodana - Code Inspection 163 | uses: JetBrains/qodana-action@v2025.2.1 164 | with: 165 | cache-default-branch-only: true 166 | 167 | # Run plugin structure verification along with IntelliJ Plugin Verifier 168 | verify: 169 | name: Verify plugin 170 | needs: [ build ] 171 | runs-on: ubuntu-latest 172 | steps: 173 | 174 | # Free GitHub Actions Environment Disk Space 175 | - name: Maximize Build Space 176 | uses: jlumbroso/free-disk-space@v1.3.1 177 | with: 178 | tool-cache: false 179 | large-packages: false 180 | 181 | # Check out the current repository 182 | - name: Fetch Sources 183 | uses: actions/checkout@v5 184 | 185 | # Set up the Java environment for the next steps 186 | - name: Setup Java 187 | uses: actions/setup-java@v5 188 | with: 189 | distribution: zulu 190 | java-version: 21 191 | 192 | # Setup Gradle 193 | - name: Setup Gradle 194 | uses: gradle/actions/setup-gradle@v5 195 | with: 196 | cache-read-only: true 197 | 198 | # Run Verify Plugin task and IntelliJ Plugin Verifier tool 199 | - name: Run Plugin Verification tasks 200 | run: ./gradlew verifyPlugin 201 | 202 | # Collect Plugin Verifier Result 203 | - name: Collect Plugin Verifier Result 204 | if: ${{ always() }} 205 | uses: actions/upload-artifact@v5 206 | with: 207 | name: pluginVerifier-result 208 | path: ${{ github.workspace }}/build/reports/pluginVerifier 209 | 210 | # Prepare a draft release for GitHub Releases page for the manual verification 211 | # If accepted and published, the release workflow would be triggered 212 | releaseDraft: 213 | name: Release draft 214 | if: github.event_name != 'pull_request' 215 | needs: [ build, test, inspectCode, verify ] 216 | runs-on: ubuntu-latest 217 | permissions: 218 | contents: write 219 | steps: 220 | 221 | # Check out the current repository 222 | - name: Fetch Sources 223 | uses: actions/checkout@v5 224 | 225 | # Remove old release drafts by using the curl request for the available releases with a draft flag 226 | - name: Remove Old Release Drafts 227 | env: 228 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 229 | run: | 230 | gh api repos/{owner}/{repo}/releases \ 231 | --jq '.[] | select(.draft == true) | .id' \ 232 | | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} 233 | 234 | # Create a new release draft which is not publicly visible and requires manual acceptance 235 | - name: Create Release Draft 236 | env: 237 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 238 | run: | 239 | VERSION=$(./gradlew properties --property version --quiet --console=plain | tail -n 1 | cut -f2- -d ' ') 240 | RELEASE_NOTE="./build/tmp/release_note.txt" 241 | ./gradlew getChangelog --unreleased --no-header --quiet --console=plain --output-file=$RELEASE_NOTE 242 | 243 | gh release create $VERSION \ 244 | --draft \ 245 | --title $VERSION \ 246 | --notes-file $RELEASE_NOTE 247 | --------------------------------------------------------------------------------