├── 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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | true
20 | true
21 | false
22 | true
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/.run/Run Plugin.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | false
20 | true
21 | false
22 | false
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.run/Run Verifications.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
4 |
--------------------------------------------------------------------------------
/src/main/resources/icons/composeToolWindow@20x20.svg:
--------------------------------------------------------------------------------
1 |
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 |
5 |
--------------------------------------------------------------------------------
/src/main/resources/icons/composeToolWindow_dark.svg:
--------------------------------------------------------------------------------
1 |
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 | 
4 | [](https://plugins.jetbrains.com/plugin/28853)
5 | [](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 |
--------------------------------------------------------------------------------
/src/main/resources/icons/lotus.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
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