feat(intellij): add option to specify node binary; improve docs. (#529)

r0.3
Zhiming Ma 2023-10-10 23:00:56 +08:00 committed by GitHub
parent 6dbb712918
commit fceb8c9217
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 142 additions and 172 deletions

View File

@ -9,7 +9,9 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.ui.Messages
import com.tabbyml.intellijtabby.agent.AgentService
import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
import kotlinx.coroutines.launch
import java.net.URL
class CheckIssueDetail : AnAction() {
private val logger = Logger.getInstance(CheckIssueDetail::class.java)
@ -21,29 +23,35 @@ class CheckIssueDetail : AnAction() {
agentService.scope.launch {
val detail = agentService.getCurrentIssueDetail() ?: return@launch
val serverHealthState = agentService.getServerHealthState()
logger.info("Show issue detail: $detail, $serverHealthState")
val settingsState = service<ApplicationSettingsState>().state.value
logger.info("Show issue detail: $detail, $serverHealthState, $settingsState")
val title = when (detail["name"]) {
"slowCompletionResponseTime" -> "Completion Requests Appear to Take Too Much Time"
"highCompletionTimeoutRate" -> "Most Completion Requests Timed Out"
else -> return@launch
}
val message = buildDetailMessage(detail, serverHealthState)
val message = buildDetailMessage(detail, serverHealthState, settingsState)
invokeLater {
val result = Messages.showOkCancelDialog(message, title, "Dismiss", "Supported Models", Messages.getInformationIcon())
if (result == Messages.CANCEL) {
val result =
Messages.showOkCancelDialog(message, title, "Supported Models", "Dismiss", Messages.getInformationIcon())
if (result == Messages.OK) {
BrowserUtil.browse("https://tabby.tabbyml.com/docs/models/")
}
}
}
}
private fun buildDetailMessage(detail: Map<String, Any>, serverHealthState: Map<String, Any>?): String {
private fun buildDetailMessage(
detail: Map<String, Any>,
serverHealthState: Map<String, Any>?,
settingsState: ApplicationSettingsState.State
): String {
val stats = detail["completionResponseStats"] as Map<*, *>?
val statsMessages = when (detail["name"]) {
"slowCompletionResponseTime" -> if (stats != null && stats["responses"] is Number && stats["averageResponseTime"] is Number) {
val response = (stats["responses"] as Number).toInt()
val averageResponseTime = (stats["averageResponseTime"] as Number).toInt()
"The average response time of recent $response completion requests is $averageResponseTime ms.\n\n"
"The average response time of recent $response completion requests is $averageResponseTime ms."
} else {
""
}
@ -51,7 +59,7 @@ class CheckIssueDetail : AnAction() {
"highCompletionTimeoutRate" -> if (stats != null && stats["total"] is Number && stats["timeouts"] is Number) {
val timeout = (stats["timeouts"] as Number).toInt()
val total = (stats["total"] as Number).toInt()
"$timeout of $total completion requests timed out.\n\n"
"$timeout of $total completion requests timed out."
} else {
""
}
@ -63,27 +71,35 @@ class CheckIssueDetail : AnAction() {
val model = serverHealthState?.get("model") as String? ?: ""
val helpMessageForRunningLargeModelOnCPU = if (device == "cpu" && model.endsWith("B")) {
"""
Your Tabby server is running model $model on CPU.
Your Tabby server is running model <i>$model</i> on CPU.
This model is too large to run on CPU, please try a smaller model or switch to GPU.
You can find supported model list in online documents.
"""
""".trimIndent()
} else {
""
}
var helpMessage = ""
if (helpMessageForRunningLargeModelOnCPU.isNotEmpty()) {
helpMessage += helpMessageForRunningLargeModelOnCPU + "\n\n"
helpMessage += "Other possible causes of this issue are: \n"
} else {
helpMessage += "Possible causes of this issue are: \n";
}
helpMessage += " - A poor network connection. Please check your network and proxy settings.\n";
helpMessage += " - Server overload. Please contact your Tabby server administrator for assistance.\n";
var commonHelpMessage = ""
val host = URL(settingsState.serverEndpoint).host
if (helpMessageForRunningLargeModelOnCPU.isEmpty()) {
helpMessage += " - The running model $model is too large to run on your Tabby server. ";
helpMessage += "Please try a smaller model. You can find supported model list in online documents.\n";
commonHelpMessage += "<li>The running model <i>$model</i> is too large to run on your Tabby server.<br/>"
commonHelpMessage += "Please try a smaller model. You can find supported model list in online documents.</li>"
}
return statsMessages + helpMessage
if (!(host == "localhost" || host == "127.0.0.1")) {
commonHelpMessage += "<li>A poor network connection. Please check your network and proxy settings.</li>"
commonHelpMessage += "<li>Server overload. Please contact your Tabby server administrator for assistance.</li>"
}
var helpMessage: String
if (helpMessageForRunningLargeModelOnCPU.isNotEmpty()) {
helpMessage = "$helpMessageForRunningLargeModelOnCPU<br/>"
if (commonHelpMessage.isNotEmpty()) {
helpMessage += "<br/>Other possible causes of this issue: <br/><ul>$commonHelpMessage</ul>"
}
} else {
// commonHelpMessage should not be empty here
helpMessage = "Possible causes of this issue: <br/><ul>$commonHelpMessage</ul>"
}
return "<html>$statsMessages<br/><br/>$helpMessage</html>"
}
override fun update(e: AnActionEvent) {

View File

@ -0,0 +1,11 @@
package com.tabbyml.intellijtabby.actions
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class OpenOnlineDocs: AnAction() {
override fun actionPerformed(e: AnActionEvent) {
BrowserUtil.browse("https://tabby.tabbyml.com/docs/extensions/")
}
}

View File

@ -10,17 +10,20 @@ import com.intellij.execution.process.ProcessAdapter
import com.intellij.execution.process.ProcessEvent
import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.extensions.PluginId
import com.intellij.openapi.util.Key
import com.intellij.util.EnvironmentUtil
import com.intellij.util.io.BaseOutputReader
import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
@ -46,27 +49,21 @@ class Agent : ProcessAdapter() {
open class AgentException(message: String) : Exception(message)
open class NodeBinaryException(message: String) : AgentException(
message = "$message Please install Node.js version >= 18.0, set the binary path in Tabby plugin settings or add bin path to system environment variable PATH, then restart IDE."
)
open class NodeBinaryNotFoundException : NodeBinaryException(
message = "Cannot find Node binary."
)
open class NodeBinaryInvalidVersionException(version: String) : NodeBinaryException(
message = "Node version is too old: $version."
)
fun open() {
logger.info("Environment variables: PATH: ${EnvironmentUtil.getValue("PATH")}")
val node = PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("node")
if (node?.exists() == true) {
logger.info("Node bin path: ${node.absolutePath}")
} else {
throw AgentException("Node bin not found. Please install Node.js v16+ and add bin path to system environment variable PATH, then restart IDE.")
}
checkNodeVersion(node.absolutePath)
val script =
PluginManagerCore.getPlugin(PluginId.getId("com.tabbyml.intellij-tabby"))?.pluginPath?.resolve("node_scripts/tabby-agent.js")
?.toFile()
if (script?.exists() == true) {
logger.info("Node script path: ${script.absolutePath}")
} else {
throw AgentException("Node script not found. Please reinstall Tabby plugin.")
}
val node = getNodeBinary()
val script = getNodeScript()
val cmd = GeneralCommandLine(node.absolutePath, script.absolutePath)
process = object : KillableProcessHandler(cmd) {
override fun readerOptions(): BaseOutputReader.Options {
@ -78,25 +75,56 @@ class Agent : ProcessAdapter() {
streamWriter = process.processInput.writer()
}
private fun checkNodeVersion(node: String) {
private fun getNodeBinary(): File {
val settings = service<ApplicationSettingsState>()
val node = if (settings.nodeBinary.isNotBlank()) {
val path = settings.nodeBinary.replaceFirst(Regex("^~"), System.getProperty("user.home"))
File(path)
} else {
logger.info("Environment variables: PATH: ${EnvironmentUtil.getValue("PATH")}")
PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("node")
}
if (node?.exists() == true) {
logger.info("Node binary path: ${node.absolutePath}")
checkNodeVersion(node)
return node
} else {
throw NodeBinaryNotFoundException()
}
}
private fun checkNodeVersion(node: File) {
try {
val process = GeneralCommandLine(node, "--version").createProcess()
val process = GeneralCommandLine(node.absolutePath, "--version").createProcess()
val version = BufferedReader(InputStreamReader(process.inputStream)).readLine()
val regResult = Regex("v([0-9]+)\\.([0-9]+)\\.([0-9]+)").find(version)
if (regResult != null && regResult.groupValues[1].toInt() >= 18) {
return
} else {
throw AgentException("Node version is too old: $version. Please install Node.js v18+ and add bin path to system environment variable PATH, then restart IDE.")
throw NodeBinaryInvalidVersionException(version)
}
} catch (e: Exception) {
if (e is AgentException) {
throw e
} else {
throw AgentException("Failed to check node version: $e. Please check your node installation.")
throw AgentException("Failed to check node version: $e.")
}
}
}
private fun getNodeScript(): File {
val script =
PluginManagerCore.getPlugin(PluginId.getId("com.tabbyml.intellij-tabby"))?.pluginPath?.resolve("node_scripts/tabby-agent.js")
?.toFile()
if (script?.exists() == true) {
logger.info("Node script path: ${script.absolutePath}")
return script
} else {
throw AgentException("Node script not found. Please reinstall Tabby plugin.")
}
}
data class Config(
val server: Server? = null,
val completion: Completion? = null,

View File

@ -20,7 +20,6 @@ import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
import com.tabbyml.intellijtabby.usage.AnonymousUsageLogger
import io.ktor.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -62,9 +61,7 @@ class AgentService : Disposable {
init {
val settings = service<ApplicationSettingsState>()
val anonymousUsageLogger = service<AnonymousUsageLogger>()
scope.launch {
val config = createAgentConfig(settings.data)
val clientProperties = createClientProperties(settings.data)
try {
@ -75,18 +72,14 @@ class AgentService : Disposable {
} catch (e: Exception) {
initResultFlow.value = false
logger.warn("Agent init failed: $e")
anonymousUsageLogger.event(
"IntelliJInitFailed", mapOf(
"client" to clientProperties.session["client"] as String, "error" to e.stackTraceToString()
)
)
val notification = Notification(
"com.tabbyml.intellijtabby.notification.warning",
"Tabby initialization failed",
"${e.message}",
NotificationType.ERROR,
)
// FIXME: Add action to open FAQ page to help user set up nodejs.
notification.addAction(ActionManager.getInstance().getAction("Tabby.OpenOnlineDocs"))
invokeLater {
initFailedNotification?.expire()
initFailedNotification = notification

View File

@ -20,6 +20,7 @@ class ApplicationConfigurable : Configurable {
val settings = service<ApplicationSettingsState>()
return settingsPanel.completionTriggerMode != settings.completionTriggerMode
|| settingsPanel.serverEndpoint != settings.serverEndpoint
|| settingsPanel.nodeBinary != settings.nodeBinary
|| settingsPanel.isAnonymousUsageTrackingDisabled != settings.isAnonymousUsageTrackingDisabled
}
@ -27,6 +28,7 @@ class ApplicationConfigurable : Configurable {
val settings = service<ApplicationSettingsState>()
settings.completionTriggerMode = settingsPanel.completionTriggerMode
settings.serverEndpoint = settingsPanel.serverEndpoint
settings.nodeBinary = settingsPanel.nodeBinary
settings.isAnonymousUsageTrackingDisabled = settingsPanel.isAnonymousUsageTrackingDisabled
}
@ -34,6 +36,7 @@ class ApplicationConfigurable : Configurable {
val settings = service<ApplicationSettingsState>()
settingsPanel.completionTriggerMode = settings.completionTriggerMode
settingsPanel.serverEndpoint = settings.serverEndpoint
settingsPanel.nodeBinary = settings.nodeBinary
settingsPanel.isAnonymousUsageTrackingDisabled = settings.isAnonymousUsageTrackingDisabled
}
}

View File

@ -15,13 +15,26 @@ class ApplicationSettingsPanel {
"""
<html>
A http or https URL of Tabby server endpoint.<br/>
If leave empty, server endpoint config in <i>~/.tabby-client/agent/config.toml</i> will be used<br/>
If leave empty, server endpoint config in <i>~/.tabby-client/agent/config.toml</i> will be used.<br/>
Default to <i>http://localhost:8080</i>.
</html>
""".trimIndent()
)
.panel
private val nodeBinaryTextField = JBTextField()
private val nodeBinaryPanel = FormBuilder.createFormBuilder()
.addComponent(nodeBinaryTextField)
.addTooltip(
"""
<html>
Path to the Node binary for running the Tabby agent. The Node version must be >= 18.0.<br/>
If left empty, Tabby will attempt to find the Node binary in the <i>PATH</i> environment variable.<br/>
</html>
""".trimIndent()
)
.panel
private val completionTriggerModeAutomaticRadioButton = JBRadioButton("Automatic")
private val completionTriggerModeManualRadioButton = JBRadioButton("Manual")
private val completionTriggerModeRadioGroup = ButtonGroup().apply {
@ -42,6 +55,8 @@ class ApplicationSettingsPanel {
.addSeparator(5)
.addLabeledComponent("Inline completion trigger", completionTriggerModePanel, 5, false)
.addSeparator(5)
.addLabeledComponent("<html>Node binary<br/>(Requires restart IDE)</html>", nodeBinaryPanel, 5, false)
.addSeparator(5)
.addLabeledComponent("Anonymous usage tracking", isAnonymousUsageTrackingDisabledCheckBox, 5, false)
.addComponentFillVertically(JPanel(), 0)
.panel
@ -65,6 +80,12 @@ class ApplicationSettingsPanel {
serverEndpointTextField.text = value
}
var nodeBinary: String
get() = nodeBinaryTextField.text
set(value) {
nodeBinaryTextField.text = value
}
var isAnonymousUsageTrackingDisabled: Boolean
get() = isAnonymousUsageTrackingDisabledCheckBox.isSelected
set(value) {

View File

@ -32,6 +32,11 @@ class ApplicationSettingsState : PersistentStateComponent<ApplicationSettingsSta
field = value
stateFlow.value = this.data
}
var nodeBinary: String = ""
set(value) {
field = value
stateFlow.value = this.data
}
var isAnonymousUsageTrackingDisabled: Boolean = false
set(value) {
field = value
@ -41,6 +46,7 @@ class ApplicationSettingsState : PersistentStateComponent<ApplicationSettingsSta
data class State(
val completionTriggerMode: TriggerMode,
val serverEndpoint: String,
val nodeBinary: String,
val isAnonymousUsageTrackingDisabled: Boolean,
)
@ -48,6 +54,7 @@ class ApplicationSettingsState : PersistentStateComponent<ApplicationSettingsSta
get() = State(
completionTriggerMode = completionTriggerMode,
serverEndpoint = serverEndpoint,
nodeBinary = nodeBinary,
isAnonymousUsageTrackingDisabled = isAnonymousUsageTrackingDisabled,
)

View File

@ -87,6 +87,7 @@ class StatusBarWidgetFactory : StatusBarEditorBasedWidgetFactory() {
actionManager.getAction("Tabby.CheckIssueDetail"),
actionManager.getAction("Tabby.ToggleInlineCompletionTriggerMode"),
actionManager.getAction("Tabby.OpenSettings"),
actionManager.getAction("Tabby.OpenOnlineDocs"),
)
}
},

View File

@ -1,116 +0,0 @@
package com.tabbyml.intellijtabby.usage
import com.google.gson.Gson
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.tabbyml.intellijtabby.settings.ApplicationSettingsState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.util.*
import kotlin.io.path.Path
@Service
class AnonymousUsageLogger {
private val logger = Logger.getInstance(AnonymousUsageLogger::class.java)
private val gson = Gson()
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private lateinit var anonymousId: String
private val disabled: Boolean
get() {
return service<ApplicationSettingsState>().isAnonymousUsageTrackingDisabled
}
private val initialized = MutableStateFlow(false)
init {
scope.launch {
try {
val home = System.getProperty("user.home")
logger.info("User home: $home")
val datafile = Path(home).resolve(".tabby-client/agent/data.json").toFile()
var data: Map<*, *>? = null
try {
val dataJson = datafile.inputStream().bufferedReader().use { it.readText() }
data = gson.fromJson(dataJson, Map::class.java)
} catch (e: Exception) {
logger.info("Failed to load anonymous ID: ${e.message}")
}
if (data?.get("anonymousId") != null) {
anonymousId = data["anonymousId"].toString()
logger.info("Saved anonymous ID: $anonymousId")
} else {
anonymousId = UUID.randomUUID().toString()
val newData = data?.toMutableMap() ?: mutableMapOf()
newData["anonymousId"] = anonymousId
val newDataJson = gson.toJson(newData)
datafile.parentFile.mkdirs()
datafile.writeText(newDataJson)
logger.info("Create new anonymous ID: $anonymousId")
}
} catch (e: Exception) {
logger.warn("Failed when init anonymous ID: ${e.message}")
anonymousId = UUID.randomUUID().toString()
} finally {
initialized.value = true
}
}
}
data class UsageRequest(
val distinctId: String,
val event: String,
val properties: Map<String, String>,
)
suspend fun event(event: String, properties: Map<String, String>) {
initialized.first { it }
if (disabled) {
return
}
val request = UsageRequest(
distinctId = anonymousId,
event = event,
properties = properties,
)
val requestString = gson.toJson(request)
withContext(scope.coroutineContext) {
try {
val connection = URL(ENDPOINT).openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("Accept", "application/json")
connection.doInput = true
connection.doOutput = true
val outputStreamWriter = OutputStreamWriter(connection.outputStream)
outputStreamWriter.write(requestString)
outputStreamWriter.flush()
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
logger.info("Usage event sent successfully: $requestString")
} else {
logger.warn("Usage event failed to send: $responseCode")
}
connection.disconnect()
} catch (e: Exception) {
logger.warn("Usage event failed to send: ${e.message}")
}
}
}
companion object {
const val ENDPOINT = "https://app.tabbyml.com/api/usage"
}
}

View File

@ -22,7 +22,7 @@
<h2 id="demo">Demo</h2>
<p>Try our online demo <a href="https://tabby.tabbyml.com/playground/">here</a>.</p>
<h2 id="requirements">Requirements</h2>
Tabby plugin requires <a href="https://nodejs.org/">Node.js</a> v18+ installed and added into <code>PATH</code> environment variable. </p>
Tabby plugin requires <a href="https://nodejs.org/">Node.js</a> v18+ installed. </p>
]]></description>
<!-- Product and plugin compatibility requirements.
@ -86,11 +86,17 @@
<action id="Tabby.ToggleInlineCompletionTriggerMode"
class="com.tabbyml.intellijtabby.actions.ToggleInlineCompletionTriggerMode">
</action>
<separator/>
<action id="Tabby.OpenSettings"
class="com.tabbyml.intellijtabby.actions.OpenSettings"
text="Open Settings..."
description="Show settings for Tabby.">
</action>
<action id="Tabby.OpenOnlineDocs"
class="com.tabbyml.intellijtabby.actions.OpenOnlineDocs"
text="Open Online Help..."
description="Open the online docs in your web browser.">
</action>
</group>
</actions>
</idea-plugin>