diff --git a/clients/intellij/build.gradle.kts b/clients/intellij/build.gradle.kts index c9cbd9795a60..a2895d16a79c 100644 --- a/clients/intellij/build.gradle.kts +++ b/clients/intellij/build.gradle.kts @@ -71,14 +71,16 @@ tasks { } } - register("buildAgent") { + register("buildDependencies") { exec { commandLine("pnpm", "turbo", "build") } } prepareSandbox { - dependsOn("buildAgent") + dependsOn("buildDependencies") + + // Copy the tabby-agent to the sandbox from( fileTree("node_modules/tabby-agent/dist/") { include("node/**/*") @@ -87,6 +89,15 @@ tasks { ) { into("intellij-tabby/tabby-agent/") } + + // Copy the tabby-threads `create-thread-from-iframe` to the sandbox + from( + fileTree("node_modules/tabby-threads/dist/") { + include("iife/create-thread-from-iframe.js") + } + ) { + into("intellij-tabby/tabby-threads/") + } } } diff --git a/clients/intellij/package.json b/clients/intellij/package.json index 5bf10c4858ed..8e4cd3cd8592 100644 --- a/clients/intellij/package.json +++ b/clients/intellij/package.json @@ -2,6 +2,7 @@ "name": "intellij-tabby", "private": true, "devDependencies": { - "tabby-agent": "workspace:*" + "tabby-agent": "workspace:*", + "tabby-threads": "workspace:*" } } diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt index c63fc500a1d9..6925bc4e9b90 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt @@ -4,16 +4,18 @@ import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import com.intellij.ide.BrowserUtil +import com.intellij.ide.plugins.PluginManagerCore import com.intellij.openapi.application.invokeLater import com.intellij.openapi.application.runReadAction import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceOrNull -import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.progress.util.BackgroundTaskUtil @@ -27,6 +29,7 @@ import com.tabbyml.intellijtabby.events.CombinedState import com.tabbyml.intellijtabby.findVirtualFile import com.tabbyml.intellijtabby.git.GitProvider import com.tabbyml.intellijtabby.lsp.ConnectionService +import com.tabbyml.intellijtabby.lsp.ConnectionService.InitializationException import com.tabbyml.intellijtabby.lsp.protocol.Config import com.tabbyml.intellijtabby.lsp.protocol.StatusInfo import com.tabbyml.intellijtabby.lsp.protocol.StatusRequestParams @@ -43,6 +46,8 @@ import java.awt.Color import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.io.File +import java.util.* +import java.util.concurrent.CompletableFuture class ChatBrowser(private val project: Project) : JBCefBrowser( @@ -57,7 +62,7 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( ) .setEnableOpenDevToolsMenuItem(true) ) { - private val logger = Logger.getInstance(ChatBrowser::class.java) + private val logger = logger() private val gson = Gson() private val combinedState = project.service() private val gitProvider = project.service() @@ -66,18 +71,10 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( private val scope = CoroutineScope(Dispatchers.IO) private suspend fun getServer() = project.serviceOrNull()?.getServerAsync() - private val reloadHandler = JBCefJSQuery.create(this as JBCefBrowserBase) - private val chatPanelRequestHandler = JBCefJSQuery.create(this as JBCefBrowserBase) - private var currentConfig: Config.ServerConfig? = null private var isChatPanelLoaded = false private val pendingScripts: MutableList = mutableListOf() - private data class ChatPanelRequest( - val method: String, - val params: List, - ) - private data class FileContext( val kind: String = "file", val range: LineRange, @@ -98,7 +95,9 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( component.background = bgColor setPageBackgroundColor("hsl(${bgColor.toHsl()})") - loadHTML(HTML_CONTENT) + val tabbyThreadsScript = loadTabbyThreadsScript() + val htmlContent = loadHtmlContent(tabbyThreadsScript) + loadHTML(htmlContent) jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { override fun onLoadingStateChange( @@ -113,17 +112,6 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( } }, cefBrowser) - reloadHandler.addHandler { - reloadContent(true) - return@addHandler JBCefJSQuery.Response("") - } - - chatPanelRequestHandler.addHandler { message -> - val request = gson.fromJson(message, ChatPanelRequest::class.java) - handleChatPanelRequest(request) - return@addHandler JBCefJSQuery.Response("") - } - messageBusConnection.subscribe(CombinedState.Listener.TOPIC, object : CombinedState.Listener { override fun stateChanged(state: CombinedState.State) { reloadContent() @@ -233,7 +221,8 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( } private fun handleLoaded() { - jsInjectHandlers() + jsInjectFunctions() + jsCreateChatPanelClient() jsApplyStyle() reloadContent() component.isVisible = true @@ -333,90 +322,10 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( } } - private fun handleChatPanelRequest(request: ChatPanelRequest) { - when (request.method) { - "navigate" -> { - logger.debug("navigate: request: ${request.params}") - val context = request.params.getOrNull(0)?.let { - gson.fromJson(gson.toJson(it), FileContext::class.java) - } ?: return - val options = request.params.getOrNull(1) as Map<*, *>? - if (options?.get("openInEditor") == true) { - navigateToFileContext(context) - } else { - currentConfig?.let { buildCodeBrowserUrl(it, context) }?.let { BrowserUtil.browse(it) } - } - } - - "refresh" -> { - logger.debug("refresh: request: ${request.params}") - reloadContent(true) - } - - "onSubmitMessage" -> { - logger.debug("onSubmitMessage: request: ${request.params}") - if (request.params.isNotEmpty()) { - val message = request.params[0] as String - val relevantContext: List? = request.params.getOrNull(1)?.let { - gson.fromJson(gson.toJson(it), object : TypeToken?>() {}.type) - } - val activeContext = getActiveFileContext() - chatPanelSendMessage(message, null, relevantContext, activeContext) - } - } - - "onApplyInEditor" -> { - logger.debug("onApplyInEditor: request: ${request.params}") - val content = request.params.getOrNull(0) as String? ?: return - val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return - invokeLater { - WriteCommandAction.runWriteCommandAction(project) { - val start = editor.selectionModel.selectionStart - val end = editor.selectionModel.selectionEnd - editor.document.replaceString(start, end, content) - editor.caretModel.moveToOffset(start + content.length) - } - } - } - - "onLoaded" -> { - logger.debug("onLoaded: request: ${request.params}") - val params = request.params.getOrNull(0) as Map<*, *>? - val apiVersion = params?.get("apiVersion") as String? - if (apiVersion != null) { - val error = checkChatPanelApiVersion(apiVersion) - if (error != null) { - showContent(error) - return - } - } - isChatPanelLoaded = true - chatPanelInit() - chatPanelUpdateTheme() - showContent() - pendingScripts.forEach { executeJs(it) } - pendingScripts.clear() - } - - "onCopy" -> { - logger.debug("onCopy: request: ${request.params}") - val content = request.params.getOrNull(0) as String? ?: return - val stringSelection = StringSelection(content) - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(stringSelection, null) - } - - "onKeyboardEvent" -> { - // nothing to do - } - } - } - // chat panel api functions private fun chatPanelInit() { - val request = ChatPanelRequest( - "init", + val params = listOf( mapOf( "fetcherOptions" to mapOf( @@ -424,9 +333,8 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( ) ) ) - ) - logger.debug("chatPanelInit: $request") - jsSendRequestToChatPanel(request) + logger.debug("chatPanelInit: $params") + jsChatPanelClientInvoke("init", params) } private fun chatPanelSendMessage( @@ -435,8 +343,7 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( relevantContext: List? = null, activeContext: FileContext? = null, ) { - val request = ChatPanelRequest( - "sendMessage", + val params = listOf( mapOf( "message" to message, @@ -445,52 +352,174 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( "activeContext" to activeContext, ) ) - ) - logger.debug("chatPanelSendMessage: $request") - jsSendRequestToChatPanel(request) + logger.debug("chatPanelSendMessage: $params") + jsChatPanelClientInvoke("sendMessage", params) } private fun chatPanelAddRelevantContext(context: FileContext) { - val request = ChatPanelRequest( - "addRelevantContext", - listOf(context) - ) - logger.debug("chatPanelAddRelevantContext: $request") - jsSendRequestToChatPanel(request) + val params = listOf(context) + + logger.debug("chatPanelAddRelevantContext: $params") + jsChatPanelClientInvoke("addRelevantContext", params) } private fun chatPanelUpdateTheme() { - val request = ChatPanelRequest( - "updateTheme", + val params = listOf( buildCss(), if (isDarkTheme) "dark" else "light", ) - ) - logger.debug("chatPanelUpdateTheme: $request") - jsSendRequestToChatPanel(request) + logger.debug("chatPanelUpdateTheme: $params") + jsChatPanelClientInvoke("updateTheme", params) } private fun chatPanelUpdateActiveSelection(context: FileContext?) { - val request = ChatPanelRequest( - "updateActiveSelection", - listOf(context) - ) - logger.debug("chatPanelUpdateActiveSelection: $request") - jsSendRequestToChatPanel(request) + val params = listOf(context) + logger.debug("chatPanelUpdateActiveSelection: $params") + jsChatPanelClientInvoke("updateActiveSelection", params) } - // js functions + // js handler functions to inject - private fun jsInjectHandlers() { - val script = String.format( - """ - window.handleReload = function() { %s } - window.handleChatPanelRequest = function(message) { %s } - """.trimIndent(), - reloadHandler.inject(""), - chatPanelRequestHandler.inject("message"), + private fun createJsFunction(handler: (List) -> Any?): String { + val jsQuery = JBCefJSQuery.create(this as JBCefBrowserBase) + jsQuery.addHandler { paramsJson -> + val params = gson.fromJson(paramsJson, object : TypeToken>() {}) + val result = handler(params) + val resultJson = gson.toJson(result) + return@addHandler JBCefJSQuery.Response(resultJson) + } + val injection = jsQuery.inject( + "paramsJson", + "function(response) { resolve(JSON.parse(response)); }", + "function(error_code, error_message) { reject(new Error(error_message)); }", ) + return """ + function(...args) { + return new Promise((resolve, reject) => { + const paramsJson = JSON.stringify(args); + $injection + }); + } + """.trimIndent().trimStart() + } + + private val jsReloadContent = createJsFunction { reloadContent(true) } + + private val jsHandleChatPanelNavigate = createJsFunction { params -> + logger.debug("navigate: $params") + val context = params.getOrNull(0)?.let { + gson.fromJson(gson.toJson(it), FileContext::class.java) + } ?: return@createJsFunction Unit + val options = params.getOrNull(1) as Map<*, *>? + if (options?.get("openInEditor") == true) { + navigateToFileContext(context) + } else { + currentConfig?.let { buildCodeBrowserUrl(it, context) }?.let { BrowserUtil.browse(it) } + } + } + + private val jsHandleChatPanelRefresh = createJsFunction { + logger.debug("refresh") + reloadContent(true) + } + + private val jsHandleChatPanelOnSubmitMessage = createJsFunction { params -> + logger.debug("onSubmitMessage: $params") + if (params.isNotEmpty()) { + val message = params[0] as String + val relevantContext: List? = params.getOrNull(1)?.let { + gson.fromJson(gson.toJson(it), object : TypeToken?>() {}.type) + } + val activeContext = getActiveFileContext() + chatPanelSendMessage(message, null, relevantContext, activeContext) + } + } + + private val jsHandleChatPanelOnApplyInEditor = createJsFunction { params -> + logger.debug("onApplyInEditor: $params") + val content = params.getOrNull(0) as String? ?: return@createJsFunction Unit + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return@createJsFunction Unit + invokeLater { + WriteCommandAction.runWriteCommandAction(project) { + val start = editor.selectionModel.selectionStart + val end = editor.selectionModel.selectionEnd + editor.document.replaceString(start, end, content) + editor.caretModel.moveToOffset(start + content.length) + } + } + } + + private val jsHandleChatPanelOnLoaded = createJsFunction { params -> + logger.debug("onLoaded: $params") + val onLoadedParams = params.getOrNull(0) as Map<*, *>? + val apiVersion = onLoadedParams?.get("apiVersion") as String? + if (apiVersion != null) { + val error = checkChatPanelApiVersion(apiVersion) + if (error != null) { + showContent(error) + return@createJsFunction Unit + } + } + isChatPanelLoaded = true + chatPanelInit() + chatPanelUpdateTheme() + showContent() + pendingScripts.forEach { executeJs(it) } + pendingScripts.clear() + } + + private val jsHandleChatPanelOnCopy = createJsFunction { params -> + logger.debug("onCopy: request: $params") + val content = params.getOrNull(0) as String? ?: return@createJsFunction Unit + val stringSelection = StringSelection(content) + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(stringSelection, null) + } + + private val jsHandleChatPanelOnKeyboardEvent = createJsFunction { params -> + logger.debug("onKeyboardEvent: request: $params") + // nothing to do + } + + private val jsHandleChatPanelOpenInEditor = createJsFunction { params -> + logger.debug("openInEditor: request: $params") + //FIXME(@icycodes): not implemented + return@createJsFunction false + } + + // functions to execute js scripts + + private fun executeJs(script: String) { + cefBrowser.executeJavaScript(script, cefBrowser.url, 0) + } + + private fun jsInjectFunctions() { + val script = """ + if (!window.handleReload) { + window.handleReload = $jsReloadContent + } + """.trimIndent().trimStart() + executeJs(script) + } + + private fun jsCreateChatPanelClient() { + val script = """ + if (!window.tabbyChatPanelClient) { + window.tabbyChatPanelClient = TabbyThreads.createThreadFromIframe(getChatPanel(), { + expose: { + navigate: $jsHandleChatPanelNavigate, + refresh: $jsHandleChatPanelRefresh, + onSubmitMessage: $jsHandleChatPanelOnSubmitMessage, + onApplyInEditor: $jsHandleChatPanelOnApplyInEditor, + onLoaded: $jsHandleChatPanelOnLoaded, + onCopy: $jsHandleChatPanelOnCopy, + onKeyboardEvent: $jsHandleChatPanelOnKeyboardEvent, + openInEditor: $jsHandleChatPanelOpenInEditor, + } + }) + } + """.trimIndent().trimStart() executeJs(script) } @@ -514,23 +543,62 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( private fun jsLoadChatPanel() { val config = currentConfig ?: return - val chatUrl = String.format("%s/chat?client=intellij", config.endpoint) - val script = String.format("loadChatPanel('%s')", chatUrl) + val chatUrl = "${config.endpoint}/chat?client=intellij" + val script = "loadChatPanel('$chatUrl')" executeJs(script) } - private fun jsSendRequestToChatPanel(request: ChatPanelRequest) { - val json = gson.toJson(request) - val script = String.format("sendRequestToChatPanel('%s')", escapeCharacters(json)) + private val pendingChatPanelRequest = mutableMapOf>() + private val jsChatPanelResponseHandlerInjection = JBCefJSQuery.create(this as JBCefBrowserBase).apply { + addHandler { results -> + logger.debug("Response from chat panel: $results") + val parsedResult = gson.fromJson(results, object : TypeToken>() {}) + val future = pendingChatPanelRequest.remove(parsedResult[0] as String) + if (parsedResult[1] is String) { + future?.completeExceptionally(Exception(parsedResult[1] as String)) + } else { + future?.complete(parsedResult[2]) + } + return@addHandler JBCefJSQuery.Response("") + } + }.inject("results") + + private fun jsChatPanelClientInvoke(method: String, params: List): CompletableFuture { + val future = CompletableFuture() + val uuid = UUID.randomUUID().toString() + pendingChatPanelRequest[uuid] = future + val paramsJson = escapeCharacters(gson.toJson(params)) + val script = """ + (function() { + const func = window.tabbyChatPanelClient['$method'] + if (func && typeof func === 'function') { + const params = JSON.parse('$paramsJson') + const resultPromise = func(...params) + if (resultPromise && typeof resultPromise.then === 'function') { + resultPromise.then(result => { + const results = JSON.stringify(['$uuid', null, result]) + $jsChatPanelResponseHandlerInjection + }).catch(error => { + const results = JSON.stringify(['$uuid', error.message, null]) + $jsChatPanelResponseHandlerInjection + }) + } else { + const results = JSON.stringify(['$uuid', null, resultPromise]) + $jsChatPanelResponseHandlerInjection + } + } else { + const results = JSON.stringify(['$uuid', 'Method not found: $method', null]) + $jsChatPanelResponseHandlerInjection + } + })() + """.trimIndent().trimStart() if (isChatPanelLoaded) { + logger.debug("Request to chat panel: $uuid, $method, $paramsJson") executeJs(script) } else { pendingScripts.add(script) } - } - - private fun executeJs(script: String) { - cefBrowser.executeJavaScript(script, cefBrowser.url, 0) + return future } companion object { @@ -653,7 +721,7 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( }.buildString() } - private const val TABBY_CHAT_PANEL_API_VERSION_RANGE = "~0.2.0" + private const val TABBY_CHAT_PANEL_API_VERSION_RANGE = "~0.4.0" private const val TABBY_SERVER_VERSION_RANGE = ">=0.18.0" private const val PROMPT_EXPLAIN: String = "Explain the selected code:" @@ -661,7 +729,21 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( private const val PROMPT_GENERATE_DOCS: String = "Generate documentation for the selected code:" private const val PROMPT_GENERATE_TESTS: String = "Generate a unit test for the selected code:" - private const val HTML_CONTENT = """ + private fun loadTabbyThreadsScript(): String { + val script = + PluginManagerCore.getPlugin(PluginId.getId("com.tabbyml.intellij-tabby")) + ?.pluginPath + ?.resolve("tabby-threads/iife/create-thread-from-iframe.js") + ?.toFile() + if (script?.exists() == true) { + logger().info("Tabby-threads script path: ${script.absolutePath}") + return script.readText() + } else { + throw InitializationException("Tabby-threads script not found. Please reinstall Tabby plugin.") + } + } + + private fun loadHtmlContent(script: String) = """ @@ -722,6 +804,9 @@ class ChatBrowser(private val project: Project) : JBCefBrowser( + - """ + """.trimIndent().trimStart() } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 908624c1e60d..68e8671f0d11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: tabby-agent: specifier: workspace:* version: link:../tabby-agent + tabby-threads: + specifier: workspace:* + version: link:../tabby-threads clients/tabby-agent: devDependencies: