diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1646bb381..f8ccd588b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -138,6 +138,7 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" } oshi = "com.github.oshi:oshi-core:6.6.5" retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit"} retrofit-converters-wire = { module = "com.squareup.retrofit2:converter-wire", version.ref = "retrofit" } rxjava = "io.reactivex.rxjava3:rxjava:3.1.9" sarif4k = "io.github.detekt.sarif4k:sarif4k:0.6.0" diff --git a/platforms/intellij/compose/build.gradle.kts b/platforms/intellij/compose/build.gradle.kts index 792f53f4d..9bcc2f3fe 100644 --- a/platforms/intellij/compose/build.gradle.kts +++ b/platforms/intellij/compose/build.gradle.kts @@ -46,6 +46,14 @@ kotlin { implementation(libs.jewel.bridge) implementation(libs.kotlin.poet) implementation(libs.markdown) + + implementation(libs.kaml) + implementation(libs.okhttp) + implementation(libs.okhttp.loggingInterceptor) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.moshi) + implementation(projects.tools.foundryCommon) } } diff --git a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatBotActionService.kt b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatBotActionService.kt new file mode 100644 index 000000000..919aa850f --- /dev/null +++ b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatBotActionService.kt @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2024 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.tooling.aibot + +import com.google.gson.Gson +import com.google.gson.JsonObject +import foundry.intellij.compose.aibot.Message +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.VisibleForTesting + +class ChatBotActionService(private val scriptPath: Path, private val apiLink: String) { + suspend fun executeCommand(question: String): String { + val jsonInput = createJsonInput(question) + val authInfo = getAuthInfo(scriptPath) + println("authInfo $authInfo") + val (userAgent, cookies) = parseAuthJson(authInfo) + + val scriptContent = createScriptContent(userAgent, cookies, jsonInput) + + val tempScript = createTempScript(scriptContent) + + val response = runScript(tempScript) + + println("User Agent: $userAgent") + println("Cookies: $cookies") + + println("authInfo $authInfo") + println("scriptContent $scriptContent") + + println("Response from API: $response") + + val parsedOutput = parseOutput(response) + + return parsedOutput + } + + @VisibleForTesting + private fun createJsonInput(question: String): String { + val gsonInput = Gson() + val jsonObjectInput = + Content( + messages = listOf(Message(role = "user", question)), + source = "curl", + max_tokens = 2048, + ) + + val content = gsonInput.toJson(jsonObjectInput) + + println("jsonContent $content") + + return content + } + + @VisibleForTesting + private fun getAuthInfo(scriptPath: Path): String { + val processBuilder = ProcessBuilder("/bin/bash", scriptPath.toString()) + processBuilder.redirectErrorStream(true) + + val process = processBuilder.start() + val output = StringBuilder() + + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + output.append(line).append("\n") + println("Script output: $line") + } + } + + val completed = process.waitFor(600, TimeUnit.SECONDS) + if (!completed) { + process.destroyForcibly() + throw RuntimeException("Process timed out after 600 seconds") + } + + val jsonStartIndex = output.indexOf("{") + return if (jsonStartIndex != -1) { + output.substring(jsonStartIndex).trim() + } else { + throw IllegalArgumentException("No valid JSON found in output") + } + } + + private fun parseAuthJson(authJsonString: String): Pair { + val gson = Gson() + val authJson = gson.fromJson(authJsonString, JsonObject::class.java) + + println("authJson $authJson") + println("Parsed AuthJson: $authJson") + + val userAgent = authJson.get("user-agent")?.asString ?: "unknown" + val machineCookie = authJson.get("machine-cookie")?.asString ?: "unknown" + val slauthSession = authJson.get("slauth-session")?.asString ?: "unknown" + val tsa2 = authJson.get("tsa2")?.asString ?: "unknown" + + val cookies = "machine-cookie=$machineCookie; slauth-session=$slauthSession; tsa2=$tsa2" + + println("User Agent: $userAgent") + println("cookies $cookies") + return Pair(userAgent, cookies) + } + + private fun createScriptContent(userAgent: String, cookies: String, jsonInput: String): String { + return """ + #!/bin/bash + curl -s -X POST $apiLink \ + -H "Content-Type: application/json" \ + -H "User-Agent: $userAgent" \ + -H "Cookie: $cookies" \ + -d '$jsonInput' + """ + .trimIndent() + } + + private suspend fun createTempScript(scriptContent: String): File { + return withContext(Dispatchers.IO) { + val tempScript = File.createTempFile("run_command", ".sh") + tempScript.writeText(scriptContent) + tempScript.setExecutable(true) + tempScript + } + } + + private fun runScript(tempScript: File): String { + val processBuilder = ProcessBuilder("/bin/bash", tempScript.absolutePath) + processBuilder.redirectErrorStream(true) + + val process = processBuilder.start() + val output = StringBuilder() + + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + output.append(line).append("\n") + } + } + + val completed = process.waitFor(600, TimeUnit.SECONDS) + if (!completed) { + process.destroyForcibly() + throw RuntimeException("Process timed out after 600 seconds") + } + + tempScript.delete() + return output.toString() + } + + @VisibleForTesting + private fun parseOutput(output: String): String { + println("output: $output") + val regex = """\{.*\}""".toRegex(RegexOption.DOT_MATCHES_ALL) + val result = regex.find(output)?.value ?: "{}" + val gson = Gson() + val jsonObject = gson.fromJson(result, JsonObject::class.java) + val contentArray = jsonObject.getAsJsonArray("content") + val contentObject = contentArray.get(0).asJsonObject + val actualContent = contentObject.get("content").asString + + println("actual content $actualContent") + + return actualContent + } + + data class Content( + val messages: List, + val source: String = "curl", + val max_tokens: Int = 512, + ) +} diff --git a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPanel.kt b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPanel.kt index ab6d8f06c..19fdbb224 100644 --- a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPanel.kt +++ b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPanel.kt @@ -22,21 +22,24 @@ import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitContent import foundry.intellij.compose.projectgen.FoundryDesktopTheme import java.awt.Dimension +import java.nio.file.Path import javax.swing.JComponent object ChatPanel { - fun createPanel(): JComponent { + fun createPanel(scriptPath: Path, apiLink: String): JComponent { return ComposePanel().apply { preferredSize = Dimension(400, 600) - setContent { FoundryDesktopTheme { ChatApp() } } + setContent { FoundryDesktopTheme { ChatApp(scriptPath, apiLink) } } } } @Composable - private fun ChatApp() { + private fun ChatApp(scriptPath: Path, apiLink: String) { + println("ChatApp Script Path $scriptPath") + println("ChatApp Script Path absolutely ${scriptPath.toAbsolutePath()}") val circuit = remember { Circuit.Builder() - .addPresenter(ChatPresenter()) + .addPresenter(ChatPresenter(scriptPath, apiLink)) .addUi { state, modifier -> ChatWindowUi(state, modifier) } .build() } diff --git a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPresenter.kt b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPresenter.kt index 8f59f2a98..7ef6c0cb7 100644 --- a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPresenter.kt +++ b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatPresenter.kt @@ -21,18 +21,33 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.slack.circuit.runtime.presenter.Presenter +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import slack.tooling.aibot.ChatBotActionService + +class ChatPresenter(private val scriptPath: Path, apiLink: String) : Presenter { + val user = "user" + val bot = "bot" + private val chatBotActionService = ChatBotActionService(scriptPath, apiLink) -class ChatPresenter : Presenter { @Composable override fun present(): ChatScreen.State { var messages by remember { mutableStateOf(emptyList()) } var isLoading by remember { mutableStateOf(false) } + println("print script path $scriptPath") + return ChatScreen.State(messages = messages, isLoading = isLoading) { event -> when (event) { is ChatScreen.Event.SendMessage -> { - val newMessage = Message(event.message, isMe = true) + val newMessage = Message(role = user, event.message) messages = messages + newMessage + CoroutineScope(Dispatchers.IO).launch { + val response = chatBotActionService.executeCommand(event.message) + messages = messages + Message(role = bot, response) + } isLoading = true val response = Message(callApi(event.message), isMe = false) messages = messages + response @@ -41,10 +56,4 @@ class ChatPresenter : Presenter { } } } - - private fun callApi(message: String): String { - // function set up to call the DevXP API in the future. - // right now, just sends back the user input message - return ("I am a bot. You said \"${message}\"") - } } diff --git a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatWindowUi.kt b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatWindowUi.kt index 800e0fb16..71e0c2f44 100644 --- a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatWindowUi.kt +++ b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/ChatWindowUi.kt @@ -62,9 +62,10 @@ fun ChatWindowUi(state: ChatScreen.State, modifier: Modifier = Modifier) { Column(modifier = modifier.fillMaxSize().background(JewelTheme.globalColors.panelBackground)) { LazyColumn(modifier = Modifier.weight(1f), reverseLayout = true) { items(state.messages.reversed()) { message -> + val isMe = message.role == "user" Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (message.isMe) Arrangement.End else Arrangement.Start, + horizontalArrangement = if (isMe) Arrangement.End else Arrangement.Start, ) { ChatBubble(message) } @@ -159,18 +160,17 @@ private fun ConversationField( @Composable private fun ChatBubble(message: Message, modifier: Modifier = Modifier) { + val isMe = message.role == "user" Box( Modifier.wrapContentWidth() .padding(8.dp) .shadow(elevation = 0.5.dp, shape = RoundedCornerShape(25.dp), clip = true) - .background( - color = if (message.isMe) ChatColors.promptBackground else ChatColors.responseBackground - ) + .background(color = if (isMe) ChatColors.promptBackground else ChatColors.responseBackground) .padding(8.dp) ) { Text( - text = message.text, - color = if (message.isMe) ChatColors.userTextColor else ChatColors.responseTextColor, + text = message.content, + color = if (isMe) ChatColors.userTextColor else ChatColors.responseTextColor, modifier = modifier.padding(8.dp), fontFamily = FontFamily.SansSerif, ) diff --git a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/Message.kt b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/Message.kt index fbd8412f6..e77fece25 100644 --- a/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/Message.kt +++ b/platforms/intellij/compose/src/jvmMain/kotlin/foundry/intellij/compose/aibot/Message.kt @@ -17,4 +17,4 @@ package foundry.intellij.compose.aibot import androidx.compose.runtime.Immutable -@Immutable data class Message(val text: String, val isMe: Boolean) +@Immutable data class Message(var role: String, val content: String) diff --git a/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/SkatePluginSettings.kt b/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/SkatePluginSettings.kt index 370449e32..b918d88b2 100644 --- a/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/SkatePluginSettings.kt +++ b/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/SkatePluginSettings.kt @@ -122,6 +122,18 @@ class SkatePluginSettings : SimplePersistentStateComponent() + + fun getAIBotScript(): Path { + val aiBotScriptSetting = settings.devxpAPIcall + println("aiBotScriptSetting $aiBotScriptSetting") + + return aiBotScriptSetting.let { scriptSetting -> + val path = Path.of(basePath, scriptSetting) + println("getAIBotScript path location: ${path.toAbsolutePath()}") + println(printScriptContent(path)) + path + } + } + + fun getAIBotAPI(): String? { + val aiBotAPILink = settings.devxpAPIlink + return aiBotAPILink + } +} + +private fun printScriptContent(scriptPath: Path) { + try { + println("Script content:") + println("--------------------") + Files.readAllLines(scriptPath).forEach { println(it) } + println("--------------------") + } catch (e: Exception) { + println("Error reading script content: ${e.message}") + } +} diff --git a/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/aibot/ChatBotToolWindow.kt b/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/aibot/ChatBotToolWindow.kt index 8052e46ff..6c3b67998 100644 --- a/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/aibot/ChatBotToolWindow.kt +++ b/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/aibot/ChatBotToolWindow.kt @@ -15,21 +15,32 @@ */ package foundry.intellij.skate.aibot +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory +import com.slack.sgp.intellij.aibot.AIBotScriptFetcher import foundry.intellij.compose.aibot.ChatPanel +import foundry.intellij.skate.SkatePluginSettings import javax.swing.JComponent class ChatBotToolWindow : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { val contentFactory = ContentFactory.getInstance() - val content = contentFactory.createContent(createComposePanel(), "", false) + val content = contentFactory.createContent(createComposePanel(project), "", false) toolWindow.contentManager.addContent(content) } - private fun createComposePanel(): JComponent { - return ChatPanel.createPanel() + private fun createComposePanel(project: Project): JComponent? { + val settings = project.service() + val aiBotScriptSetting = settings.devxpAPIcall + println("aiBotScriptSetting in ChatBotToolWindow $aiBotScriptSetting") + val scriptFetcher = AIBotScriptFetcher(project) + println("AIBotScriptFetcher $scriptFetcher") + val scriptPath = scriptFetcher.getAIBotScript() + val apiLink = scriptFetcher.getAIBotAPI() + println("Printing script path $scriptPath") + return apiLink?.let { ChatPanel.createPanel(scriptPath, it) } } } diff --git a/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/ui/SkateConfigUI.kt b/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/ui/SkateConfigUI.kt index 63dee2a94..aa8cd1264 100644 --- a/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/ui/SkateConfigUI.kt +++ b/platforms/intellij/skate/src/main/kotlin/foundry/intellij/skate/ui/SkateConfigUI.kt @@ -41,6 +41,7 @@ internal class SkateConfigUI( featureFlagSettings() tracingSettings() codeOwnerSettings() + aiBotSettings() } private fun Panel.whatsNewPanelSettings() { @@ -192,4 +193,22 @@ internal class SkateConfigUI( .apply { enabledCondition?.let { enabledIf(it) } } } } + + private fun Panel.aiBotSettings() { + group("DevXP AI bot") { + bindAndValidateTextFieldRow( + titleMessageKey = "skate.configuration.aiBotSettings.title", + getter = { settings.devxpAPIcall }, + setter = { settings.devxpAPIcall = it }, + errorMessageKey = "skate.configuration.aiBotSettings.error", + ) + + bindAndValidateTextFieldRow( + titleMessageKey = "skate.configuration.aiBotLink.title", + getter = { settings.devxpAPIlink }, + setter = { settings.devxpAPIlink = it }, + errorMessageKey = "skate.configuration.aiBotSettings.error", + ) + } + } } diff --git a/platforms/intellij/skate/src/main/resources/messages/skateBundle.properties b/platforms/intellij/skate/src/main/resources/messages/skateBundle.properties index 94fea1484..f2b4363a2 100644 --- a/platforms/intellij/skate/src/main/resources/messages/skateBundle.properties +++ b/platforms/intellij/skate/src/main/resources/messages/skateBundle.properties @@ -19,4 +19,6 @@ skate.configuration.tracingEndpoint.error=API endpoint can't be empty skate.configuration.codeOwner.title=Code Owner skate.configuration.codeOwner.enabledDescription=Enable plugin to see team code ownership in status widget skate.configuration.codeOwnerFile.title=File path -skate.configuration.codeOwnerFile.error=File path can't be empty \ No newline at end of file +skate.configuration.codeOwnerFile.error=File path can't be empty +skate.configuration.aiBotSettings.title=AI bot +skate.configuration.aiBotLink.title=AI API \ No newline at end of file diff --git a/skate-plugin/src/test/kotlin/slack/tooling/aibot/ChatBotActionServiceTest.kt b/skate-plugin/src/test/kotlin/slack/tooling/aibot/ChatBotActionServiceTest.kt new file mode 100644 index 000000000..8c59e0efa --- /dev/null +++ b/skate-plugin/src/test/kotlin/slack/tooling/aibot/ChatBotActionServiceTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.tooling.aibot + +import com.google.common.truth.Truth.assertThat +import com.google.gson.Gson +import com.google.gson.JsonObject +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class ChatBotActionServiceTest { + @Test + fun `createJsonInput with simple input`() { + val question = "Why is the sky blue?" + + val result = createJsonInput(question) + + val expectedJson = + """ + { + "messages": [ + { + "role": "user", + "content": "Why is the sky blue?" + } + ], + "source": "curl", + "max_tokens": 512 + } + """ + .trimIndent() + + val trimmedExpected = expectedJson.replace(Regex("\\s"), "") + val trimmedResult = result.replace(Regex("\\s"), "") + println("expected is $trimmedExpected") + println("actual is $trimmedResult") + + assertThat(trimmedResult).isEqualTo(trimmedExpected) + } + + @Test + fun `createJsonInput with long strings`() { + val question = "A".repeat(10000) + val result = createJsonInput(question) + println("result $result") + val jsonObject = Gson().fromJson(result, JsonObject::class.java) + println(jsonObject) + assertEquals( + question, + jsonObject.get("messages").asJsonArray[0].asJsonObject.get("content").asString, + ) + } + + @Test + fun `createJsonInput with special characters`() { + val question = "What about \n, \t, and \"quotes\"? and \'apostrophes" + val result = createJsonInput(question) + println("result $result") + val jsonObject = Gson().fromJson(result, JsonObject::class.java) + assertEquals( + question, + jsonObject.get("messages").asJsonArray[0].asJsonObject.get("content").asString, + ) + } + + private fun createJsonInput(question: String): String { + val user = "user" + val gsonInput = Gson() + val content = + Content(messages = listOf(Message(role = user, question)), source = "curl", max_tokens = 512) + + val jsonContent = gsonInput.toJson(content).toString() + return jsonContent + } + + data class Content( + val messages: List, + val source: String = "curl", + val max_tokens: Int = 512, + ) +}