mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-10-19 09:43:16 +00:00
feat: proxyai agent
This commit is contained in:
parent
b2a2e902f5
commit
8d5c7e0076
130 changed files with 14038 additions and 364 deletions
|
|
@ -37,6 +37,7 @@ plugins {
|
|||
id("org.jetbrains.intellij.platform")
|
||||
alias(libs.plugins.changelog)
|
||||
alias(libs.plugins.protobuf)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
group = properties("pluginGroup").get()
|
||||
|
|
@ -72,26 +73,37 @@ dependencies {
|
|||
implementation(project(":codegpt-telemetry"))
|
||||
implementation(project(":codegpt-treesitter"))
|
||||
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(platform(libs.slf4j.bom))
|
||||
implementation(platform(libs.jackson.bom))
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8")
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation(platform(libs.mcp.sdk.bom))
|
||||
|
||||
implementation(libs.jackson.datatype.jdk8)
|
||||
implementation(libs.jackson.datatype.jsr310)
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
|
||||
implementation(libs.flexmark.all) {
|
||||
// vulnerable transitive dependency
|
||||
exclude(group = "org.jsoup", module = "jsoup")
|
||||
}
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(kotlin("reflect"))
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlin.reflect)
|
||||
implementation(libs.koog.agents) {
|
||||
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
|
||||
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8")
|
||||
}
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.commons.text)
|
||||
implementation(libs.jtokkit)
|
||||
|
||||
implementation(libs.grpc.protobuf)
|
||||
implementation(libs.grpc.stub)
|
||||
implementation(libs.grpc.netty.shaded)
|
||||
implementation(libs.protobuf.runtime)
|
||||
implementation(platform(libs.mcp.sdk.bom))
|
||||
|
||||
implementation(libs.mcp.sdk)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
testImplementation(libs.junit4)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ dependencies {
|
|||
}
|
||||
|
||||
implementation(lib("llm.client"))
|
||||
implementation(lib("kotlinx.serialization.json"))
|
||||
constraints {
|
||||
implementation(lib("okio")) {
|
||||
because("llm-client 0.7.0 uses okio 3.2.0: https://avd.aquasec.com/nvd/cve-2023-3635")
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ jsoup = "1.21.2"
|
|||
jtokkit = "1.1.0"
|
||||
junit = "5.13.4"
|
||||
kotlin = "2.2.10"
|
||||
llm-client = "0.8.56"
|
||||
kotlinx-serialization = "1.6.3"
|
||||
llm-client = "0.8.58"
|
||||
koog = "0.6.0"
|
||||
okhttp = "4.12.0"
|
||||
okio = "3.15.0"
|
||||
slf4j = "2.0.17"
|
||||
tree-sitter = "0.24.5"
|
||||
grpc = "1.73.0"
|
||||
protobuf = "3.25.1"
|
||||
|
|
@ -28,14 +32,24 @@ flexmark-all = { module = "com.vladsch.flexmark:flexmark-all", version.ref = "fl
|
|||
gradle-intellij-plugin = { module = "org.jetbrains.intellij.platform:intellij-platform-gradle-plugin", version.ref = "gradle-intellij-plugin-version" }
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" }
|
||||
jackson-datatype-jdk8 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", version.ref = "jackson" }
|
||||
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" }
|
||||
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
|
||||
mcp-sdk = { module = "io.modelcontextprotocol.sdk:mcp", version.ref = "mcp" }
|
||||
mcp-sdk-bom = { module = "io.modelcontextprotocol.sdk:mcp-bom", version.ref = "mcp" }
|
||||
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||
jtokkit = { module = "com.knuddels:jtokkit", version.ref = "jtokkit" }
|
||||
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
|
||||
junit4 = { module = "junit:junit", version = "4.13.2" }
|
||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||
koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" }
|
||||
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
llm-client = { module = "ee.carlrobert:llm-client", version.ref = "llm-client" }
|
||||
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
|
||||
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
||||
slf4j-bom = { module = "org.slf4j:slf4j-bom", version.ref = "slf4j" }
|
||||
tree-sitter = { module = "io.github.bonede:tree-sitter", version.ref = "tree-sitter" }
|
||||
grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" }
|
||||
grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" }
|
||||
|
|
@ -46,4 +60,5 @@ protobuf-runtime = { module = "com.google.protobuf:protobuf-java", version.ref =
|
|||
|
||||
[plugins]
|
||||
changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" }
|
||||
|
|
|
|||
|
|
@ -128,8 +128,8 @@ public class ToolwindowChatCompletionRequestHandler {
|
|||
}
|
||||
} catch (Throwable ex) {
|
||||
handleCallException(ex);
|
||||
throw ex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void handleCallException(Throwable ex) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package ee.carlrobert.codegpt.settings.service;
|
||||
|
||||
public enum FeatureType {
|
||||
AGENT,
|
||||
CHAT,
|
||||
CODE_COMPLETION,
|
||||
AUTO_APPLY,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.intellij.ui.JBColor;
|
|||
import com.intellij.util.concurrency.AppExecutorUtil;
|
||||
import com.intellij.util.ui.JBUI;
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys;
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle;
|
||||
import ee.carlrobert.codegpt.EncodingManager;
|
||||
import ee.carlrobert.codegpt.ReferencedFile;
|
||||
import ee.carlrobert.codegpt.actions.ActionType;
|
||||
|
|
@ -38,6 +39,7 @@ import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction;
|
|||
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureState;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.header.LoadingPanel;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensDetails;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel;
|
||||
|
|
@ -57,10 +59,10 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails;
|
|||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager;
|
||||
import ee.carlrobert.codegpt.util.EditorUtil;
|
||||
import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers;
|
||||
import ee.carlrobert.llm.client.openai.completion.ErrorDetails;
|
||||
import git4idea.GitCommit;
|
||||
import java.awt.BorderLayout;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
|
@ -93,6 +95,8 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
private final TagManager tagManager;
|
||||
private final JPanel mcpApprovalContainer;
|
||||
private @Nullable ToolwindowChatCompletionRequestHandler requestHandler;
|
||||
private LoadingPanel inputLoadingPanel;
|
||||
private final JPanel queuedMessageContainer;
|
||||
|
||||
public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) {
|
||||
this.project = project;
|
||||
|
|
@ -130,6 +134,11 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
mcpApprovalContainer.setBorder(JBUI.Borders.empty());
|
||||
mcpApprovalContainer.setOpaque(false);
|
||||
|
||||
queuedMessageContainer = new JPanel();
|
||||
queuedMessageContainer.setLayout(new BoxLayout(queuedMessageContainer, BoxLayout.Y_AXIS));
|
||||
queuedMessageContainer.setBorder(JBUI.Borders.empty());
|
||||
queuedMessageContainer.setOpaque(false);
|
||||
|
||||
rootPanel = createRootPanel();
|
||||
|
||||
if (conversation.getMessages().isEmpty()) {
|
||||
|
|
@ -414,7 +423,7 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
false,
|
||||
message.isWebSearchIncluded(),
|
||||
fileContextIncluded || message.getDocumentationDetails() != null,
|
||||
true,
|
||||
false,
|
||||
this));
|
||||
return panel;
|
||||
}
|
||||
|
|
@ -481,6 +490,7 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
}
|
||||
|
||||
userInputPanel.setSubmitEnabled(false);
|
||||
userInputPanel.setStopEnabled(true);
|
||||
userMessagePanel.disableActions(List.of("RELOAD", "DELETE"));
|
||||
responseMessagePanel.disableActions(List.of("COPY"));
|
||||
|
||||
|
|
@ -493,6 +503,29 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
totalTokensPanel,
|
||||
userInputPanel) {
|
||||
@Override
|
||||
public void handleRequestOpen() {
|
||||
super.handleRequestOpen();
|
||||
showInputLoading(CodeGPTBundle.get("toolwindow.chat.loading"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCompleted(String fullMessage, ChatCompletionParameters callParameters) {
|
||||
try {
|
||||
super.handleCompleted(fullMessage, callParameters);
|
||||
} finally {
|
||||
hideInputLoading();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(ErrorDetails error, Throwable ex) {
|
||||
try {
|
||||
super.handleError(error, ex);
|
||||
} finally {
|
||||
hideInputLoading();
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void handleTokensExceededPolicyAccepted() {
|
||||
call(callParameters, responseMessagePanel, userMessagePanel);
|
||||
}
|
||||
|
|
@ -500,6 +533,7 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
this);
|
||||
|
||||
requestHandler.setResponseMessagePanel(responseMessagePanel);
|
||||
showInputLoading(CodeGPTBundle.get("toolwindow.chat.loading"));
|
||||
requestHandler.call(callParameters);
|
||||
}
|
||||
|
||||
|
|
@ -520,7 +554,7 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
.filter(TagDetails::getSelected)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
var messageBuilder = new MessageBuilder(project, text).withInlays(appliedTags);
|
||||
var messageBuilder = new MessageBuilder(project, text).withTags(appliedTags);
|
||||
|
||||
List<ReferencedFile> referencedFiles = getReferencedFiles(appliedTags);
|
||||
if (!referencedFiles.isEmpty()) {
|
||||
|
|
@ -547,27 +581,15 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
private Unit handleCancel() {
|
||||
if (requestHandler != null) {
|
||||
requestHandler.cancel();
|
||||
ApplicationManager.getApplication().invokeLater(() -> {
|
||||
mcpApprovalContainer.removeAll();
|
||||
updateUserPromptPanel();
|
||||
hideInputLoading();
|
||||
userInputPanel.setSubmitEnabled(true);
|
||||
CompletionProgressNotifier.update(project, false);
|
||||
requestHandler = null;
|
||||
});
|
||||
}
|
||||
ApplicationManager.getApplication().invokeLater(() -> {
|
||||
mcpApprovalContainer.removeAll();
|
||||
updateUserPromptPanel();
|
||||
var lastComponent = toolWindowScrollablePanel.getLastComponent();
|
||||
if (lastComponent != null) {
|
||||
Arrays.stream(lastComponent.getComponents())
|
||||
.filter(ResponseMessagePanel.class::isInstance)
|
||||
.findFirst()
|
||||
.ifPresent(panel -> {
|
||||
ResponseMessagePanel responsePanel = (ResponseMessagePanel) panel;
|
||||
var responseComponent = responsePanel.getResponseComponent();
|
||||
if (responseComponent != null) {
|
||||
responseComponent.stopLoading();
|
||||
}
|
||||
});
|
||||
}
|
||||
userInputPanel.setSubmitEnabled(true);
|
||||
CompletionProgressNotifier.update(project, false);
|
||||
});
|
||||
requestHandler = null;
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
|
|
@ -581,6 +603,16 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
topContainer.setLayout(new BoxLayout(topContainer, BoxLayout.Y_AXIS));
|
||||
topContainer.setOpaque(false);
|
||||
|
||||
inputLoadingPanel = new LoadingPanel(CodeGPTBundle.get("toolwindow.chat.loading"), null, null);
|
||||
inputLoadingPanel.setAlignmentX(JComponent.LEFT_ALIGNMENT);
|
||||
inputLoadingPanel.setVisible(false);
|
||||
topContainer.add(inputLoadingPanel);
|
||||
|
||||
if (queuedMessageContainer.getComponentCount() > 0) {
|
||||
queuedMessageContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT);
|
||||
topContainer.add(queuedMessageContainer);
|
||||
}
|
||||
|
||||
if (mcpApprovalContainer.getComponentCount() > 0) {
|
||||
mcpApprovalContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT);
|
||||
topContainer.add(mcpApprovalContainer);
|
||||
|
|
@ -596,6 +628,23 @@ public class ChatToolWindowTabPanel implements Disposable {
|
|||
return panel;
|
||||
}
|
||||
|
||||
private void showInputLoading(String text) {
|
||||
if (inputLoadingPanel != null) {
|
||||
inputLoadingPanel.setText(text);
|
||||
inputLoadingPanel.setVisible(true);
|
||||
inputLoadingPanel.revalidate();
|
||||
inputLoadingPanel.repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private void hideInputLoading() {
|
||||
if (inputLoadingPanel != null) {
|
||||
inputLoadingPanel.setVisible(false);
|
||||
inputLoadingPanel.revalidate();
|
||||
inputLoadingPanel.repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private JComponent getLandingView() {
|
||||
return new ChatToolWindowLandingPanel((action, locationOnScreen) -> {
|
||||
var editor = EditorUtil.getSelectedEditor(project);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ abstract class ToolWindowCompletionResponseEventListener implements
|
|||
private final UserMessagePanel userMessagePanel;
|
||||
private ChatMessageResponseBody responseContainer;
|
||||
private final TotalTokensPanel totalTokensPanel;
|
||||
private final UserInputPanel textArea;
|
||||
private final UserInputPanel userInputPanel;
|
||||
|
||||
private final Timer updateTimer = new Timer(UPDATE_INTERVAL_MS, e -> processBufferedMessages());
|
||||
private final ConcurrentLinkedQueue<String> messageBuffer = new ConcurrentLinkedQueue<>();
|
||||
|
|
@ -47,13 +47,13 @@ abstract class ToolWindowCompletionResponseEventListener implements
|
|||
UserMessagePanel userMessagePanel,
|
||||
ResponseMessagePanel responsePanel,
|
||||
TotalTokensPanel totalTokensPanel,
|
||||
UserInputPanel textArea) {
|
||||
UserInputPanel userInputPanel) {
|
||||
this.encodingManager = EncodingManager.getInstance();
|
||||
this.project = project;
|
||||
this.userMessagePanel = userMessagePanel;
|
||||
this.responsePanel = responsePanel;
|
||||
this.totalTokensPanel = totalTokensPanel;
|
||||
this.textArea = textArea;
|
||||
this.userInputPanel = userInputPanel;
|
||||
}
|
||||
|
||||
public abstract void handleTokensExceededPolicyAccepted();
|
||||
|
|
@ -146,7 +146,7 @@ abstract class ToolWindowCompletionResponseEventListener implements
|
|||
container.withResponse(fullMessage);
|
||||
}
|
||||
}
|
||||
totalTokensPanel.updateUserPromptTokens(textArea.getText());
|
||||
totalTokensPanel.updateUserPromptTokens(userInputPanel.getText());
|
||||
totalTokensPanel.updateConversationTokens(callParameters.getConversation());
|
||||
} finally {
|
||||
stopStreaming(responseContainer);
|
||||
|
|
@ -184,11 +184,11 @@ abstract class ToolWindowCompletionResponseEventListener implements
|
|||
|
||||
private void stopStreaming(ChatMessageResponseBody responseContainer) {
|
||||
stopped = true;
|
||||
textArea.setSubmitEnabled(true);
|
||||
userInputPanel.setSubmitEnabled(true);
|
||||
userInputPanel.setStopEnabled(false);
|
||||
userMessagePanel.enableAllActions(true);
|
||||
responsePanel.enableAllActions(true);
|
||||
if (responseContainer != null) {
|
||||
responseContainer.stopLoading();
|
||||
responseContainer.hideCaret();
|
||||
responseContainer.finishThinking();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,9 @@ import com.intellij.openapi.ui.VerticalFlowLayout;
|
|||
import com.intellij.openapi.util.io.FileUtil;
|
||||
import com.intellij.openapi.vfs.LocalFileSystem;
|
||||
import com.intellij.openapi.vfs.VirtualFile;
|
||||
import com.intellij.ui.AnimatedIcon;
|
||||
import com.intellij.ui.PopupHandler;
|
||||
import com.intellij.ui.components.JBLabel;
|
||||
import com.intellij.util.ui.JBUI;
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel;
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle;
|
||||
import ee.carlrobert.codegpt.Icons;
|
||||
import ee.carlrobert.codegpt.actions.ActionType;
|
||||
|
|
@ -63,8 +61,8 @@ import java.awt.BorderLayout;
|
|||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JEditorPane;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextPane;
|
||||
import javax.swing.event.HyperlinkListener;
|
||||
|
|
@ -83,7 +81,6 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
private final DefaultListModel<WebSearchEventDetails> webpageListModel = new DefaultListModel<>();
|
||||
private final WebpageList webpageList = new WebpageList(webpageListModel);
|
||||
private final ResponseBodyProgressPanel progressPanel = new ResponseBodyProgressPanel();
|
||||
private final JPanel loadingLabel = createLoadingPanel();
|
||||
private final JPanel contentPanel =
|
||||
new JPanel(new VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 4, true, false));
|
||||
|
||||
|
|
@ -91,15 +88,6 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
private JEditorPane currentlyProcessedTextPane;
|
||||
private JPanel webpageListPanel;
|
||||
|
||||
private JPanel createLoadingPanel() {
|
||||
return new BorderLayoutPanel()
|
||||
.addToLeft(new JBLabel(
|
||||
CodeGPTBundle.get("toolwindow.chat.loading"),
|
||||
new AnimatedIcon.Default(),
|
||||
JLabel.LEFT))
|
||||
.withBorder(JBUI.Borders.empty(4, 0));
|
||||
}
|
||||
|
||||
public ChatMessageResponseBody(Project project, boolean compact, Disposable parentDisposable) {
|
||||
this(project, false, compact, false, false, false, parentDisposable);
|
||||
}
|
||||
|
|
@ -122,10 +110,7 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
setOpaque(false);
|
||||
|
||||
contentPanel.setOpaque(false);
|
||||
add(contentPanel, BorderLayout.NORTH);
|
||||
|
||||
loadingLabel.setVisible(withLoading);
|
||||
add(loadingLabel, BorderLayout.SOUTH);
|
||||
add(contentPanel, BorderLayout.CENTER);
|
||||
|
||||
if (ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
|
||||
== ServiceType.PROXYAI) {
|
||||
|
|
@ -153,15 +138,11 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
return this;
|
||||
}
|
||||
|
||||
public void stopLoading() {
|
||||
loadingLabel.setVisible(false);
|
||||
}
|
||||
|
||||
public void addToolStatusPanel(JPanel panel) {
|
||||
public void addToolStatusPanel(JComponent component) {
|
||||
currentlyProcessedTextPane = null;
|
||||
currentlyProcessedEditorPanel = null;
|
||||
streamOutputParser.clear();
|
||||
contentPanel.add(panel);
|
||||
contentPanel.add(component);
|
||||
}
|
||||
|
||||
public void updateMessage(String partialMessage) {
|
||||
|
|
@ -199,6 +180,11 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
});
|
||||
}
|
||||
|
||||
public void displayCreditsExhausted() {
|
||||
String message = "You’ve used all your ProxyAI credits. <a href=\"https://tryproxy.io/dashboard/billing\">Get more credits</a> to continue.";
|
||||
displayErrorMessage(message, UIUtil::handleHyperlinkClicked);
|
||||
}
|
||||
|
||||
public void displayError(String message) {
|
||||
displayErrorMessage(message, null);
|
||||
}
|
||||
|
|
@ -244,7 +230,6 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
public void clear() {
|
||||
contentPanel.removeAll();
|
||||
streamOutputParser.clear();
|
||||
loadingLabel.setVisible(false);
|
||||
|
||||
// Reset for the next incoming message
|
||||
prepareProcessingText(true);
|
||||
|
|
@ -257,9 +242,6 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
|
||||
private void displayErrorMessage(String message, HyperlinkListener hyperlinkListener) {
|
||||
ApplicationManager.getApplication().invokeLater(() -> {
|
||||
if (loadingLabel.isVisible()) {
|
||||
loadingLabel.setVisible(false);
|
||||
}
|
||||
if (webpageListPanel != null) {
|
||||
webpageListPanel.setVisible(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import static ee.carlrobert.codegpt.CodeGPTKeys.CODEGPT_USER_DETAILS;
|
|||
import static ee.carlrobert.codegpt.settings.service.ServiceType.ANTHROPIC;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.CUSTOM_OPENAI;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.GOOGLE;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.LLAMA_CPP;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.MISTRAL;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.OLLAMA;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.OPENAI;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.PROXYAI;
|
||||
import static ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION;
|
||||
|
||||
import com.intellij.icons.AllIcons;
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread;
|
||||
|
|
@ -150,8 +150,15 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
}
|
||||
|
||||
private AnAction[] getCodeGPTModelActions(Project project, Presentation presentation) {
|
||||
var userDetails = CODEGPT_USER_DETAILS.get(project);
|
||||
var registry = ModelRegistry.getInstance();
|
||||
if (featureType == FeatureType.AGENT) {
|
||||
return registry.getAllModelsForFeature(featureType).stream()
|
||||
.filter(model -> model.getProvider() == PROXYAI)
|
||||
.map(model -> createCodeGPTModelAction(model, presentation))
|
||||
.toArray(AnAction[]::new);
|
||||
}
|
||||
|
||||
var userDetails = CODEGPT_USER_DETAILS.get(project);
|
||||
return registry.getProxyAIChatModelsForPricingPlan(
|
||||
userDetails == null ? null : userDetails.getPricingPlan()).stream()
|
||||
.map(model -> createCodeGPTModelAction(model, presentation))
|
||||
|
|
@ -175,31 +182,28 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
if (availableProviders.contains(ANTHROPIC)) {
|
||||
var anthropicGroup = DefaultActionGroup.createPopupGroup(() -> "Anthropic");
|
||||
anthropicGroup.getTemplatePresentation().setIcon(Icons.Anthropic);
|
||||
anthropicGroup.add(
|
||||
createAnthropicModelAction(ModelRegistry.CLAUDE_OPUS_4_20250514, presentation));
|
||||
anthropicGroup.add(
|
||||
createAnthropicModelAction(ModelRegistry.CLAUDE_SONNET_4_20250514, presentation));
|
||||
ModelRegistry.getInstance().getAgentModels(ANTHROPIC).forEach(item ->
|
||||
anthropicGroup.add(createModelAction(
|
||||
ANTHROPIC,
|
||||
item.getModel().getId(),
|
||||
Icons.Anthropic,
|
||||
presentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(featureType, item.getModel().getId(), ANTHROPIC))));
|
||||
actionGroup.add(anthropicGroup);
|
||||
}
|
||||
|
||||
if (availableProviders.contains(OPENAI)) {
|
||||
var openaiGroup = DefaultActionGroup.createPopupGroup(() -> "OpenAI");
|
||||
openaiGroup.getTemplatePresentation().setIcon(Icons.OpenAI);
|
||||
List.of(
|
||||
OpenAIChatCompletionModel.GPT_5,
|
||||
OpenAIChatCompletionModel.GPT_5_MINI,
|
||||
OpenAIChatCompletionModel.O_4_MINI,
|
||||
OpenAIChatCompletionModel.O_3,
|
||||
OpenAIChatCompletionModel.O_3_MINI,
|
||||
OpenAIChatCompletionModel.O_1_PREVIEW,
|
||||
OpenAIChatCompletionModel.O_1_MINI,
|
||||
OpenAIChatCompletionModel.GPT_4_1,
|
||||
OpenAIChatCompletionModel.GPT_4_1_MINI,
|
||||
OpenAIChatCompletionModel.GPT_4_1_NANO,
|
||||
OpenAIChatCompletionModel.GPT_4_O,
|
||||
OpenAIChatCompletionModel.GPT_4_O_MINI,
|
||||
OpenAIChatCompletionModel.GPT_4_0125_128k)
|
||||
.forEach(model -> openaiGroup.add(createOpenAIModelAction(model, presentation)));
|
||||
ModelRegistry.getInstance().getAgentModels(OPENAI).forEach(item ->
|
||||
openaiGroup.add(createModelAction(
|
||||
OPENAI,
|
||||
item.getModel().getId(),
|
||||
Icons.OpenAI,
|
||||
presentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(featureType, item.getModel().getId(), OPENAI))));
|
||||
actionGroup.add(openaiGroup);
|
||||
}
|
||||
|
||||
|
|
@ -220,26 +224,32 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
if (availableProviders.contains(GOOGLE)) {
|
||||
var googleGroup = DefaultActionGroup.createPopupGroup(() -> "Google");
|
||||
googleGroup.getTemplatePresentation().setIcon(Icons.Google);
|
||||
List.of(
|
||||
GoogleModel.GEMINI_2_5_PRO_PREVIEW,
|
||||
GoogleModel.GEMINI_2_5_FLASH_PREVIEW,
|
||||
GoogleModel.GEMINI_2_5_PRO,
|
||||
GoogleModel.GEMINI_2_0_PRO_EXP,
|
||||
GoogleModel.GEMINI_2_0_FLASH_THINKING_EXP,
|
||||
GoogleModel.GEMINI_2_0_FLASH,
|
||||
GoogleModel.GEMINI_1_5_PRO)
|
||||
.forEach(model -> googleGroup.add(createGoogleModelAction(model, presentation)));
|
||||
|
||||
ModelRegistry.getInstance().getAgentModels(GOOGLE).forEach(item ->
|
||||
googleGroup.add(createModelAction(
|
||||
GOOGLE,
|
||||
item.getModel().getId(),
|
||||
Icons.Google,
|
||||
presentation,
|
||||
() -> {
|
||||
var application = ApplicationManager.getApplication();
|
||||
application.getService(ModelSettings.class)
|
||||
.setModel(featureType, item.getModel().getId(), GOOGLE);
|
||||
})));
|
||||
actionGroup.add(googleGroup);
|
||||
}
|
||||
|
||||
if (availableProviders.contains(MISTRAL)) {
|
||||
var mistralGroup = DefaultActionGroup.createPopupGroup(() -> "Mistral");
|
||||
mistralGroup.getTemplatePresentation().setIcon(Icons.Mistral);
|
||||
List.of(
|
||||
ModelRegistry.DEVSTRAL_MEDIUM_2507,
|
||||
ModelRegistry.MISTRAL_LARGE_2411,
|
||||
ModelRegistry.CODESTRAL_LATEST)
|
||||
.forEach(model -> mistralGroup.add(createMistralModelAction(model, presentation)));
|
||||
ModelRegistry.getInstance().getAgentModels(MISTRAL).forEach(item ->
|
||||
mistralGroup.add(createModelAction(
|
||||
MISTRAL,
|
||||
item.getModel().getId(),
|
||||
Icons.Mistral,
|
||||
presentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(featureType, item.getModel().getId(), MISTRAL))));
|
||||
actionGroup.add(mistralGroup);
|
||||
}
|
||||
|
||||
|
|
@ -310,7 +320,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
.filter(it -> modelCode != null && it.getModel().equals(modelCode))
|
||||
.findFirst();
|
||||
templatePresentation.setIcon(
|
||||
proxyAIModel.map(ModelSelection::getIcon).orElse(Icons.CodeGPTModel));
|
||||
proxyAIModel.map(ModelSelection::getIcon).orElse(Icons.DefaultSmall));
|
||||
templatePresentation.setText(
|
||||
proxyAIModel.map(ModelSelection::getDisplayName).orElse("Unknown"));
|
||||
break;
|
||||
|
|
@ -321,7 +331,10 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
break;
|
||||
case CUSTOM_OPENAI:
|
||||
ModelRegistry.getInstance().getCustomOpenAIModels().stream()
|
||||
.filter(it -> it.getModel().equals(modelCode))
|
||||
.filter(it -> {
|
||||
var id = it.getId();
|
||||
return id != null && id.equals(modelCode);
|
||||
})
|
||||
.findFirst()
|
||||
.ifPresent(selection -> {
|
||||
templatePresentation.setIcon(Icons.OpenAI);
|
||||
|
|
@ -354,7 +367,8 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
break;
|
||||
case INCEPTION:
|
||||
templatePresentation.setIcon(Icons.Inception);
|
||||
var inceptionModelName = ModelRegistry.getInstance().getModelDisplayName(INCEPTION, modelCode);
|
||||
var inceptionModelName = ModelRegistry.getInstance()
|
||||
.getModelDisplayName(INCEPTION, modelCode);
|
||||
templatePresentation.setText(inceptionModelName);
|
||||
break;
|
||||
default:
|
||||
|
|
@ -425,7 +439,7 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
var selected = model.getDisplayName().equals(comboBoxPresentation.getText());
|
||||
var locked = shouldLockModel(model);
|
||||
return new CodeGPTModelsListPopupAction(model.getDisplayName(), model.getModel(),
|
||||
model.getIcon() != null ? model.getIcon() : Icons.CodeGPTModel,
|
||||
model.getIcon() != null ? model.getIcon() : Icons.DefaultSmall,
|
||||
model.getPricingPlan() != null ? model.getPricingPlan() : PricingPlan.ANONYMOUS,
|
||||
locked, selected, () -> {
|
||||
var application = ApplicationManager.getApplication();
|
||||
|
|
@ -438,6 +452,10 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
}
|
||||
|
||||
private boolean shouldLockModel(ModelSelection model) {
|
||||
if (featureType == FeatureType.AGENT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var userDetails = CODEGPT_USER_DETAILS.get(project);
|
||||
if (userDetails != null) {
|
||||
if (userDetails.getPricingPlan() == PricingPlan.INDIVIDUAL) {
|
||||
|
|
@ -478,15 +496,20 @@ public class ModelComboBoxAction extends ComboBoxAction {
|
|||
}
|
||||
|
||||
private AnAction createCustomOpenAIModelAction(
|
||||
CustomServiceSettingsState model,
|
||||
CustomServiceSettingsState state,
|
||||
Presentation comboBoxPresentation) {
|
||||
var model = state.getChatCompletionSettings().getBody().get("model");
|
||||
var displayName =
|
||||
state.getName()
|
||||
+ ((model instanceof String && !((String) model).isEmpty()) ? " (" + model + ")" : "");
|
||||
|
||||
return createModelAction(
|
||||
CUSTOM_OPENAI,
|
||||
model.getName(),
|
||||
displayName,
|
||||
Icons.OpenAI,
|
||||
comboBoxPresentation,
|
||||
() -> ApplicationManager.getApplication().getService(ModelSettings.class)
|
||||
.setModel(featureType, model.getId(), CUSTOM_OPENAI));
|
||||
.setModel(featureType, state.getId(), CUSTOM_OPENAI));
|
||||
}
|
||||
|
||||
private AnAction createGoogleModelAction(GoogleModel model, Presentation comboBoxPresentation) {
|
||||
|
|
|
|||
35
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentEvents.kt
Normal file
35
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentEvents.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.core.feature.handler.agent.AgentCompletedContext
|
||||
import ai.koog.prompt.executor.clients.LLMClientException
|
||||
import ee.carlrobert.codegpt.agent.tools.AskUserQuestionTool
|
||||
import ee.carlrobert.codegpt.conversations.message.TokenUsage
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
|
||||
|
||||
interface AgentEvents {
|
||||
fun onTextReceived(text: String) {}
|
||||
fun onAgentCompleted(ctx: AgentCompletedContext) {}
|
||||
fun onToolStarting(id: String, toolName: String, args: Any?) {}
|
||||
fun onToolCompleted(id: String?, toolName: String, result: Any?) {}
|
||||
fun onSubAgentToolStarting(parentId: String, toolName: String, args: Any?): String? = null
|
||||
fun onSubAgentToolCompleted(
|
||||
parentId: String,
|
||||
childId: String?,
|
||||
toolName: String,
|
||||
result: Any?
|
||||
) {
|
||||
}
|
||||
|
||||
fun onQueuedMessagesResolved()
|
||||
fun onTokenUsageAvailable(tokenUsage: Long) {}
|
||||
fun onTokenUsageUpdated(tokenUsage: TokenUsage) {}
|
||||
fun onCreditsAvailable(event: AgentCreditsEvent) {}
|
||||
fun onClientException(provider: ServiceType, ex: LLMClientException) {}
|
||||
|
||||
suspend fun approveToolCall(request: ToolApprovalRequest): Boolean = false
|
||||
|
||||
suspend fun askUserQuestions(model: AskUserQuestionTool.AskUserQuestionsModel): Map<String, String> =
|
||||
emptyMap()
|
||||
}
|
||||
456
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt
Normal file
456
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.core.agent.AIAgent
|
||||
import ai.koog.agents.core.agent.GraphAIAgent
|
||||
import ai.koog.agents.core.agent.config.AIAgentConfig
|
||||
import ai.koog.agents.core.dsl.builder.forwardTo
|
||||
import ai.koog.agents.core.dsl.builder.strategy
|
||||
import ai.koog.agents.core.dsl.extension.*
|
||||
import ai.koog.agents.core.feature.handler.tool.ToolCallCompletedContext
|
||||
import ai.koog.agents.core.feature.handler.tool.ToolCallStartingContext
|
||||
import ai.koog.agents.core.tools.ToolRegistry
|
||||
import ai.koog.agents.ext.tool.ExitTool
|
||||
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
|
||||
import ai.koog.agents.features.eventHandler.feature.handleEvents
|
||||
import ai.koog.prompt.dsl.prompt
|
||||
import ai.koog.prompt.executor.clients.LLMClient
|
||||
import ai.koog.prompt.executor.clients.anthropic.AnthropicLLMClient
|
||||
import ai.koog.prompt.executor.clients.google.GoogleLLMClient
|
||||
import ai.koog.prompt.executor.clients.mistralai.MistralAILLMClient
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
|
||||
import ai.koog.prompt.executor.clients.retry.RetryConfig
|
||||
import ai.koog.prompt.executor.clients.retry.RetryingLLMClient
|
||||
import ai.koog.prompt.executor.llms.SingleLLMPromptExecutor
|
||||
import ai.koog.prompt.executor.model.PromptExecutor
|
||||
import ai.koog.prompt.executor.ollama.client.OllamaClient
|
||||
import ai.koog.prompt.message.Message
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.agent.clients.CustomOpenAILLMClient
|
||||
import ee.carlrobert.codegpt.agent.clients.ProxyAILLMClient
|
||||
import ee.carlrobert.codegpt.agent.credits.extractCreditsSnapshot
|
||||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey
|
||||
import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
|
||||
import java.time.LocalDate
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
object AgentFactory {
|
||||
|
||||
fun createAgent(
|
||||
agentType: AgentType,
|
||||
provider: ServiceType,
|
||||
project: Project,
|
||||
sessionId: String,
|
||||
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
|
||||
onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)? = null,
|
||||
onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)? = null,
|
||||
extraBehavior: String? = null,
|
||||
toolOverrides: Set<SubagentTool>? = null,
|
||||
onCreditsAvailable: ((AgentCreditsEvent) -> Unit)? = null
|
||||
): AIAgent<String, String> {
|
||||
val installHandler = buildUsageAwareInstallHandler(
|
||||
provider,
|
||||
sessionId,
|
||||
onAgentToolCallStarting,
|
||||
onAgentToolCallCompleted,
|
||||
onCreditsAvailable
|
||||
)
|
||||
return when (agentType) {
|
||||
AgentType.GENERAL_PURPOSE -> createGeneralPurposeAgent(
|
||||
provider,
|
||||
project,
|
||||
sessionId,
|
||||
approveToolCall,
|
||||
installHandler,
|
||||
extraBehavior,
|
||||
toolOverrides
|
||||
)
|
||||
|
||||
AgentType.EXPLORE -> createExploreAgent(
|
||||
provider,
|
||||
project,
|
||||
sessionId,
|
||||
approveToolCall,
|
||||
installHandler,
|
||||
extraBehavior,
|
||||
toolOverrides
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createManualAgent(
|
||||
provider: ServiceType,
|
||||
project: Project,
|
||||
sessionId: String,
|
||||
title: String,
|
||||
behavior: String,
|
||||
toolNames: Set<String>,
|
||||
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
|
||||
onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)? = null,
|
||||
onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)? = null,
|
||||
onCreditsAvailable: ((AgentCreditsEvent) -> Unit)? = null,
|
||||
): AIAgent<String, String> {
|
||||
val installHandler = buildUsageAwareInstallHandler(
|
||||
provider,
|
||||
sessionId,
|
||||
onAgentToolCallStarting,
|
||||
onAgentToolCallCompleted,
|
||||
onCreditsAvailable
|
||||
)
|
||||
|
||||
val selected = SubagentTool.parse(toolNames)
|
||||
val registry = createToolRegistry(
|
||||
project,
|
||||
sessionId,
|
||||
approveToolCall,
|
||||
selected,
|
||||
approvalBashHandler(approveToolCall)
|
||||
)
|
||||
|
||||
return AIAgent.Companion(
|
||||
promptExecutor = createExecutor(provider),
|
||||
strategy = singleRunWithParallelAbility(),
|
||||
agentConfig = AIAgentConfig(
|
||||
prompt = prompt("manual-agent") {
|
||||
system(
|
||||
"""
|
||||
You are a user-defined subagent named "$title".
|
||||
|
||||
${getEnvironmentInfo(project)}${behaviorSection(behavior)}
|
||||
""".trimIndent()
|
||||
)
|
||||
},
|
||||
model = service<ModelSelectionService>().getAgentModel(),
|
||||
maxAgentIterations = 100
|
||||
),
|
||||
toolRegistry = registry,
|
||||
installFeatures = installHandler
|
||||
)
|
||||
}
|
||||
|
||||
fun createExecutor(provider: ServiceType): PromptExecutor {
|
||||
return when (provider) {
|
||||
ServiceType.OPENAI -> {
|
||||
val apiKey = getCredential(CredentialKey.OpenaiApiKey) ?: ""
|
||||
createRetryingExecutor(OpenAILLMClient(apiKey))
|
||||
}
|
||||
|
||||
ServiceType.ANTHROPIC -> {
|
||||
val apiKey = getCredential(CredentialKey.AnthropicApiKey) ?: ""
|
||||
createRetryingExecutor(AnthropicLLMClient(apiKey))
|
||||
}
|
||||
|
||||
ServiceType.GOOGLE -> {
|
||||
val apiKey = getCredential(CredentialKey.GoogleApiKey) ?: ""
|
||||
createRetryingExecutor(GoogleLLMClient(apiKey))
|
||||
}
|
||||
|
||||
ServiceType.OLLAMA -> {
|
||||
createRetryingExecutor(OllamaClient())
|
||||
}
|
||||
|
||||
ServiceType.MISTRAL -> {
|
||||
val apiKey = getCredential(CredentialKey.MistralApiKey) ?: ""
|
||||
createRetryingExecutor(MistralAILLMClient(apiKey))
|
||||
}
|
||||
|
||||
ServiceType.CUSTOM_OPENAI -> {
|
||||
val state = service<CustomServicesSettings>()
|
||||
.customServiceStateForFeatureType(FeatureType.AGENT)
|
||||
val serviceId = state.id
|
||||
?: throw IllegalArgumentException("No custom service configured")
|
||||
val apiKey = getCredential(CredentialKey.CustomServiceApiKeyById(serviceId))
|
||||
?: throw IllegalArgumentException("No API key found for custom service: $serviceId")
|
||||
|
||||
createRetryingExecutor(
|
||||
CustomOpenAILLMClient.fromSettingsState(apiKey, state.chatCompletionSettings)
|
||||
)
|
||||
}
|
||||
|
||||
ServiceType.PROXYAI -> {
|
||||
val apiKey = getCredential(CredentialKey.CodeGptApiKey) ?: ""
|
||||
createRetryingExecutor(ProxyAILLMClient(apiKey))
|
||||
}
|
||||
|
||||
else -> throw UnsupportedOperationException("Provider not supported: $provider")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRetryingExecutor(client: LLMClient): PromptExecutor {
|
||||
val retryingClient = RetryingLLMClient(
|
||||
client,
|
||||
RetryConfig(
|
||||
maxAttempts = 3,
|
||||
initialDelay = 1.seconds,
|
||||
maxDelay = 20.seconds,
|
||||
backoffMultiplier = 2.0,
|
||||
jitterFactor = 0.2
|
||||
)
|
||||
)
|
||||
return SingleLLMPromptExecutor(retryingClient)
|
||||
}
|
||||
|
||||
private fun createGeneralPurposeAgent(
|
||||
provider: ServiceType,
|
||||
project: Project,
|
||||
sessionId: String,
|
||||
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
|
||||
installFeatures: GraphAIAgent.FeatureContext.() -> Unit = {},
|
||||
extraBehavior: String? = null,
|
||||
toolOverrides: Set<SubagentTool>? = null
|
||||
): AIAgent<String, String> {
|
||||
|
||||
val selectedTools = toolOverrides ?: SubagentTool.entries.toSet()
|
||||
return AIAgent.Companion(
|
||||
promptExecutor = createExecutor(provider),
|
||||
strategy = singleRunWithParallelAbility(),
|
||||
agentConfig = AIAgentConfig(
|
||||
prompt = prompt("general-agent") {
|
||||
system(
|
||||
"""
|
||||
You are a general-purpose coding subagent operating inside a JetBrains IDE. You are invoked by a main agent to execute concrete steps. Work efficiently, call tools decisively, and keep messages concise and action-oriented.
|
||||
|
||||
${getEnvironmentInfo(project)}${behaviorSection(extraBehavior)}
|
||||
|
||||
# Tone and Style
|
||||
- Keep responses short, task-focused, and free of fluff.
|
||||
- Use GitHub-flavored Markdown for structure when helpful.
|
||||
|
||||
# Tool Usage Policy
|
||||
- You may call multiple tools in a single turn. If calls are independent, run them in parallel. If calls depend on earlier results, run sequentially.
|
||||
- Prefer specialized tools over bash: use Read for file content, Grep for search, Edit/Write for code changes. Use Bash only for true shell operations.
|
||||
- Never use Bash to "echo" thoughts or communicate. All communication happens in your response text.
|
||||
- Never guess parameters. If a required argument is unknown, first gather it via an appropriate tool.
|
||||
- Respect approval gates: Edit/Write operations require confirmation hooks and may be denied.
|
||||
- Use WebSearch for library/framework questions; use ResolveLibraryId/GetLibraryDocs for dependency docs.
|
||||
- Use TodoWrite to outline and track subtasks when a request spans multiple steps.
|
||||
|
||||
# Tool Routing Rules
|
||||
- If the user asks about how to use a library, best practices, API semantics, configuration, or conventions: prefer ResolveLibraryId followed by GetLibraryDocs to gather authoritative guidance before proposing changes.
|
||||
- Use IntelliJSearch/Grep to locate symbols and examples before editing files.
|
||||
|
||||
# Collaboration as Subagent
|
||||
- Assume a parent agent orchestrates overall strategy. Focus on execution quality and clear, minimal output.
|
||||
- Do not create further planning layers; execute tasks and report concrete results or blockers.
|
||||
|
||||
# Good Practices
|
||||
- Be precise and cite evidence (paths/lines) for findings and changes.
|
||||
- Batch independent reads/greps/web queries in parallel for speed.
|
||||
- Validate at boundaries (user input, external APIs); trust internal code guarantees.
|
||||
""".trimIndent()
|
||||
)
|
||||
},
|
||||
model = service<ModelSelectionService>().getAgentModel(),
|
||||
maxAgentIterations = 100
|
||||
),
|
||||
toolRegistry = createToolRegistry(
|
||||
project,
|
||||
sessionId,
|
||||
approveToolCall,
|
||||
selectedTools,
|
||||
approvalBashHandler(approveToolCall)
|
||||
),
|
||||
installFeatures = installFeatures
|
||||
)
|
||||
}
|
||||
|
||||
private fun createExploreAgent(
|
||||
provider: ServiceType,
|
||||
project: Project,
|
||||
sessionId: String,
|
||||
approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null,
|
||||
installFeatures: GraphAIAgent.FeatureContext.() -> Unit = {},
|
||||
extraBehavior: String? = null,
|
||||
toolOverrides: Set<SubagentTool>? = null
|
||||
): AIAgent<String, String> {
|
||||
val selectedTools = toolOverrides ?: (SubagentTool.readOnly + SubagentTool.BASH).toSet()
|
||||
return AIAgent.Companion(
|
||||
promptExecutor = createExecutor(provider),
|
||||
strategy = singleRunWithParallelAbility(),
|
||||
agentConfig = AIAgentConfig(
|
||||
prompt = prompt("explore-agent") {
|
||||
system(
|
||||
"""
|
||||
You are an Explore subagent for codebase understanding. You are invoked by a main agent to gather context and answer questions by reading and searching the project. You do NOT modify files.
|
||||
|
||||
${getEnvironmentInfo(project)}${behaviorSection(extraBehavior)}
|
||||
|
||||
# Tool Usage Policy (Read-only)
|
||||
- Use Read to examine files; Grep to search patterns; WebSearch for external context; ResolveLibraryId/GetLibraryDocs for dependencies; TodoWrite to record findings.
|
||||
- You may call multiple tools in parallel when independent (e.g., read multiple files, run several greps at once).
|
||||
- Do not use Edit or Write. Avoid destructive Bash. Use Bash only for safe, read-only operations if strictly necessary.
|
||||
- Never guess parameters; first gather precise paths/patterns.
|
||||
|
||||
# Tool Routing Rules
|
||||
- For library usage and best practices: ResolveLibraryId then GetLibraryDocs to retrieve relevant docs prior to summarizing advice.
|
||||
- Use IntelliJSearch/Grep for code navigation and symbol discovery.
|
||||
|
||||
# Exploration Workflow
|
||||
- Initial scan: read key config/entry files in parallel; map structure.
|
||||
- Deep dive: run targeted greps and reads in parallel for related components.
|
||||
- Summarize: synthesize findings with concrete references; use TodoWrite to capture a brief outline of insights.
|
||||
|
||||
# Good Practices
|
||||
- Prefer breadth-first sampling before deep dives.
|
||||
- Keep results actionable, with clear file paths and line references.
|
||||
- Stop when the question is fully answered or sufficient context is gathered; otherwise continue exploration.
|
||||
""".trimIndent()
|
||||
)
|
||||
},
|
||||
model = service<ModelSelectionService>().getAgentModel(),
|
||||
maxAgentIterations = 100
|
||||
),
|
||||
toolRegistry = createToolRegistry(
|
||||
project,
|
||||
sessionId,
|
||||
approveToolCall = approveToolCall,
|
||||
selected = selectedTools,
|
||||
bashConfirmationHandler = { ShellCommandConfirmation.Approved }
|
||||
),
|
||||
installFeatures = installFeatures
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildUsageAwareInstallHandler(
|
||||
provider: ServiceType,
|
||||
sessionId: String,
|
||||
onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)?,
|
||||
onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)?,
|
||||
onCreditsAvailable: ((AgentCreditsEvent) -> Unit)?
|
||||
): GraphAIAgent.FeatureContext.() -> Unit = {
|
||||
handleEvents {
|
||||
onToolCallStarting { ctx -> onAgentToolCallStarting?.invoke(ctx) }
|
||||
onToolCallCompleted { ctx -> onAgentToolCallCompleted?.invoke(ctx) }
|
||||
onNodeExecutionCompleted { ctx ->
|
||||
val responses = (ctx.output as? List<*>)?.filterIsInstance<Message.Response>()
|
||||
?: return@onNodeExecutionCompleted
|
||||
if (responses.isEmpty()) return@onNodeExecutionCompleted
|
||||
if (provider == ServiceType.PROXYAI) {
|
||||
extractCredits(responses, sessionId)?.let { onCreditsAvailable?.invoke(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractCredits(
|
||||
responses: List<Message.Response>,
|
||||
sessionId: String
|
||||
): AgentCreditsEvent? {
|
||||
val credits = extractCreditsSnapshot(responses) ?: return null
|
||||
return AgentCreditsEvent(
|
||||
sessionId = sessionId,
|
||||
remaining = credits.remaining,
|
||||
monthlyRemaining = credits.monthlyRemaining,
|
||||
consumed = credits.total
|
||||
)
|
||||
}
|
||||
|
||||
private fun singleRunWithParallelAbility() = strategy("subagent_single_run_sequential") {
|
||||
val nodeCallLLM by nodeLLMRequestMultiple()
|
||||
val nodeExecuteTool by nodeExecuteMultipleTools(parallelTools = true)
|
||||
val nodeSendToolResult by nodeLLMSendMultipleToolResults()
|
||||
|
||||
edge(nodeStart forwardTo nodeCallLLM)
|
||||
edge(nodeCallLLM forwardTo nodeExecuteTool onMultipleToolCalls { true })
|
||||
edge(
|
||||
nodeCallLLM forwardTo nodeFinish
|
||||
onMultipleAssistantMessages { true }
|
||||
transformed { it.joinToString("\n") { message -> message.content } }
|
||||
)
|
||||
|
||||
edge(nodeExecuteTool forwardTo nodeSendToolResult)
|
||||
|
||||
edge(nodeSendToolResult forwardTo nodeExecuteTool onMultipleToolCalls { true })
|
||||
|
||||
edge(
|
||||
nodeSendToolResult forwardTo nodeFinish
|
||||
onMultipleAssistantMessages { true }
|
||||
transformed { it.joinToString("\n") { message -> message.content } }
|
||||
)
|
||||
}
|
||||
|
||||
private fun getEnvironmentInfo(project: Project): String {
|
||||
val osName = System.getProperty("os.name") ?: "Unknown"
|
||||
val osVersion = System.getProperty("os.version") ?: "Unknown"
|
||||
val currentDate = LocalDate.now()
|
||||
|
||||
return """
|
||||
<environment>
|
||||
Working Directory: ${project.basePath}
|
||||
Platform: $osName
|
||||
OS Version: $osVersion
|
||||
Current Date: $currentDate
|
||||
</environment>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun createToolRegistry(
|
||||
project: Project,
|
||||
sessionId: String,
|
||||
approveToolCall: (suspend (name: String, details: String) -> Boolean)?,
|
||||
selected: Set<SubagentTool>,
|
||||
bashConfirmationHandler: BashCommandConfirmationHandler
|
||||
): ToolRegistry {
|
||||
return ToolRegistry.Companion {
|
||||
if (SubagentTool.READ in selected) tool(ReadTool(project))
|
||||
if (SubagentTool.EDIT in selected) {
|
||||
tool(
|
||||
ConfirmingEditTool(EditTool(project)) { name, details ->
|
||||
approveToolCall?.invoke(name, details) ?: true
|
||||
}
|
||||
)
|
||||
}
|
||||
if (SubagentTool.WRITE in selected) {
|
||||
tool(
|
||||
ConfirmingWriteTool(WriteTool(project)) { name, details ->
|
||||
approveToolCall?.invoke(name, details) ?: true
|
||||
}
|
||||
)
|
||||
}
|
||||
if (SubagentTool.TODO_WRITE in selected) tool(TodoWriteTool(project, sessionId))
|
||||
if (SubagentTool.INTELLIJ_SEARCH in selected) tool(IntelliJSearchTool(project))
|
||||
if (SubagentTool.WEB_SEARCH in selected) tool(WebSearchTool())
|
||||
if (SubagentTool.BASH_OUTPUT in selected) tool(BashOutputTool(sessionId))
|
||||
if (SubagentTool.KILL_SHELL in selected) tool(KillShellTool())
|
||||
if (SubagentTool.RESOLVE_LIBRARY_ID in selected) tool(ResolveLibraryIdTool())
|
||||
if (SubagentTool.GET_LIBRARY_DOCS in selected) tool(GetLibraryDocsTool())
|
||||
if (SubagentTool.BASH in selected) {
|
||||
tool(
|
||||
BashTool(
|
||||
project.basePath ?: "",
|
||||
confirmationHandler = bashConfirmationHandler,
|
||||
sessionId = sessionId,
|
||||
settingsService = project.service<ProxyAISettingsService>()
|
||||
)
|
||||
)
|
||||
}
|
||||
tool(ExitTool)
|
||||
}
|
||||
}
|
||||
|
||||
private fun approvalBashHandler(
|
||||
approveToolCall: (suspend (name: String, details: String) -> Boolean)?
|
||||
): BashCommandConfirmationHandler {
|
||||
return BashCommandConfirmationHandler { args ->
|
||||
try {
|
||||
val ok = approveToolCall?.invoke("Bash", args.command) != false
|
||||
if (ok) ShellCommandConfirmation.Approved
|
||||
else ShellCommandConfirmation.Denied("User rejected the command")
|
||||
} catch (_: Exception) {
|
||||
ShellCommandConfirmation.Approved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun behaviorSection(behavior: String?): String {
|
||||
val content = behavior?.trim().orEmpty()
|
||||
return if (content.isEmpty()) "" else "\n# Subagent Behavior\n$content"
|
||||
}
|
||||
}
|
||||
12
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentNotifier.kt
Normal file
12
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentNotifier.kt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
|
||||
interface AgentToolOutputNotifier {
|
||||
fun toolOutput(toolId: String, text: String, isError: Boolean)
|
||||
|
||||
companion object {
|
||||
val AGENT_TOOL_OUTPUT_TOPIC =
|
||||
Topic.create("agentToolOutput", AgentToolOutputNotifier::class.java)
|
||||
}
|
||||
}
|
||||
122
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt
Normal file
122
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.core.agent.AIAgent
|
||||
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
|
||||
import ai.koog.prompt.executor.clients.LLMClientException
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.conversations.message.TokenUsageTracker
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.io.path.Path
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
class AgentService(private val project: Project) {
|
||||
|
||||
companion object {
|
||||
private val logger = thisLogger()
|
||||
}
|
||||
|
||||
private val sessionJobs = ConcurrentHashMap<String, Job>()
|
||||
private val pendingMessages = ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>()
|
||||
private val sessionAgents = ConcurrentHashMap<String, AIAgent<MessageWithContext, String>>()
|
||||
private val sessionTokenTrackers = ConcurrentHashMap<String, TokenUsageTracker>()
|
||||
private val checkpointStorage =
|
||||
JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai"))
|
||||
|
||||
private val _queuedMessageProcessed = MutableSharedFlow<String>()
|
||||
val queuedMessageProcessed = _queuedMessageProcessed.asSharedFlow()
|
||||
|
||||
fun addToQueue(message: MessageWithContext, sessionId: String) {
|
||||
pendingMessages.getOrPut(sessionId) { ArrayDeque() }.add(message)
|
||||
}
|
||||
|
||||
fun submitMessage(message: MessageWithContext, events: AgentEvents, sessionId: String) {
|
||||
if (isSessionRunning(sessionId)) {
|
||||
addToQueue(message, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = service<ModelSelectionService>().getServiceForFeature(FeatureType.AGENT)
|
||||
val previousCheckpoint = runBlocking {
|
||||
val prevAgentId =
|
||||
project.service<AgentSessionState>().state.sessions
|
||||
.firstOrNull { it.sessionId == sessionId }?.lastAgentId
|
||||
if (prevAgentId == null) {
|
||||
return@runBlocking null
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val checkpoints = checkpointStorage.getCheckpoints(prevAgentId)
|
||||
val latestCheckpoint = checkpoints
|
||||
.filter { it.nodePath != "tombstone" }
|
||||
.maxByOrNull { it.createdAt }
|
||||
latestCheckpoint
|
||||
}.onFailure { ex ->
|
||||
logger.error(
|
||||
"Agent checkpoints: failed to load for session=$sessionId " +
|
||||
"agentId=$prevAgentId error=${ex.message}", ex
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
val agent = ProxyAIAgent.create(
|
||||
project,
|
||||
checkpointStorage,
|
||||
previousCheckpoint,
|
||||
provider,
|
||||
events,
|
||||
sessionId,
|
||||
pendingMessages
|
||||
)
|
||||
sessionAgents[sessionId] = agent
|
||||
project.service<AgentSessionState>().updateSession(sessionId, lastAgentId = agent.id)
|
||||
sessionJobs[sessionId] = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
agent.run(message)
|
||||
} catch (ex: LLMClientException) {
|
||||
events.onClientException(provider, ex)
|
||||
} catch (_: CancellationException) {
|
||||
return@launch
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex)
|
||||
} finally {
|
||||
sessionJobs.remove(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelCurrentRun(sessionId: String) {
|
||||
sessionJobs[sessionId]?.cancel()
|
||||
sessionJobs.remove(sessionId)
|
||||
}
|
||||
|
||||
fun isSessionRunning(sessionId: String): Boolean {
|
||||
return sessionJobs[sessionId]?.isActive == true
|
||||
}
|
||||
|
||||
fun getPendingMessages(sessionId: String): List<MessageWithContext> {
|
||||
return pendingMessages[sessionId]?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
fun clearPendingMessages(sessionId: String) {
|
||||
pendingMessages.remove(sessionId)
|
||||
}
|
||||
|
||||
fun getAgentForSession(sessionId: String): AIAgent<MessageWithContext, String>? {
|
||||
return sessionAgents[sessionId]
|
||||
}
|
||||
|
||||
fun getTokenTrackerForSession(sessionId: String): TokenUsageTracker {
|
||||
return sessionTokenTrackers.getOrPut(sessionId) { TokenUsageTracker() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ee.carlrobert.codegpt.settings.models.ModelRegistry
|
||||
import ee.carlrobert.codegpt.settings.models.ModelSelection
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.util.file.FileUtil
|
||||
|
||||
internal object AgentSystemPrompts {
|
||||
private const val WORKING_DIRECTORY_TOKEN = "{{WORKING_DIRECTORY}}"
|
||||
|
||||
private const val OPENAI_KEY = "openai"
|
||||
private const val GEMINI_KEY = "gemini"
|
||||
private const val ANTHROPIC_KEY = "anthropic"
|
||||
|
||||
private val openAiPrompt: String by lazy { loadPrompt("/prompts/agent/openai.txt") }
|
||||
private val geminiPrompt: String by lazy { loadPrompt("/prompts/agent/google.txt") }
|
||||
private val anthropicPrompt: String by lazy { loadPrompt("/prompts/agent/anthropic.txt") }
|
||||
|
||||
fun createSystemPrompt(
|
||||
provider: ServiceType,
|
||||
modelSelection: ModelSelection?,
|
||||
projectPath: String? = null
|
||||
): String {
|
||||
return when (provider) {
|
||||
ServiceType.PROXYAI -> createProxyAiSystemPrompt(modelSelection, projectPath)
|
||||
ServiceType.OPENAI -> createOpenAiSystemPrompt(projectPath)
|
||||
ServiceType.GOOGLE -> createGeminiSystemPrompt(projectPath)
|
||||
else -> createAnthropicSystemPrompt(projectPath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createProxyAiSystemPrompt(
|
||||
modelSelection: ModelSelection?,
|
||||
projectPath: String?
|
||||
): String {
|
||||
val modelId = modelSelection?.model
|
||||
return if (modelId == ModelRegistry.PROXYAI_AUTO || modelId?.startsWith("claude") == true) {
|
||||
createAnthropicSystemPrompt(projectPath)
|
||||
} else {
|
||||
createOpenAiSystemPrompt(projectPath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOpenAiSystemPrompt(projectPath: String? = null): String {
|
||||
return renderPrompt(OPENAI_KEY, projectPath)
|
||||
}
|
||||
|
||||
private fun createGeminiSystemPrompt(projectPath: String? = null): String {
|
||||
return renderPrompt(GEMINI_KEY, projectPath)
|
||||
}
|
||||
|
||||
private fun createAnthropicSystemPrompt(projectPath: String? = null): String {
|
||||
return renderPrompt(ANTHROPIC_KEY, projectPath)
|
||||
}
|
||||
|
||||
private fun renderPrompt(key: String, projectPath: String?): String {
|
||||
val workingDirectory = projectPath ?: "unknown"
|
||||
return getPrompt(key).replace(WORKING_DIRECTORY_TOKEN, workingDirectory)
|
||||
}
|
||||
|
||||
private fun getPrompt(key: String): String {
|
||||
return when (key) {
|
||||
OPENAI_KEY -> openAiPrompt
|
||||
GEMINI_KEY -> geminiPrompt
|
||||
ANTHROPIC_KEY -> anthropicPrompt
|
||||
else -> error("Unknown system prompt key: $key")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPrompt(path: String): String {
|
||||
return FileUtil.getResourceContent(path).trimEnd()
|
||||
}
|
||||
}
|
||||
28
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentType.kt
Normal file
28
src/main/kotlin/ee/carlrobert/codegpt/agent/AgentType.kt
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
/**
|
||||
* Represents the different types of specialized agents that can be launched via the Task tool.
|
||||
* Each agent type has a specific focus and optimized tool set for its intended use case.
|
||||
*/
|
||||
sealed class AgentType(val value: String, val description: String) {
|
||||
|
||||
object GENERAL_PURPOSE : AgentType(
|
||||
value = "general-purpose",
|
||||
description = "General-purpose agent for complex research, code search, and multi-step tasks"
|
||||
)
|
||||
|
||||
object EXPLORE : AgentType(
|
||||
value = "explore",
|
||||
description = "Fast agent specialized for exploring codebases and finding files/patterns"
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): AgentType = when (value.lowercase().trim()) {
|
||||
"general-purpose" -> GENERAL_PURPOSE
|
||||
"explore" -> EXPLORE
|
||||
else -> throw IllegalArgumentException(
|
||||
"Unknown agent type: '$value'. Valid types are: general-purpose, explore, plan."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
|
||||
import java.util.UUID
|
||||
|
||||
class MessageWithContext(val text: String, val tags: List<TagDetails>) {
|
||||
|
||||
val id: UUID = UUID.randomUUID()
|
||||
}
|
||||
212
src/main/kotlin/ee/carlrobert/codegpt/agent/PromptEnhancer.kt
Normal file
212
src/main/kotlin/ee/carlrobert/codegpt/agent/PromptEnhancer.kt
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
|
||||
import ai.koog.prompt.dsl.prompt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState
|
||||
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
|
||||
import ee.carlrobert.codegpt.util.GitUtil
|
||||
import ee.carlrobert.codegpt.util.ThinkingOutputParser
|
||||
import ee.carlrobert.codegpt.util.file.FileUtil
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.io.path.Path
|
||||
import ai.koog.prompt.message.Message as KoogMessage
|
||||
import ai.koog.prompt.message.Message as PromptMessage
|
||||
import ee.carlrobert.codegpt.conversations.message.Message as ChatMessage
|
||||
|
||||
data class PromptEnhancement(val prompt: String, val includeGitChanges: Boolean)
|
||||
|
||||
class PromptEnhancer(private val project: Project) {
|
||||
|
||||
fun enhancePrompt(
|
||||
prompt: String,
|
||||
tags: List<TagDetails> = emptyList(),
|
||||
sessionId: String? = null
|
||||
): PromptEnhancement {
|
||||
return runBlocking { enhancePromptInternal(prompt, tags, sessionId) }
|
||||
}
|
||||
|
||||
private suspend fun enhancePromptInternal(
|
||||
prompt: String,
|
||||
tags: List<TagDetails>,
|
||||
sessionId: String?
|
||||
): PromptEnhancement {
|
||||
val trimmedPrompt = prompt.trim()
|
||||
val gitDiff = GitUtil.getCurrentChanges(project)?.trim().orEmpty()
|
||||
val tagsContext = buildTagsContext(tags)
|
||||
val historyContext = buildHistoryContext(sessionId)
|
||||
|
||||
val modelService = service<ModelSelectionService>()
|
||||
val provider = modelService.getServiceForFeature(FeatureType.AGENT)
|
||||
val model = modelService.getAgentModel()
|
||||
val executor = AgentFactory.createExecutor(provider)
|
||||
|
||||
val responseText = runCatching {
|
||||
val p = prompt("prompt-enhancer") {
|
||||
system(PromptEnhancerPrompts.DEFAULT_PROMPT)
|
||||
user(buildUserPrompt(trimmedPrompt, gitDiff, tagsContext, historyContext))
|
||||
}
|
||||
val responses = executor.execute(p, model, emptyList())
|
||||
responses.filterIsInstance<KoogMessage.Assistant>()
|
||||
.joinToString("\n") { it.content.trim() }
|
||||
}.getOrDefault("")
|
||||
|
||||
val enhanced = extractEnhancedPrompt(responseText.ifBlank { trimmedPrompt })
|
||||
|
||||
return PromptEnhancement(
|
||||
prompt = enhanced,
|
||||
includeGitChanges = gitDiff.isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildUserPrompt(
|
||||
prompt: String,
|
||||
gitDiff: String,
|
||||
tagsContext: String,
|
||||
historyContext: String
|
||||
): String {
|
||||
return buildString {
|
||||
if (historyContext.isNotBlank()) {
|
||||
append("Previous conversation history:\n")
|
||||
append(historyContext.trim())
|
||||
append("\n\n")
|
||||
}
|
||||
if (tagsContext.isNotBlank()) {
|
||||
append("Selected tag context:\n")
|
||||
append(tagsContext.trim())
|
||||
append("\n\n")
|
||||
}
|
||||
append("Original prompt:\n")
|
||||
append(prompt)
|
||||
if (gitDiff.isNotBlank()) {
|
||||
append("\n\nCurrent git changes:\n```diff\n")
|
||||
append(gitDiff)
|
||||
append("\n```")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractEnhancedPrompt(response: String): String {
|
||||
val parser = ThinkingOutputParser()
|
||||
val parsed = parser.processChunk(response)
|
||||
val candidate = parsed.ifBlank { response }.trim()
|
||||
return stripCodeFence(candidate)
|
||||
}
|
||||
|
||||
private fun stripCodeFence(text: String): String {
|
||||
val trimmed = text.trim()
|
||||
if (!trimmed.startsWith("```")) {
|
||||
return trimmed
|
||||
}
|
||||
val closingIndex = trimmed.lastIndexOf("```")
|
||||
if (closingIndex <= 2) {
|
||||
return trimmed
|
||||
}
|
||||
val inner = trimmed.substring(3, closingIndex).trimStart()
|
||||
val newlineIndex = inner.indexOf('\n')
|
||||
if (newlineIndex == -1) {
|
||||
return inner.trim()
|
||||
}
|
||||
val header = inner.substring(0, newlineIndex)
|
||||
val body = inner.substring(newlineIndex + 1)
|
||||
return if (header.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
|
||||
body.trim()
|
||||
} else {
|
||||
inner.trim()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTagsContext(tags: List<TagDetails>): String {
|
||||
if (tags.isEmpty()) return ""
|
||||
val contextBuilder = StringBuilder()
|
||||
val tempMessage = ChatMessage("")
|
||||
tags.forEach { tagDetails ->
|
||||
val processor = TagProcessorFactory.getProcessor(project, tagDetails)
|
||||
processor.process(tempMessage, contextBuilder)
|
||||
}
|
||||
appendTagMetadata(contextBuilder, tempMessage)
|
||||
return contextBuilder.toString().trim()
|
||||
}
|
||||
|
||||
private fun appendTagMetadata(builder: StringBuilder, message: ChatMessage) {
|
||||
val referencedFiles = message.referencedFilePaths.orEmpty()
|
||||
if (referencedFiles.isNotEmpty()) {
|
||||
builder.append("\nReferenced files:\n")
|
||||
referencedFiles.forEach { path ->
|
||||
builder.append("- ").append(path).append("\n")
|
||||
}
|
||||
}
|
||||
message.documentationDetails?.let { details ->
|
||||
builder.append("\nDocumentation:\n")
|
||||
.append(details.name)
|
||||
.append(" (")
|
||||
.append(details.url)
|
||||
.append(")\n")
|
||||
}
|
||||
message.personaName?.takeIf { it.isNotBlank() }?.let { persona ->
|
||||
builder.append("\nPersona:\n").append(persona).append("\n")
|
||||
}
|
||||
message.imageFilePath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||
builder.append("\nImage:\n").append(path).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildHistoryContext(sessionId: String?): String {
|
||||
if (sessionId.isNullOrBlank()) return ""
|
||||
val sessionState = project.service<AgentSessionState>()
|
||||
val agentId = sessionState.getLastAgentId(sessionId) ?: return ""
|
||||
val storage = JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai"))
|
||||
val checkpoint = storage.getCheckpoints(agentId)
|
||||
.filter { it.nodePath != "tombstone" }
|
||||
.maxByOrNull { it.createdAt }
|
||||
?: return ""
|
||||
return formatHistory(checkpoint.messageHistory)
|
||||
}
|
||||
|
||||
private fun formatHistory(messages: List<PromptMessage>): String {
|
||||
val builder = StringBuilder()
|
||||
messages.filterNot { it is PromptMessage.System }.forEach { msg ->
|
||||
when (msg) {
|
||||
is PromptMessage.User -> {
|
||||
appendHistoryLine(builder, "User", msg.content)
|
||||
}
|
||||
|
||||
is PromptMessage.Assistant -> {
|
||||
appendHistoryLine(builder, "Assistant", msg.content)
|
||||
}
|
||||
|
||||
is PromptMessage.Reasoning -> {
|
||||
appendHistoryLine(builder, "Assistant", msg.content)
|
||||
}
|
||||
|
||||
is PromptMessage.Tool.Call -> {
|
||||
appendHistoryLine(builder, "Tool Call (${msg.tool})", msg.content)
|
||||
}
|
||||
|
||||
is PromptMessage.Tool.Result -> {
|
||||
appendHistoryLine(builder, "Tool Result (${msg.tool})", msg.content)
|
||||
}
|
||||
|
||||
else -> {
|
||||
appendHistoryLine(builder, "Message", msg.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.toString().trim()
|
||||
}
|
||||
|
||||
private fun appendHistoryLine(builder: StringBuilder, label: String, content: String) {
|
||||
val trimmed = content.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
builder.append(label).append(": ").append(trimmed).append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
private object PromptEnhancerPrompts {
|
||||
private const val PROMPT_PATH = "/prompts/core/prompt-enhancer.txt"
|
||||
val DEFAULT_PROMPT: String = FileUtil.getResourceContent(PROMPT_PATH)
|
||||
}
|
||||
289
src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt
Normal file
289
src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.core.agent.AIAgent
|
||||
import ai.koog.agents.core.agent.config.AIAgentConfig
|
||||
import ai.koog.agents.core.agent.context.RollbackStrategy
|
||||
import ai.koog.agents.core.tools.ToolRegistry
|
||||
import ai.koog.agents.ext.tool.ExitTool
|
||||
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
|
||||
import ai.koog.agents.features.eventHandler.feature.handleEvents
|
||||
import ai.koog.agents.features.tokenizer.feature.MessageTokenizer
|
||||
import ai.koog.agents.features.tracing.feature.Tracing
|
||||
import ai.koog.agents.features.tracing.writer.TraceFeatureMessageLogWriter
|
||||
import ai.koog.agents.snapshot.feature.AgentCheckpointData
|
||||
import ai.koog.agents.snapshot.feature.Persistence
|
||||
import ai.koog.agents.snapshot.feature.persistence
|
||||
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
|
||||
import ai.koog.prompt.dsl.prompt
|
||||
import ai.koog.prompt.message.Message
|
||||
import ai.koog.prompt.streaming.StreamFrame
|
||||
import ai.koog.prompt.tokenizer.Tokenizer
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.EncodingManager
|
||||
import ee.carlrobert.codegpt.agent.strategy.SingleRunStrategyProvider
|
||||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.BashPayload
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalType
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class ToolError(val message: String)
|
||||
|
||||
object ProxyAIAgent {
|
||||
|
||||
private const val INSTRUCTION_FILE_NAME = "PROXYAI.md"
|
||||
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
private fun searchForInstructions(projectPath: String?): String? {
|
||||
if (projectPath == null) return null
|
||||
val projectRoot = Paths.get(projectPath)
|
||||
if (!Files.exists(projectRoot)) return null
|
||||
|
||||
val proxyAiFile = projectRoot.resolve(INSTRUCTION_FILE_NAME)
|
||||
if (Files.exists(proxyAiFile) && Files.isRegularFile(proxyAiFile)) {
|
||||
try {
|
||||
val content = Files.readString(proxyAiFile).trim()
|
||||
if (content.isNotEmpty()) {
|
||||
return content
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun create(
|
||||
project: Project,
|
||||
checkpointStorage: JVMFilePersistenceStorageProvider,
|
||||
previousCheckpoint: AgentCheckpointData?,
|
||||
provider: ServiceType,
|
||||
events: AgentEvents,
|
||||
sessionId: String,
|
||||
pendingMessages: ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>,
|
||||
): AIAgent<MessageWithContext, String> {
|
||||
val modelSelection =
|
||||
service<ModelSelectionService>().getModelSelectionForFeature(FeatureType.AGENT)
|
||||
val stream =
|
||||
provider == ServiceType.ANTHROPIC || provider == ServiceType.OPENAI || provider == ServiceType.PROXYAI
|
||||
val projectInstructions = searchForInstructions(project.basePath)
|
||||
|
||||
val executor = AgentFactory.createExecutor(provider)
|
||||
val pendingMessageQueue = pendingMessages.getOrPut(sessionId) { ArrayDeque() }
|
||||
val toolRegistry = createToolRegistry(project, events, sessionId)
|
||||
val agent = AIAgent(
|
||||
promptExecutor = executor,
|
||||
strategy = SingleRunStrategyProvider().build(
|
||||
project,
|
||||
executor,
|
||||
projectInstructions,
|
||||
previousCheckpoint,
|
||||
pendingMessageQueue,
|
||||
events,
|
||||
sessionId,
|
||||
provider,
|
||||
stream
|
||||
),
|
||||
agentConfig = AIAgentConfig(
|
||||
prompt = prompt("proxyai-agent") {
|
||||
system(
|
||||
AgentSystemPrompts.createSystemPrompt(
|
||||
provider,
|
||||
modelSelection,
|
||||
project.basePath
|
||||
)
|
||||
)
|
||||
},
|
||||
model = service<ModelSelectionService>().getAgentModel(),
|
||||
maxAgentIterations = 100
|
||||
),
|
||||
toolRegistry = toolRegistry,
|
||||
) {
|
||||
if (ConfigurationSettings.getState().debugModeEnabled) {
|
||||
install(Tracing) {
|
||||
addMessageProcessor(TraceFeatureMessageLogWriter(logger))
|
||||
}
|
||||
}
|
||||
install(Persistence) {
|
||||
storage = checkpointStorage
|
||||
rollbackStrategy = RollbackStrategy.Default
|
||||
}
|
||||
install(MessageTokenizer) {
|
||||
tokenizer = object : Tokenizer {
|
||||
override fun countTokens(text: String): Int =
|
||||
EncodingManager.getInstance().countTokens(text)
|
||||
}
|
||||
enableCaching = false
|
||||
}
|
||||
|
||||
handleEvents {
|
||||
val toolCallToUiId: MutableMap<String, String> = HashMap()
|
||||
val anonymousToolIds: ArrayDeque<String> = ArrayDeque()
|
||||
|
||||
onLLMStreamingFrameReceived { ctx ->
|
||||
if (!stream) return@onLLMStreamingFrameReceived
|
||||
|
||||
val frame = ctx.streamFrame
|
||||
if (frame is StreamFrame.Append) {
|
||||
events.onTextReceived(frame.text)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeExecutionCompleted { ctx ->
|
||||
val checkpoint = ctx.context.persistence().createCheckpoint(
|
||||
agentContext = ctx.context,
|
||||
nodePath = ctx.context.executionInfo.path(),
|
||||
lastInput = ctx.input,
|
||||
lastInputType = ctx.inputType,
|
||||
checkpointId = ctx.context.runId,
|
||||
version = 0L
|
||||
)
|
||||
project.service<AgentSessionState>().updateSession(
|
||||
sessionId = sessionId,
|
||||
lastAgentId = ctx.context.agentId,
|
||||
checkpointId = checkpoint?.checkpointId
|
||||
)
|
||||
|
||||
if (stream) return@onNodeExecutionCompleted
|
||||
|
||||
(ctx.output as? List<*>)?.forEach { msg ->
|
||||
(msg as? Message.Assistant)?.let {
|
||||
events.onTextReceived(it.content)
|
||||
}
|
||||
(msg as? Message.Reasoning)?.let {
|
||||
events.onTextReceived(it.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onToolCallStarting { ctx ->
|
||||
val id = ctx.toolCallId ?: UUID.randomUUID().toString()
|
||||
if (ctx.toolCallId == null) {
|
||||
anonymousToolIds.addLast(id)
|
||||
}
|
||||
val decodedArgs =
|
||||
ToolCallPayloadDecoder.decodeArgs(toolRegistry, ctx.toolName, ctx.toolArgs)
|
||||
events.onToolStarting(id, ctx.toolName, decodedArgs)
|
||||
ToolRunContext.set(sessionId, id)
|
||||
}
|
||||
|
||||
onToolCallCompleted { ctx ->
|
||||
val uiId = when {
|
||||
ctx.toolCallId != null -> toolCallToUiId[ctx.toolCallId] ?: ctx.toolCallId
|
||||
anonymousToolIds.isNotEmpty() -> anonymousToolIds.removeFirst()
|
||||
else -> null
|
||||
}
|
||||
val decodedResult = ToolCallPayloadDecoder.decodeResult(
|
||||
toolRegistry,
|
||||
ctx.toolName,
|
||||
ctx.toolResult
|
||||
)
|
||||
events.onToolCompleted(uiId, ctx.toolName, decodedResult)
|
||||
}
|
||||
|
||||
onAgentCompleted { context ->
|
||||
events.onAgentCompleted(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
private fun createToolRegistry(
|
||||
project: Project,
|
||||
events: AgentEvents,
|
||||
sessionId: String
|
||||
): ToolRegistry {
|
||||
return ToolRegistry {
|
||||
tool(ReadTool(project))
|
||||
tool(
|
||||
ConfirmingEditTool(EditTool(project)) { name, details ->
|
||||
try {
|
||||
events.approveToolCall(
|
||||
ToolApprovalRequest(
|
||||
if (name.equals("Edit", true)
|
||||
) ToolApprovalType.EDIT else ToolApprovalType.GENERIC,
|
||||
"Allow $name?",
|
||||
details
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
tool(
|
||||
ConfirmingWriteTool(WriteTool(project)) { name, details ->
|
||||
try {
|
||||
val type = if (name.equals("Write", true))
|
||||
ToolApprovalType.WRITE
|
||||
else ToolApprovalType.GENERIC
|
||||
|
||||
events.approveToolCall(
|
||||
ToolApprovalRequest(
|
||||
type,
|
||||
"Allow $name?",
|
||||
details
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
tool(TodoWriteTool(project, sessionId))
|
||||
tool(AskUserQuestionTool(events))
|
||||
tool(ExitTool)
|
||||
tool(IntelliJSearchTool(project))
|
||||
tool(WebSearchTool())
|
||||
tool(BashOutputTool(sessionId))
|
||||
tool(KillShellTool())
|
||||
tool(ResolveLibraryIdTool())
|
||||
tool(GetLibraryDocsTool())
|
||||
tool(
|
||||
BashTool(
|
||||
project.basePath ?: "",
|
||||
confirmationHandler = { args ->
|
||||
try {
|
||||
val approved = events.approveToolCall(
|
||||
ToolApprovalRequest(
|
||||
ToolApprovalType.BASH,
|
||||
"Run shell command?",
|
||||
args.command,
|
||||
BashPayload(args.command, args.description)
|
||||
)
|
||||
)
|
||||
if (approved) ShellCommandConfirmation.Approved else ShellCommandConfirmation.Denied(
|
||||
"User rejected the command"
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ShellCommandConfirmation.Approved
|
||||
}
|
||||
},
|
||||
sessionId = sessionId,
|
||||
settingsService = project.service<ProxyAISettingsService>()
|
||||
)
|
||||
)
|
||||
tool(
|
||||
TaskTool(
|
||||
project,
|
||||
sessionId,
|
||||
service<ModelSelectionService>().getServiceForFeature(FeatureType.AGENT),
|
||||
events
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt
Normal file
39
src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
enum class SubagentTool(val id: String, val displayName: String, val isWrite: Boolean) {
|
||||
READ("read", "Read", false),
|
||||
TODO_WRITE("todowrite", "TodoWrite", false),
|
||||
INTELLIJ_SEARCH("intellijsearch", "IntelliJSearch", false),
|
||||
WEB_SEARCH("websearch", "WebSearch", false),
|
||||
RESOLVE_LIBRARY_ID("resolvelibraryid", "ResolveLibraryId", false),
|
||||
GET_LIBRARY_DOCS("getlibrarydocs", "GetLibraryDocs", false),
|
||||
BASH_OUTPUT("bashoutput", "BashOutput", false),
|
||||
KILL_SHELL("killshell", "KillShell", false),
|
||||
EDIT("edit", "Edit", true),
|
||||
WRITE("write", "Write", true),
|
||||
BASH("bash", "Bash", true),
|
||||
EXIT("exit", "Exit", false);
|
||||
|
||||
companion object {
|
||||
val readOnly: List<SubagentTool> = entries.filterNot { it.isWrite }
|
||||
val write: List<SubagentTool> = entries.filter { it.isWrite }
|
||||
|
||||
fun parse(values: Collection<String>): Set<SubagentTool> {
|
||||
return values.mapNotNull { fromString(it) }.toSet()
|
||||
}
|
||||
|
||||
fun toStoredValues(tools: Collection<SubagentTool>): List<String> {
|
||||
val selected = tools.toSet()
|
||||
return entries.filter { it in selected }.map { it.id }
|
||||
}
|
||||
|
||||
fun fromString(value: String): SubagentTool? {
|
||||
val key = normalize(value)
|
||||
return entries.firstOrNull { normalize(it.id) == key || normalize(it.displayName) == key }
|
||||
}
|
||||
|
||||
private fun normalize(value: String): String {
|
||||
return value.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.core.tools.ToolRegistry
|
||||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
object ToolCallPayloadDecoder {
|
||||
|
||||
private val fallbackJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
decodeEnumsCaseInsensitive = true
|
||||
}
|
||||
|
||||
fun decodeArgs(toolRegistry: ToolRegistry?, toolName: String, rawArgs: JsonObject): Any? {
|
||||
val tool = toolRegistry?.getToolOrNull(toolName)
|
||||
if (tool != null) {
|
||||
return runCatching { tool.decodeArgs(rawArgs) }.getOrElse { rawArgs }
|
||||
}
|
||||
val serializer = argsSerializerFor(toolName) ?: return rawArgs
|
||||
return decodeWith(serializer, rawArgs, rawArgs)
|
||||
}
|
||||
|
||||
fun decodeResult(toolRegistry: ToolRegistry?, toolName: String, rawResult: JsonElement?): Any? {
|
||||
if (rawResult == null) return null
|
||||
val tool = toolRegistry?.getToolOrNull(toolName)
|
||||
if (tool != null) {
|
||||
return runCatching { tool.decodeResult(rawResult) }.getOrElse { rawResult }
|
||||
}
|
||||
val serializer = resultSerializerFor(toolName) ?: return rawResult
|
||||
return decodeWith(serializer, rawResult, rawResult)
|
||||
}
|
||||
|
||||
private fun argsSerializerFor(toolName: String): KSerializer<out Any>? {
|
||||
return when (toolName) {
|
||||
"Read" -> ReadTool.Args.serializer()
|
||||
"Write" -> WriteTool.Args.serializer()
|
||||
"Edit" -> EditTool.Args.serializer()
|
||||
"Bash" -> BashTool.Args.serializer()
|
||||
"BashOutput" -> BashOutputTool.Args.serializer()
|
||||
"KillShell" -> KillShellTool.Args.serializer()
|
||||
"IntelliJSearch" -> IntelliJSearchTool.Args.serializer()
|
||||
"WebSearch" -> WebSearchTool.Args.serializer()
|
||||
"ResolveLibraryId" -> ResolveLibraryIdTool.Args.serializer()
|
||||
"GetLibraryDocs" -> GetLibraryDocsTool.Args.serializer()
|
||||
"Task" -> TaskTool.Args.serializer()
|
||||
"AskUserQuestion" -> AskUserQuestionTool.Args.serializer()
|
||||
"TodoWrite", "TodoWriteTool" -> TodoWriteTool.Args.serializer()
|
||||
"Exit" -> Unit.serializer()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultSerializerFor(toolName: String): KSerializer<out Any>? {
|
||||
return when (toolName) {
|
||||
"Read" -> ReadTool.Result.serializer()
|
||||
"Write" -> WriteTool.Result.serializer()
|
||||
"Edit" -> EditTool.Result.serializer()
|
||||
"Bash" -> BashTool.Result.serializer()
|
||||
"BashOutput" -> BashOutputTool.Result.serializer()
|
||||
"KillShell" -> KillShellTool.Result.serializer()
|
||||
"IntelliJSearch" -> IntelliJSearchTool.Result.serializer()
|
||||
"WebSearch" -> WebSearchTool.Result.serializer()
|
||||
"ResolveLibraryId" -> ResolveLibraryIdTool.Result.serializer()
|
||||
"GetLibraryDocs" -> GetLibraryDocsTool.Result.serializer()
|
||||
"Task" -> TaskTool.Result.serializer()
|
||||
"AskUserQuestion" -> AskUserQuestionTool.Result.serializer()
|
||||
"TodoWrite", "TodoWriteTool" -> String.serializer()
|
||||
"Exit" -> Unit.serializer()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> decodeWith(
|
||||
serializer: KSerializer<T>,
|
||||
raw: JsonElement,
|
||||
fallback: Any?
|
||||
): Any? {
|
||||
return runCatching { fallbackJson.decodeFromJsonElement(serializer, raw) }.getOrElse { fallback }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class ToolContext(
|
||||
val sessionId: String,
|
||||
val toolId: String,
|
||||
val checkpointId: String? = null,
|
||||
val parentToolId: String? = null,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
object ToolRunContext {
|
||||
private val sessionContexts = ConcurrentHashMap<String, ThreadLocal<ToolContext?>>()
|
||||
|
||||
fun set(sessionId: String, toolId: String, checkpointId: String? = null) {
|
||||
val currentContext = get(sessionId)
|
||||
val context = ToolContext(
|
||||
sessionId = sessionId,
|
||||
toolId = toolId,
|
||||
checkpointId = checkpointId,
|
||||
parentToolId = currentContext?.toolId,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
sessionContexts.computeIfAbsent(sessionId) { ThreadLocal() }.set(context)
|
||||
}
|
||||
|
||||
fun get(sessionId: String): ToolContext? {
|
||||
return sessionContexts[sessionId]?.get()
|
||||
}
|
||||
|
||||
fun getToolId(sessionId: String): String? {
|
||||
return sessionContexts[sessionId]?.get()?.toolId
|
||||
}
|
||||
|
||||
fun clear(sessionId: String) {
|
||||
sessionContexts[sessionId]?.remove()
|
||||
}
|
||||
|
||||
fun cleanupSession(sessionId: String) {
|
||||
sessionContexts.remove(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package ee.carlrobert.codegpt.agent.clients
|
||||
|
||||
import ai.koog.prompt.executor.clients.openai.base.models.*
|
||||
import ai.koog.prompt.executor.clients.serialization.AdditionalPropertiesFlatteningSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
internal class CustomOpenAIChatCompletionRequest(
|
||||
val messages: List<OpenAIMessage> = emptyList(),
|
||||
val prompt: String? = null,
|
||||
override val model: String? = null,
|
||||
override val stream: Boolean? = null,
|
||||
override val temperature: Double? = null,
|
||||
val tools: List<OpenAITool>? = null,
|
||||
val toolChoice: OpenAIToolChoice? = null,
|
||||
override val topP: Double? = null,
|
||||
override val topLogprobs: Int? = null,
|
||||
val maxTokens: Int? = null,
|
||||
val frequencyPenalty: Double? = null,
|
||||
val presencePenalty: Double? = null,
|
||||
val responseFormat: OpenAIResponseFormat? = null,
|
||||
val stop: List<String>? = null,
|
||||
val logprobs: Boolean? = null,
|
||||
val seed: Int? = null,
|
||||
val topK: Int? = null,
|
||||
val repetitionPenalty: Double? = null,
|
||||
val logitBias: Map<Int, Double>? = null,
|
||||
val minP: Double? = null,
|
||||
val topA: Double? = null,
|
||||
val prediction: OpenAIStaticContent? = null,
|
||||
val transforms: List<String>? = null,
|
||||
val models: List<String>? = null,
|
||||
val route: String? = null,
|
||||
val user: String? = null,
|
||||
val additionalProperties: Map<String, JsonElement>? = null,
|
||||
) : OpenAIBaseLLMRequest
|
||||
|
||||
/**
|
||||
* Chat completion choice
|
||||
*
|
||||
* @property finishReason The reason the model stopped generating tokens.
|
||||
* This will be `stop` if the model hit a natural stop point or a provided stop sequence,
|
||||
* `length` if the maximum number of tokens specified in the request was reached,
|
||||
* `content_filter` if content was omitted due to a flag from our content filters,
|
||||
* `tool_calls` if the model called a tool, or `function_call` (deprecated) if the model called a function.
|
||||
* `error` if the model finishes the request with an error.
|
||||
* @property nativeFinishReason The raw finish_reason string returned by the model
|
||||
* @property message A chat completion message generated by the model.
|
||||
* @property error An error response structure typically used for conveying error details to the clients.
|
||||
*
|
||||
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
|
||||
*/
|
||||
@Serializable
|
||||
public class CustomOpenAIChoice(
|
||||
public val finishReason: String? = null,
|
||||
public val nativeFinishReason: String? = null,
|
||||
public val message: OpenAIMessage,
|
||||
public val error: ErrorResponse? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Chat completion choice
|
||||
*
|
||||
* @property finishReason The reason the model stopped generating tokens.
|
||||
* This will be `stop` if the model hit a natural stop point or a provided stop sequence,
|
||||
* `length` if the maximum number of tokens specified in the request was reached,
|
||||
* `content_filter` if content was omitted due to a flag from our content filters,
|
||||
* `tool_calls` if the model called a tool, or `function_call` (deprecated) if the model called a function.
|
||||
* `error` if the model finishes the request with an error.
|
||||
* @property nativeFinishReason The raw finish_reason string returned by the model
|
||||
* @property delta A chat completion delta generated by streamed model responses.
|
||||
* @property error An error response structure typically used for conveying error details to the clients.
|
||||
*
|
||||
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
|
||||
*/
|
||||
@Serializable
|
||||
public class CustomOpenAIStreamChoice(
|
||||
public val finishReason: String? = null,
|
||||
public val nativeFinishReason: String? = null,
|
||||
public val delta: CustomOpenAIStreamDelta,
|
||||
public val error: ErrorResponse? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* @property content The contents of the chunk message.
|
||||
* @property role The role of the author of this message.
|
||||
* @property toolCalls The tool calls requested by the model.
|
||||
*/
|
||||
@Serializable
|
||||
public class CustomOpenAIStreamDelta(
|
||||
public val content: String? = null,
|
||||
public val role: String? = null,
|
||||
public val toolCalls: List<CustomOpenAIToolCall>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
public class CustomOpenAIToolCall(
|
||||
public val id: String? = "",
|
||||
public val function: CustomOpenAIFunction
|
||||
) {
|
||||
/** The type of the tool. Currently, only `function` is supported. */
|
||||
public val type: String = "function"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
public class CustomOpenAIFunction(
|
||||
public val name: String? = "",
|
||||
public val arguments: String? = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents an error response structure typically used for conveying error details to the clients.
|
||||
*
|
||||
* @property code The numeric code representing the error.
|
||||
* @property message A descriptive message providing details about the error.
|
||||
* @property metadata Optional additional information about the error in the form of key-value pairs.
|
||||
*/
|
||||
@Serializable
|
||||
public class ErrorResponse(
|
||||
public val code: Int,
|
||||
public val message: String,
|
||||
public val metadata: Map<String, String>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* CustomOpenAI Chat Completion Response
|
||||
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
|
||||
*/
|
||||
@Serializable
|
||||
public class CustomOpenAIChatCompletionResponse(
|
||||
public val choices: List<CustomOpenAIChoice>,
|
||||
override val created: Long,
|
||||
override val id: String,
|
||||
override val model: String,
|
||||
public val systemFingerprint: String? = null,
|
||||
@SerialName("object")
|
||||
public val objectType: String = "chat.completion",
|
||||
public val usage: OpenAIUsage? = null,
|
||||
) : OpenAIBaseLLMResponse
|
||||
|
||||
/**
|
||||
* CustomOpenAI Chat Completion Streaming Response
|
||||
* See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format]
|
||||
*/
|
||||
@Serializable
|
||||
public class CustomOpenAIChatCompletionStreamResponse(
|
||||
public val choices: List<CustomOpenAIStreamChoice>,
|
||||
override val created: Long,
|
||||
override val id: String,
|
||||
override val model: String,
|
||||
public val systemFingerprint: String? = null,
|
||||
@SerialName("object")
|
||||
public val objectType: String = "chat.completion.chunk",
|
||||
public val usage: OpenAIUsage? = null,
|
||||
) : OpenAIBaseLLMStreamResponse
|
||||
|
||||
internal object CustomOpenAIChatCompletionRequestSerializer :
|
||||
AdditionalPropertiesFlatteningSerializer<CustomOpenAIChatCompletionRequest>(
|
||||
CustomOpenAIChatCompletionRequest.serializer()
|
||||
)
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
package ee.carlrobert.codegpt.agent.clients
|
||||
|
||||
import ai.koog.prompt.dsl.ModerationResult
|
||||
import ai.koog.prompt.dsl.Prompt
|
||||
import ai.koog.prompt.executor.clients.ConnectionTimeoutConfig
|
||||
import ai.koog.prompt.executor.clients.LLMClient
|
||||
import ai.koog.prompt.executor.clients.LLMClientException
|
||||
import ai.koog.prompt.executor.clients.openai.base.AbstractOpenAILLMClient
|
||||
import ai.koog.prompt.executor.clients.openai.base.OpenAIBaseSettings
|
||||
import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator
|
||||
import ai.koog.prompt.executor.clients.openai.base.models.*
|
||||
import ai.koog.prompt.llm.LLMProvider
|
||||
import ai.koog.prompt.llm.LLModel
|
||||
import ai.koog.prompt.message.LLMChoice
|
||||
import ai.koog.prompt.message.Message
|
||||
import ai.koog.prompt.message.ResponseMetaInfo
|
||||
import ai.koog.prompt.params.LLMParams
|
||||
import ai.koog.prompt.streaming.StreamFrameFlowBuilder
|
||||
import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.*
|
||||
import kotlinx.datetime.Clock
|
||||
import java.net.URI
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Configuration settings for connecting to the CustomOpenAI API.
|
||||
*
|
||||
* @property baseUrl The base URL of the CustomOpenAI API. Default is "https://CustomOpenAI.ai/api/v1".
|
||||
* @property timeoutConfig Configuration for connection timeouts including request, connection, and socket timeouts.
|
||||
*/
|
||||
public class CustomOpenAIClientSettings(
|
||||
baseUrl: String,
|
||||
chatCompletionsPath: String,
|
||||
timeoutConfig: ConnectionTimeoutConfig = ConnectionTimeoutConfig(
|
||||
requestTimeoutMillis = 120_000,
|
||||
socketTimeoutMillis = 120_000
|
||||
)
|
||||
) : OpenAIBaseSettings(baseUrl, chatCompletionsPath, timeoutConfig)
|
||||
|
||||
/**
|
||||
* Implementation of [LLMClient] for CustomOpenAI API.
|
||||
* CustomOpenAI is an API that routes requests to multiple LLM providers.
|
||||
*
|
||||
* @param apiKey The API key for the CustomOpenAI API
|
||||
* @param settings The base URL and timeouts for the CustomOpenAI API, defaults to "https://CustomOpenAI.ai" and 900s
|
||||
* @param clock Clock instance used for tracking response metadata timestamps.
|
||||
*/
|
||||
public class CustomOpenAILLMClient(
|
||||
apiKey: String,
|
||||
private val settings: CustomOpenAIClientSettings,
|
||||
private val state: CustomServiceChatCompletionSettingsState,
|
||||
baseClient: HttpClient = HttpClient(),
|
||||
clock: Clock = Clock.System
|
||||
) : AbstractOpenAILLMClient<CustomOpenAIChatCompletionResponse, CustomOpenAIChatCompletionStreamResponse>(
|
||||
apiKey,
|
||||
settings,
|
||||
baseClient,
|
||||
clock,
|
||||
staticLogger,
|
||||
OpenAICompatibleToolDescriptorSchemaGenerator()
|
||||
) {
|
||||
public data object CustomOpenAI : LLMProvider("custom-openai", "Custom OpenAI")
|
||||
|
||||
|
||||
companion object {
|
||||
private val staticLogger = KotlinLogging.logger { }
|
||||
|
||||
init {
|
||||
registerOpenAIJsonSchemaGenerators(CustomOpenAI)
|
||||
}
|
||||
|
||||
fun fromSettingsState(
|
||||
apiKey: String,
|
||||
state: CustomServiceChatCompletionSettingsState
|
||||
): CustomOpenAILLMClient {
|
||||
val stateUrl = state.url ?: throw RuntimeException("Url not set")
|
||||
val uri = URI.create(stateUrl)
|
||||
val settings = CustomOpenAIClientSettings(
|
||||
baseUrl = stateUrl.substring(0, stateUrl.lastIndexOf(uri.host) + (uri.host.length)),
|
||||
chatCompletionsPath = uri.path,
|
||||
)
|
||||
return CustomOpenAILLMClient(apiKey, settings, state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun llmProvider(): LLMProvider = CustomOpenAI
|
||||
|
||||
override fun serializeProviderChatRequest(
|
||||
messages: List<OpenAIMessage>,
|
||||
model: LLModel,
|
||||
tools: List<OpenAITool>?,
|
||||
toolChoice: OpenAIToolChoice?,
|
||||
params: LLMParams,
|
||||
stream: Boolean
|
||||
): String {
|
||||
val customParams = params.toCustomOpenAIParams(state)
|
||||
val responseFormat = createResponseFormat(params.schema, model)
|
||||
|
||||
val request = CustomOpenAIChatCompletionRequest(
|
||||
messages = messages,
|
||||
model = model.id,
|
||||
stream = stream,
|
||||
temperature = customParams.temperature,
|
||||
tools = tools,
|
||||
toolChoice = customParams.toolChoice?.toOpenAIToolChoice(),
|
||||
topP = customParams.topP,
|
||||
topLogprobs = customParams.topLogprobs,
|
||||
maxTokens = customParams.maxTokens,
|
||||
frequencyPenalty = customParams.frequencyPenalty,
|
||||
presencePenalty = customParams.presencePenalty,
|
||||
responseFormat = responseFormat,
|
||||
stop = customParams.stop,
|
||||
logprobs = customParams.logprobs,
|
||||
topK = customParams.topK,
|
||||
repetitionPenalty = customParams.repetitionPenalty,
|
||||
minP = customParams.minP,
|
||||
topA = customParams.topA,
|
||||
prediction = customParams.speculation?.let { OpenAIStaticContent(Content.Text(it)) },
|
||||
transforms = customParams.transforms,
|
||||
models = customParams.models,
|
||||
route = customParams.route,
|
||||
user = customParams.user,
|
||||
additionalProperties = customParams.additionalProperties,
|
||||
)
|
||||
|
||||
return json.encodeToString(CustomOpenAIChatCompletionRequestSerializer, request)
|
||||
}
|
||||
|
||||
override fun processProviderChatResponse(response: CustomOpenAIChatCompletionResponse): List<LLMChoice> {
|
||||
require(response.choices.isNotEmpty()) { "Empty choices in response" }
|
||||
return response.choices.map {
|
||||
it.message.toResponses(it.finishReason, createMetaInfo(response.usage))
|
||||
}
|
||||
}
|
||||
|
||||
override fun decodeStreamingResponse(data: String): CustomOpenAIChatCompletionStreamResponse =
|
||||
json.decodeFromString(data)
|
||||
|
||||
override fun decodeResponse(data: String): CustomOpenAIChatCompletionResponse =
|
||||
json.decodeFromString(data)
|
||||
|
||||
override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: CustomOpenAIChatCompletionStreamResponse) {
|
||||
chunk.choices.firstOrNull()?.let { choice ->
|
||||
choice.delta.content?.let { emitAppend(it) }
|
||||
choice.delta.toolCalls?.forEachIndexed { index, openAIToolCall ->
|
||||
val id = openAIToolCall.id
|
||||
val name = openAIToolCall.function.name
|
||||
val arguments = openAIToolCall.function.arguments
|
||||
upsertToolCall(index, id, name, arguments)
|
||||
}
|
||||
choice.finishReason?.let { emitEnd(it, createMetaInfo(chunk.usage)) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun moderate(prompt: Prompt, model: LLModel): ModerationResult {
|
||||
logger.warn { "Moderation is not supported by CustomOpenAI API" }
|
||||
throw UnsupportedOperationException("Moderation is not supported by CustomOpenAI API.")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun OpenAIMessage.toResponses(
|
||||
finishReason: String?,
|
||||
metaInfo: ResponseMetaInfo
|
||||
): List<Message.Response> {
|
||||
return when {
|
||||
this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> {
|
||||
val result = mutableListOf<Message.Response>()
|
||||
if (this.content != null) {
|
||||
result.add(
|
||||
Message.Assistant(
|
||||
content = this.content!!.text(),
|
||||
finishReason = finishReason,
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
this.toolCalls!!.forEach { toolCall ->
|
||||
result.add(
|
||||
Message.Tool.Call(
|
||||
id = toolCall.id,
|
||||
tool = toolCall.function.name,
|
||||
/*
|
||||
If the tool has no arguments, OpenRouter puts an empty string in the arguments instead of an empty object
|
||||
But we always expect arguments to be a JSON object. Fixing this.
|
||||
*/
|
||||
content = toolCall.function.arguments
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: "{}",
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
this is OpenAIMessage.Assistant && this.reasoningContent != null && this.content != null -> listOf(
|
||||
Message.Reasoning(
|
||||
content = this.reasoningContent!!,
|
||||
metaInfo = metaInfo
|
||||
),
|
||||
Message.Assistant(
|
||||
content = this.content!!.text(),
|
||||
finishReason = finishReason,
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
|
||||
this.content != null -> listOf(
|
||||
Message.Assistant(
|
||||
content = this.content!!.text(),
|
||||
finishReason = finishReason,
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
|
||||
else -> {
|
||||
val exception = LLMClientException(
|
||||
clientName,
|
||||
"Unexpected response: no tool calls and no content"
|
||||
)
|
||||
logger.error(exception) { exception.message }
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
package ee.carlrobert.codegpt.agent.clients
|
||||
|
||||
import ai.koog.prompt.params.LLMParams
|
||||
import ai.koog.prompt.params.LLMParams.Schema
|
||||
import ai.koog.prompt.params.LLMParams.ToolChoice
|
||||
import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
internal fun LLMParams.toCustomOpenAIParams(state: CustomServiceChatCompletionSettingsState): CustomOpenAIParams {
|
||||
if (this is CustomOpenAIParams) return this
|
||||
return CustomOpenAIParams(
|
||||
temperature = state.body["temperature"] as? Double ?: temperature,
|
||||
maxTokens = state.body["maxTokens"] as? Int ?: maxTokens,
|
||||
numberOfChoices = state.body["numberOfChoices"] as? Int ?: numberOfChoices,
|
||||
speculation = state.body["speculation"] as? String ?: speculation,
|
||||
schema = state.body["schema"] as? Schema ?: schema,
|
||||
toolChoice = state.body["toolChoice"] as? ToolChoice ?: toolChoice,
|
||||
user = state.body["user"] as? String ?: user,
|
||||
additionalProperties = state.body["additionalProperties"] as? Map<String, JsonElement>
|
||||
?: additionalProperties,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomOpenAI chat-completions parameters layered on top of [LLMParams].
|
||||
*
|
||||
* @property temperature Sampling temperature in [0.0, 2.0]. Higher ⇒ more random;
|
||||
* lower ⇒ more deterministic. Adjust this **or** [topP], not both.
|
||||
* @property maxTokens Maximum number of tokens the model may generate for this response.
|
||||
* @property numberOfChoices Number of completions to generate for the prompt (cost scales with N).
|
||||
* @property speculation Provider-specific control for speculative decoding / draft acceleration.
|
||||
* @property schema JSON Schema to constrain model output (validated when supported).
|
||||
* @property toolChoice Controls if/which tool must be called (`none`/`auto`/`required`/specific).
|
||||
* @property user stable end-user identifier
|
||||
* @property includeThoughts Request inclusion of model “thoughts”/reasoning traces (model-dependent).
|
||||
* @property thinkingBudget Soft cap on tokens spent on internal reasoning (reasoning models).
|
||||
* @property additionalProperties Additional properties that can be used to store custom parameters.
|
||||
* @property frequencyPenalty Number in [-2.0, 2.0]—penalizes frequent tokens to reduce repetition.
|
||||
* @property presencePenalty Number in [-2.0, 2.0]—encourages introduction of new tokens/topics.
|
||||
* @property logprobs Whether to include log-probabilities for output tokens.
|
||||
* @property stop Stop sequences (0–4 items); generation halts before any of these.
|
||||
* @property topLogprobs Number of top alternatives per position (0–20). Requires [logprobs] = true.
|
||||
* @property topP Nucleus sampling in (0.0, 1.0]; use **instead of** [temperature].
|
||||
* @property topK Number of top tokens to consider when generating output (min 1).
|
||||
* @property repetitionPenalty Number in (0.0, 2.0] — penalizes token repetition.
|
||||
* @property minP Minimum cumulative probability for token inclusion in sampling.
|
||||
* @property topA Temperature scaling based on marginal probability gain.
|
||||
* @property transforms List of context transforms.
|
||||
* Defines how context is transformed when it exceeds the model's token limit.
|
||||
* Default is ["middle-out"] which truncates from the middle of the prompt.
|
||||
* Use empty list [] for no transformations.
|
||||
* @property models List of allowed models for this request.
|
||||
* @property route Request routing identifier.
|
||||
* @property provider Model provider preferences.
|
||||
*/
|
||||
public open class CustomOpenAIParams(
|
||||
temperature: Double? = null,
|
||||
maxTokens: Int? = null,
|
||||
numberOfChoices: Int? = null,
|
||||
speculation: String? = null,
|
||||
schema: Schema? = null,
|
||||
toolChoice: ToolChoice? = null,
|
||||
user: String? = null,
|
||||
additionalProperties: Map<String, JsonElement>? = null,
|
||||
public val frequencyPenalty: Double? = null,
|
||||
public val presencePenalty: Double? = null,
|
||||
public val logprobs: Boolean? = null,
|
||||
public val stop: List<String>? = null,
|
||||
public val topLogprobs: Int? = null,
|
||||
public val topP: Double? = null,
|
||||
public val topK: Int? = null,
|
||||
public val repetitionPenalty: Double? = null,
|
||||
public val minP: Double? = null,
|
||||
public val topA: Double? = null,
|
||||
public val transforms: List<String>? = null,
|
||||
public val models: List<String>? = null,
|
||||
public val route: String? = null,
|
||||
) : LLMParams(
|
||||
temperature, maxTokens, numberOfChoices,
|
||||
speculation, schema, toolChoice,
|
||||
user, additionalProperties
|
||||
) {
|
||||
init {
|
||||
require(topP == null || topP in 0.0..1.0) {
|
||||
"topP must be in (0.0, 1.0], but was $topP"
|
||||
}
|
||||
if (topLogprobs != null) {
|
||||
require(logprobs == true) {
|
||||
"`topLogprobs` requires `logprobs=true`."
|
||||
}
|
||||
require(topLogprobs in 0..20) {
|
||||
"topLogprobs must be in [0, 20], but was $topLogprobs"
|
||||
}
|
||||
}
|
||||
require(frequencyPenalty == null || frequencyPenalty in -2.0..2.0) {
|
||||
"frequencyPenalty must be in [-2.0, 2.0], but was $frequencyPenalty"
|
||||
}
|
||||
require(presencePenalty == null || presencePenalty in -2.0..2.0) {
|
||||
"presencePenalty must be in [-2.0, 2.0], but was $presencePenalty"
|
||||
}
|
||||
require(repetitionPenalty == null || repetitionPenalty in 0.0..2.0) {
|
||||
"repetitionPenalty must be in (0.0, 2.0], but was $repetitionPenalty"
|
||||
}
|
||||
require(topK == null || topK >= 1) {
|
||||
"topK must be in [1, Infinity], but was $topK"
|
||||
}
|
||||
require(minP == null || minP in 0.0..1.0) {
|
||||
"minP must be in [0.0, 1.0], but was $minP"
|
||||
}
|
||||
require(topA == null || topA in 0.0..1.0) {
|
||||
"topA must be in [0.0, 1.0], but was $topA"
|
||||
}
|
||||
|
||||
// --- Stop sequences ---
|
||||
if (stop != null) {
|
||||
require(stop.isNotEmpty()) { "stop must not be empty when provided." }
|
||||
require(stop.size <= 4) { "stop supports at most 4 sequences, but was ${stop.size}" }
|
||||
require(stop.all { it.isNotBlank() }) { "stop sequences must not be blank." }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of this instance with the ability to modify any of its properties.
|
||||
*/
|
||||
public fun copy(
|
||||
temperature: Double? = this.temperature,
|
||||
maxTokens: Int? = this.maxTokens,
|
||||
numberOfChoices: Int? = this.numberOfChoices,
|
||||
speculation: String? = this.speculation,
|
||||
schema: Schema? = this.schema,
|
||||
toolChoice: ToolChoice? = this.toolChoice,
|
||||
user: String? = this.user,
|
||||
additionalProperties: Map<String, JsonElement>? = this.additionalProperties,
|
||||
frequencyPenalty: Double? = this.frequencyPenalty,
|
||||
presencePenalty: Double? = this.presencePenalty,
|
||||
logprobs: Boolean? = this.logprobs,
|
||||
stop: List<String>? = this.stop,
|
||||
topLogprobs: Int? = this.topLogprobs,
|
||||
topP: Double? = this.topP,
|
||||
topK: Int? = this.topK,
|
||||
repetitionPenalty: Double? = this.repetitionPenalty,
|
||||
minP: Double? = this.minP,
|
||||
topA: Double? = this.topA,
|
||||
transforms: List<String>? = this.transforms,
|
||||
models: List<String>? = this.models,
|
||||
route: String? = this.route,
|
||||
): CustomOpenAIParams = CustomOpenAIParams(
|
||||
temperature = temperature,
|
||||
maxTokens = maxTokens,
|
||||
numberOfChoices = numberOfChoices,
|
||||
speculation = speculation,
|
||||
schema = schema,
|
||||
toolChoice = toolChoice,
|
||||
user = user,
|
||||
additionalProperties = additionalProperties,
|
||||
frequencyPenalty = frequencyPenalty,
|
||||
presencePenalty = presencePenalty,
|
||||
logprobs = logprobs,
|
||||
stop = stop,
|
||||
topLogprobs = topLogprobs,
|
||||
topP = topP,
|
||||
topK = topK,
|
||||
repetitionPenalty = repetitionPenalty,
|
||||
minP = minP,
|
||||
topA = topA,
|
||||
transforms = transforms,
|
||||
models = models,
|
||||
route = route,
|
||||
)
|
||||
|
||||
override fun equals(other: Any?): Boolean = when {
|
||||
this === other -> true
|
||||
other !is CustomOpenAIParams -> false
|
||||
else ->
|
||||
temperature == other.temperature &&
|
||||
maxTokens == other.maxTokens &&
|
||||
numberOfChoices == other.numberOfChoices &&
|
||||
speculation == other.speculation &&
|
||||
schema == other.schema &&
|
||||
toolChoice == other.toolChoice &&
|
||||
user == other.user &&
|
||||
additionalProperties == other.additionalProperties &&
|
||||
frequencyPenalty == other.frequencyPenalty &&
|
||||
presencePenalty == other.presencePenalty &&
|
||||
logprobs == other.logprobs &&
|
||||
stop == other.stop &&
|
||||
topLogprobs == other.topLogprobs &&
|
||||
topP == other.topP &&
|
||||
topK == other.topK &&
|
||||
repetitionPenalty == other.repetitionPenalty &&
|
||||
minP == other.minP &&
|
||||
topA == other.topA &&
|
||||
transforms == other.transforms &&
|
||||
models == other.models &&
|
||||
route == other.route
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = listOf(
|
||||
temperature, maxTokens, numberOfChoices,
|
||||
speculation, schema, toolChoice,
|
||||
user,
|
||||
additionalProperties, frequencyPenalty, presencePenalty,
|
||||
logprobs, stop, topLogprobs, topP,
|
||||
topK, repetitionPenalty, minP,
|
||||
topA, transforms, models, route,
|
||||
).fold(0) { acc, element ->
|
||||
31 * acc + (element?.hashCode() ?: 0)
|
||||
}
|
||||
|
||||
override fun toString(): String = buildString {
|
||||
append("CustomOpenAIParams(")
|
||||
append("temperature=$temperature")
|
||||
append(", maxTokens=$maxTokens")
|
||||
append(", numberOfChoices=$numberOfChoices")
|
||||
append(", speculation=$speculation")
|
||||
append(", schema=$schema")
|
||||
append(", toolChoice=$toolChoice")
|
||||
append(", user=$user")
|
||||
append(", additionalProperties=$additionalProperties")
|
||||
append(", frequencyPenalty=$frequencyPenalty")
|
||||
append(", presencePenalty=$presencePenalty")
|
||||
append(", logprobs=$logprobs")
|
||||
append(", stop=$stop")
|
||||
append(", topLogprobs=$topLogprobs")
|
||||
append(", topP=$topP")
|
||||
append(", topK=$topK")
|
||||
append(", repetitionPenalty=$repetitionPenalty")
|
||||
append(", minP=$minP")
|
||||
append(", topA=$topA")
|
||||
append(", transforms=$transforms")
|
||||
append(", models=$models")
|
||||
append(", route=$route")
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package ee.carlrobert.codegpt.agent.clients
|
||||
|
||||
import ai.koog.prompt.executor.clients.openai.base.models.*
|
||||
import ai.koog.prompt.executor.clients.serialization.AdditionalPropertiesFlatteningSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
internal class ProxyAIChatCompletionRequest(
|
||||
val messages: List<OpenAIMessage> = emptyList(),
|
||||
val prompt: String? = null,
|
||||
override val model: String? = null,
|
||||
override val stream: Boolean? = null,
|
||||
override val temperature: Double? = null,
|
||||
val tools: List<OpenAITool>? = null,
|
||||
val toolChoice: OpenAIToolChoice? = null,
|
||||
override val topP: Double? = null,
|
||||
override val topLogprobs: Int? = null,
|
||||
val maxTokens: Int? = null,
|
||||
val frequencyPenalty: Double? = null,
|
||||
val presencePenalty: Double? = null,
|
||||
val responseFormat: OpenAIResponseFormat? = null,
|
||||
val stop: List<String>? = null,
|
||||
val logprobs: Boolean? = null,
|
||||
val seed: Int? = null,
|
||||
val logitBias: Map<Int, Double>? = null,
|
||||
val user: String? = null,
|
||||
val additionalProperties: Map<String, JsonElement>? = null,
|
||||
) : OpenAIBaseLLMRequest
|
||||
|
||||
/**
|
||||
* Chat completion choice.
|
||||
*/
|
||||
@Serializable
|
||||
public class ProxyAIChoice(
|
||||
public val finishReason: String? = null,
|
||||
public val nativeFinishReason: String? = null,
|
||||
public val message: OpenAIMessage,
|
||||
public val error: ProxyAIErrorResponse? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Chat completion streaming choice.
|
||||
*/
|
||||
@Serializable
|
||||
public class ProxyAIStreamChoice(
|
||||
public val finishReason: String? = null,
|
||||
public val nativeFinishReason: String? = null,
|
||||
public val delta: ProxyAIStreamDelta,
|
||||
public val error: ProxyAIErrorResponse? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* @property content The contents of the chunk message.
|
||||
* @property role The role of the author of this message.
|
||||
* @property toolCalls The tool calls requested by the model.
|
||||
*/
|
||||
@Serializable
|
||||
public class ProxyAIStreamDelta(
|
||||
public val content: String? = null,
|
||||
public val role: String? = null,
|
||||
public val toolCalls: List<ProxyAIToolCall>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
public class ProxyAIToolCall(
|
||||
public val id: String? = "",
|
||||
public val function: ProxyAIFunction
|
||||
) {
|
||||
/** The type of the tool. Currently, only `function` is supported. */
|
||||
public val type: String = "function"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
public class ProxyAIFunction(
|
||||
public val name: String? = "",
|
||||
public val arguments: String? = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents an error response structure typically used for conveying error details to the clients.
|
||||
*/
|
||||
@Serializable
|
||||
public class ProxyAIErrorResponse(
|
||||
public val code: Int,
|
||||
public val message: String,
|
||||
public val metadata: Map<String, String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
public class ProxyAICredits(
|
||||
public val prompt: Long? = null,
|
||||
public val completion: Long? = null,
|
||||
public val total: Long? = null,
|
||||
public val remaining: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
public class ProxyAIUsage(
|
||||
@SerialName("prompt_tokens")
|
||||
public val promptTokens: Int? = null,
|
||||
@SerialName("completion_tokens")
|
||||
public val completionTokens: Int? = null,
|
||||
@SerialName("total_tokens")
|
||||
public val totalTokens: Int? = null,
|
||||
public val credits: ProxyAICredits? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* ProxyAI Chat Completion Response.
|
||||
*/
|
||||
@Serializable
|
||||
public class ProxyAIChatCompletionResponse(
|
||||
public val choices: List<ProxyAIChoice> = emptyList(),
|
||||
override val created: Long = 0L,
|
||||
override val id: String = "",
|
||||
override val model: String = "",
|
||||
public val systemFingerprint: String? = null,
|
||||
@SerialName("object")
|
||||
public val objectType: String = "chat.completion",
|
||||
public val usage: ProxyAIUsage? = null,
|
||||
) : OpenAIBaseLLMResponse
|
||||
|
||||
/**
|
||||
* ProxyAI Chat Completion Streaming Response.
|
||||
*/
|
||||
@Serializable
|
||||
public class ProxyAIChatCompletionStreamResponse(
|
||||
public val choices: List<ProxyAIStreamChoice> = emptyList(),
|
||||
override val created: Long = 0L,
|
||||
override val id: String = "",
|
||||
override val model: String = "",
|
||||
public val systemFingerprint: String? = null,
|
||||
@SerialName("object")
|
||||
public val objectType: String = "chat.completion.chunk",
|
||||
public val usage: ProxyAIUsage? = null,
|
||||
) : OpenAIBaseLLMStreamResponse
|
||||
|
||||
internal object ProxyAIChatCompletionRequestSerializer :
|
||||
AdditionalPropertiesFlatteningSerializer<ProxyAIChatCompletionRequest>(
|
||||
ProxyAIChatCompletionRequest.serializer()
|
||||
)
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
package ee.carlrobert.codegpt.agent.clients
|
||||
|
||||
import ai.koog.prompt.dsl.ModerationResult
|
||||
import ai.koog.prompt.dsl.Prompt
|
||||
import ai.koog.prompt.executor.clients.ConnectionTimeoutConfig
|
||||
import ai.koog.prompt.executor.clients.LLMClient
|
||||
import ai.koog.prompt.executor.clients.LLMClientException
|
||||
import ai.koog.prompt.executor.clients.openai.base.AbstractOpenAILLMClient
|
||||
import ai.koog.prompt.executor.clients.openai.base.OpenAIBaseSettings
|
||||
import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator
|
||||
import ai.koog.prompt.executor.clients.openai.base.models.*
|
||||
import ai.koog.prompt.llm.LLMProvider
|
||||
import ai.koog.prompt.llm.LLModel
|
||||
import ai.koog.prompt.message.LLMChoice
|
||||
import ai.koog.prompt.message.Message
|
||||
import ai.koog.prompt.message.ResponseMetaInfo
|
||||
import ai.koog.prompt.params.LLMParams
|
||||
import ai.koog.prompt.streaming.StreamFrameFlowBuilder
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Configuration settings for connecting to the ProxyAI API.
|
||||
*/
|
||||
public class ProxyAIClientSettings(
|
||||
baseUrl: String = DEFAULT_BASE_URL,
|
||||
chatCompletionsPath: String = DEFAULT_CHAT_COMPLETIONS_PATH,
|
||||
timeoutConfig: ConnectionTimeoutConfig = ConnectionTimeoutConfig(
|
||||
requestTimeoutMillis = 120_000,
|
||||
socketTimeoutMillis = 120_000
|
||||
)
|
||||
) : OpenAIBaseSettings(baseUrl, chatCompletionsPath, timeoutConfig) {
|
||||
public companion object {
|
||||
public const val DEFAULT_BASE_URL: String = "https://codegpt-api.carlrobert.ee"
|
||||
public const val DEFAULT_CHAT_COMPLETIONS_PATH: String = "/v2/chat/completions"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of [LLMClient] for ProxyAI API.
|
||||
*/
|
||||
public class ProxyAILLMClient(
|
||||
apiKey: String,
|
||||
private val settings: ProxyAIClientSettings = ProxyAIClientSettings(),
|
||||
baseClient: HttpClient = HttpClient(),
|
||||
clock: Clock = Clock.System
|
||||
) : AbstractOpenAILLMClient<ProxyAIChatCompletionResponse, ProxyAIChatCompletionStreamResponse>(
|
||||
apiKey,
|
||||
settings,
|
||||
baseClient,
|
||||
clock,
|
||||
staticLogger,
|
||||
OpenAICompatibleToolDescriptorSchemaGenerator()
|
||||
) {
|
||||
public data object ProxyAI : LLMProvider("proxyai", "ProxyAI")
|
||||
|
||||
companion object {
|
||||
private val staticLogger = KotlinLogging.logger { }
|
||||
|
||||
init {
|
||||
registerOpenAIJsonSchemaGenerators(ProxyAI)
|
||||
}
|
||||
}
|
||||
|
||||
override fun llmProvider(): LLMProvider = ProxyAI
|
||||
|
||||
override fun serializeProviderChatRequest(
|
||||
messages: List<OpenAIMessage>,
|
||||
model: LLModel,
|
||||
tools: List<OpenAITool>?,
|
||||
toolChoice: OpenAIToolChoice?,
|
||||
params: LLMParams,
|
||||
stream: Boolean
|
||||
): String {
|
||||
val proxyParams = params.toProxyAIParams()
|
||||
val responseFormat = createResponseFormat(params.schema, model)
|
||||
val request = ProxyAIChatCompletionRequest(
|
||||
messages = messages,
|
||||
model = model.id,
|
||||
stream = stream,
|
||||
temperature = proxyParams.temperature,
|
||||
tools = tools,
|
||||
toolChoice = proxyParams.toolChoice?.toOpenAIToolChoice(),
|
||||
topP = proxyParams.topP,
|
||||
topLogprobs = proxyParams.topLogprobs,
|
||||
maxTokens = proxyParams.maxTokens,
|
||||
frequencyPenalty = proxyParams.frequencyPenalty,
|
||||
presencePenalty = proxyParams.presencePenalty,
|
||||
responseFormat = responseFormat,
|
||||
stop = proxyParams.stop,
|
||||
logprobs = proxyParams.logprobs,
|
||||
user = proxyParams.user,
|
||||
additionalProperties = proxyParams.additionalProperties,
|
||||
)
|
||||
|
||||
return json.encodeToString(ProxyAIChatCompletionRequestSerializer, request)
|
||||
}
|
||||
|
||||
override fun processProviderChatResponse(response: ProxyAIChatCompletionResponse): List<LLMChoice> {
|
||||
if (response.choices.isEmpty()) {
|
||||
val errorMsg = "Empty choices in response. Response may contain an error."
|
||||
logger.error { errorMsg }
|
||||
throw LLMClientException(clientName, errorMsg)
|
||||
}
|
||||
|
||||
// Check for errors in choices
|
||||
response.choices.forEach { choice ->
|
||||
choice.error?.let { error ->
|
||||
val errorMsg = "ProxyAI API error (code: ${error.code}): ${error.message}"
|
||||
logger.error { errorMsg }
|
||||
throw LLMClientException(clientName, errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return response.choices.map {
|
||||
it.message.toResponses(it.finishReason, createProxyMetaInfo(response.usage))
|
||||
}
|
||||
}
|
||||
|
||||
override fun decodeStreamingResponse(data: String): ProxyAIChatCompletionStreamResponse =
|
||||
json.decodeFromString(data)
|
||||
|
||||
override fun decodeResponse(data: String): ProxyAIChatCompletionResponse =
|
||||
json.decodeFromString(data)
|
||||
|
||||
override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: ProxyAIChatCompletionStreamResponse) {
|
||||
chunk.choices.firstOrNull()?.let { choice ->
|
||||
choice.delta.content?.let { emitAppend(it) }
|
||||
choice.delta.toolCalls?.forEachIndexed { index, toolCall ->
|
||||
val id = toolCall.id
|
||||
val name = toolCall.function.name
|
||||
val arguments = toolCall.function.arguments
|
||||
upsertToolCall(index, id, name, arguments)
|
||||
}
|
||||
choice.finishReason?.let { emitEnd(it, createProxyMetaInfo(chunk.usage)) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun moderate(prompt: Prompt, model: LLModel): ModerationResult {
|
||||
logger.warn { "Moderation is not supported by ProxyAI API" }
|
||||
throw UnsupportedOperationException("Moderation is not supported by ProxyAI API.")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun OpenAIMessage.toResponses(
|
||||
finishReason: String?,
|
||||
metaInfo: ResponseMetaInfo
|
||||
): List<Message.Response> {
|
||||
return when {
|
||||
this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> {
|
||||
val result = mutableListOf<Message.Response>()
|
||||
if (this.content != null) {
|
||||
result.add(
|
||||
Message.Assistant(
|
||||
content = this.content!!.text(),
|
||||
finishReason = finishReason,
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
this.toolCalls!!.forEach { toolCall ->
|
||||
result.add(
|
||||
Message.Tool.Call(
|
||||
id = toolCall.id,
|
||||
tool = toolCall.function.name,
|
||||
content = toolCall.function.arguments
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: "{}",
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
this is OpenAIMessage.Assistant && this.reasoningContent != null && this.content != null -> listOf(
|
||||
Message.Reasoning(
|
||||
content = this.reasoningContent!!,
|
||||
metaInfo = metaInfo
|
||||
),
|
||||
Message.Assistant(
|
||||
content = this.content!!.text(),
|
||||
finishReason = finishReason,
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
|
||||
this.content != null -> listOf(
|
||||
Message.Assistant(
|
||||
content = this.content!!.text(),
|
||||
finishReason = finishReason,
|
||||
metaInfo = metaInfo
|
||||
)
|
||||
)
|
||||
|
||||
else -> {
|
||||
val exception = LLMClientException(
|
||||
clientName,
|
||||
"Unexpected response: no tool calls and no content"
|
||||
)
|
||||
logger.error(exception) { exception.message }
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createProxyMetaInfo(usage: ProxyAIUsage?): ResponseMetaInfo {
|
||||
val metadata = usage?.credits?.toMetadata()
|
||||
return ResponseMetaInfo.create(
|
||||
clock = clock,
|
||||
totalTokensCount = usage?.totalTokens,
|
||||
inputTokensCount = usage?.promptTokens,
|
||||
outputTokensCount = usage?.completionTokens,
|
||||
metadata = metadata
|
||||
)
|
||||
}
|
||||
|
||||
private fun ProxyAICredits.toMetadata(): JsonObject? {
|
||||
val creditsObject = buildJsonObject {
|
||||
putIfNotNull("prompt", prompt)
|
||||
putIfNotNull("completion", completion)
|
||||
putIfNotNull("total", total)
|
||||
putIfNotNull("remaining", remaining)
|
||||
}
|
||||
if (creditsObject.isEmpty()) return null
|
||||
return buildJsonObject {
|
||||
put("credits", creditsObject)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObjectBuilder.putIfNotNull(key: String, value: Long?) {
|
||||
if (value != null) {
|
||||
put(key, JsonPrimitive(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package ee.carlrobert.codegpt.agent.clients
|
||||
|
||||
import ai.koog.prompt.params.LLMParams
|
||||
import ai.koog.prompt.params.LLMParams.Schema
|
||||
import ai.koog.prompt.params.LLMParams.ToolChoice
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
internal fun LLMParams.toProxyAIParams(): ProxyAIParams {
|
||||
if (this is ProxyAIParams) return this
|
||||
return ProxyAIParams(
|
||||
temperature = temperature,
|
||||
maxTokens = maxTokens,
|
||||
numberOfChoices = numberOfChoices,
|
||||
speculation = speculation,
|
||||
schema = schema,
|
||||
toolChoice = toolChoice,
|
||||
user = user,
|
||||
additionalProperties = additionalProperties,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ProxyAI chat-completions parameters layered on top of [LLMParams].
|
||||
*/
|
||||
public open class ProxyAIParams(
|
||||
temperature: Double? = null,
|
||||
maxTokens: Int? = null,
|
||||
numberOfChoices: Int? = null,
|
||||
speculation: String? = null,
|
||||
schema: Schema? = null,
|
||||
toolChoice: ToolChoice? = null,
|
||||
user: String? = null,
|
||||
additionalProperties: Map<String, JsonElement>? = null,
|
||||
public val frequencyPenalty: Double? = null,
|
||||
public val presencePenalty: Double? = null,
|
||||
public val logprobs: Boolean? = null,
|
||||
public val stop: List<String>? = null,
|
||||
public val topLogprobs: Int? = null,
|
||||
public val topP: Double? = null,
|
||||
) : LLMParams(
|
||||
temperature, maxTokens, numberOfChoices,
|
||||
speculation, schema, toolChoice,
|
||||
user, additionalProperties
|
||||
) {
|
||||
init {
|
||||
require(topP == null || topP in 0.0..1.0) {
|
||||
"topP must be in (0.0, 1.0], but was $topP"
|
||||
}
|
||||
if (topLogprobs != null) {
|
||||
require(logprobs == true) {
|
||||
"`topLogprobs` requires `logprobs=true`."
|
||||
}
|
||||
require(topLogprobs in 0..20) {
|
||||
"topLogprobs must be in [0, 20], but was $topLogprobs"
|
||||
}
|
||||
}
|
||||
require(frequencyPenalty == null || frequencyPenalty in -2.0..2.0) {
|
||||
"frequencyPenalty must be in [-2.0, 2.0], but was $frequencyPenalty"
|
||||
}
|
||||
require(presencePenalty == null || presencePenalty in -2.0..2.0) {
|
||||
"presencePenalty must be in [-2.0, 2.0], but was $presencePenalty"
|
||||
}
|
||||
|
||||
if (stop != null) {
|
||||
require(stop.isNotEmpty()) { "stop must not be empty when provided." }
|
||||
require(stop.size <= 4) { "stop supports at most 4 sequences, but was ${stop.size}" }
|
||||
require(stop.all { it.isNotBlank() }) { "stop sequences must not be blank." }
|
||||
}
|
||||
}
|
||||
|
||||
public fun copy(
|
||||
temperature: Double? = this.temperature,
|
||||
maxTokens: Int? = this.maxTokens,
|
||||
numberOfChoices: Int? = this.numberOfChoices,
|
||||
speculation: String? = this.speculation,
|
||||
schema: Schema? = this.schema,
|
||||
toolChoice: ToolChoice? = this.toolChoice,
|
||||
user: String? = this.user,
|
||||
additionalProperties: Map<String, JsonElement>? = this.additionalProperties,
|
||||
frequencyPenalty: Double? = this.frequencyPenalty,
|
||||
presencePenalty: Double? = this.presencePenalty,
|
||||
logprobs: Boolean? = this.logprobs,
|
||||
stop: List<String>? = this.stop,
|
||||
topLogprobs: Int? = this.topLogprobs,
|
||||
topP: Double? = this.topP,
|
||||
): ProxyAIParams = ProxyAIParams(
|
||||
temperature = temperature,
|
||||
maxTokens = maxTokens,
|
||||
numberOfChoices = numberOfChoices,
|
||||
speculation = speculation,
|
||||
schema = schema,
|
||||
toolChoice = toolChoice,
|
||||
user = user,
|
||||
additionalProperties = additionalProperties,
|
||||
frequencyPenalty = frequencyPenalty,
|
||||
presencePenalty = presencePenalty,
|
||||
logprobs = logprobs,
|
||||
stop = stop,
|
||||
topLogprobs = topLogprobs,
|
||||
topP = topP,
|
||||
)
|
||||
|
||||
override fun equals(other: Any?): Boolean = when {
|
||||
this === other -> true
|
||||
other !is ProxyAIParams -> false
|
||||
else ->
|
||||
temperature == other.temperature &&
|
||||
maxTokens == other.maxTokens &&
|
||||
numberOfChoices == other.numberOfChoices &&
|
||||
speculation == other.speculation &&
|
||||
schema == other.schema &&
|
||||
toolChoice == other.toolChoice &&
|
||||
user == other.user &&
|
||||
additionalProperties == other.additionalProperties &&
|
||||
frequencyPenalty == other.frequencyPenalty &&
|
||||
presencePenalty == other.presencePenalty &&
|
||||
logprobs == other.logprobs &&
|
||||
stop == other.stop &&
|
||||
topLogprobs == other.topLogprobs &&
|
||||
topP == other.topP
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = listOf(
|
||||
temperature, maxTokens, numberOfChoices,
|
||||
speculation, schema, toolChoice,
|
||||
user, additionalProperties,
|
||||
frequencyPenalty, presencePenalty,
|
||||
logprobs, stop, topLogprobs, topP,
|
||||
).fold(0) { acc, element ->
|
||||
31 * acc + (element?.hashCode() ?: 0)
|
||||
}
|
||||
|
||||
override fun toString(): String = buildString {
|
||||
append("ProxyAIParams(")
|
||||
append("temperature=$temperature")
|
||||
append(", maxTokens=$maxTokens")
|
||||
append(", numberOfChoices=$numberOfChoices")
|
||||
append(", speculation=$speculation")
|
||||
append(", schema=$schema")
|
||||
append(", toolChoice=$toolChoice")
|
||||
append(", user=$user")
|
||||
append(", additionalProperties=$additionalProperties")
|
||||
append(", frequencyPenalty=$frequencyPenalty")
|
||||
append(", presencePenalty=$presencePenalty")
|
||||
append(", logprobs=$logprobs")
|
||||
append(", stop=$stop")
|
||||
append(", topLogprobs=$topLogprobs")
|
||||
append(", topP=$topP")
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package ee.carlrobert.codegpt.agent.credits
|
||||
|
||||
import ai.koog.prompt.message.Message
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
data class CreditsSnapshot(
|
||||
val remaining: Long,
|
||||
val monthlyRemaining: Long,
|
||||
val total: Long,
|
||||
)
|
||||
|
||||
fun extractCreditsSnapshot(responses: List<Message.Response>): CreditsSnapshot? {
|
||||
val creditsObject = responses.asReversed().firstNotNullOfOrNull { response ->
|
||||
response.metaInfo.metadata?.get("credits")?.jsonObject
|
||||
} ?: return null
|
||||
val remaining = creditsObject.longOrNull("remaining") ?: return null
|
||||
val monthlyRemaining = creditsObject.longOrNull("monthly_remaining") ?: remaining
|
||||
val total = creditsObject.longOrNull("total") ?: 0
|
||||
return CreditsSnapshot(remaining, monthlyRemaining, total)
|
||||
}
|
||||
|
||||
private fun JsonObject.longOrNull(key: String): Long? {
|
||||
return this[key]?.jsonPrimitive?.longOrNull
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
package ee.carlrobert.codegpt.agent.strategy
|
||||
|
||||
import ai.koog.agents.core.agent.config.AIAgentConfig
|
||||
import ai.koog.agents.core.agent.entity.AIAgentGraphStrategy
|
||||
import ai.koog.agents.core.agent.session.AIAgentLLMWriteSession
|
||||
import ai.koog.agents.core.dsl.builder.AIAgentEdgeBuilderIntermediate
|
||||
import ai.koog.agents.core.dsl.builder.EdgeTransformationDslMarker
|
||||
import ai.koog.agents.core.dsl.builder.forwardTo
|
||||
import ai.koog.agents.core.dsl.builder.strategy
|
||||
import ai.koog.agents.core.dsl.extension.nodeExecuteMultipleTools
|
||||
import ai.koog.agents.core.dsl.extension.onIsInstance
|
||||
import ai.koog.agents.core.dsl.extension.onMultipleToolCalls
|
||||
import ai.koog.agents.core.environment.ReceivedToolResult
|
||||
import ai.koog.agents.core.environment.result
|
||||
import ai.koog.agents.features.tokenizer.feature.tokenizer
|
||||
import ai.koog.agents.snapshot.feature.AgentCheckpointData
|
||||
import ai.koog.prompt.dsl.Prompt
|
||||
import ai.koog.prompt.executor.model.PromptExecutor
|
||||
import ai.koog.prompt.message.Message
|
||||
import ai.koog.prompt.message.RequestMetaInfo
|
||||
import ai.koog.prompt.streaming.StreamFrame
|
||||
import ai.koog.prompt.streaming.toMessageResponses
|
||||
import ai.koog.prompt.tokenizer.PromptTokenizer
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import ee.carlrobert.codegpt.ReferencedFile
|
||||
import ee.carlrobert.codegpt.agent.AgentEvents
|
||||
import ee.carlrobert.codegpt.agent.MessageWithContext
|
||||
import ee.carlrobert.codegpt.agent.credits.extractCreditsSnapshot
|
||||
import ee.carlrobert.codegpt.completions.CompletionRequestUtil
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
|
||||
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.*
|
||||
import ee.carlrobert.codegpt.conversations.message.Message as ChatMessage
|
||||
|
||||
internal interface AgentRunStrategyProvider {
|
||||
fun build(
|
||||
project: Project,
|
||||
executor: PromptExecutor,
|
||||
projectInstructions: String?,
|
||||
previousCheckpoint: AgentCheckpointData?,
|
||||
pendingMessageQueue: ArrayDeque<MessageWithContext>,
|
||||
events: AgentEvents,
|
||||
sessionId: String,
|
||||
provider: ServiceType,
|
||||
stream: Boolean
|
||||
): AIAgentGraphStrategy<MessageWithContext, String>
|
||||
}
|
||||
|
||||
internal class SingleRunStrategyProvider : AgentRunStrategyProvider {
|
||||
override fun build(
|
||||
project: Project,
|
||||
executor: PromptExecutor,
|
||||
projectInstructions: String?,
|
||||
previousCheckpoint: AgentCheckpointData?,
|
||||
pendingMessageQueue: ArrayDeque<MessageWithContext>,
|
||||
events: AgentEvents,
|
||||
sessionId: String,
|
||||
provider: ServiceType,
|
||||
stream: Boolean
|
||||
): AIAgentGraphStrategy<MessageWithContext, String> = strategy("single_run") {
|
||||
val nodeCallLLM by node<MessageWithContext, List<Message.Response>> { message ->
|
||||
llm.writeSession {
|
||||
if (previousCheckpoint == null) {
|
||||
projectInstructions?.let {
|
||||
appendPrompt {
|
||||
message(
|
||||
Message.User(
|
||||
it,
|
||||
RequestMetaInfo(clock.now(), buildJsonObject {
|
||||
put("cache_control", buildJsonObject {
|
||||
put("type", JsonPrimitive("ephemeral"))
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
appendPrompt {
|
||||
user(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val filteredMessageHistory = previousCheckpoint.messageHistory
|
||||
.filter { it !is Message.System }
|
||||
.toMutableList()
|
||||
.apply {
|
||||
if (lastOrNull() is Message.Tool.Call) {
|
||||
removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
for (previousMessage in filteredMessageHistory) {
|
||||
if (previousMessage !is Message.System) {
|
||||
appendPrompt { message(previousMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.tags.isNotEmpty()) {
|
||||
val context = buildTagContext(project, message.tags)
|
||||
if (context.isNotBlank()) {
|
||||
appendPrompt { user(context) }
|
||||
}
|
||||
}
|
||||
|
||||
appendPrompt { user(message.text) }
|
||||
|
||||
requestAndPublish(
|
||||
executor,
|
||||
config,
|
||||
tokenizer(),
|
||||
events,
|
||||
sessionId,
|
||||
provider,
|
||||
stream
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val nodeExecuteTool by nodeExecuteMultipleTools(parallelTools = true)
|
||||
|
||||
val nodeSendToolResult by node<List<ReceivedToolResult>, List<Message.Response>> { results ->
|
||||
llm.writeSession {
|
||||
appendPrompt {
|
||||
results.forEach { tool { result(it) } }
|
||||
}
|
||||
|
||||
val messages = prompt.messages
|
||||
val toolCallMessages = messages.count { it is Message.Tool.Call }
|
||||
val todoWriteToolUsed = messages.any { msg ->
|
||||
msg is Message.Tool.Call && msg.tool == "TodoWrite"
|
||||
}
|
||||
|
||||
if (toolCallMessages >= 2 && !todoWriteToolUsed) {
|
||||
appendPrompt {
|
||||
user("It seems that you haven't created a todo list yet. If the task on hand requires multiple steps then create a todo list to track your changes.")
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingMessageQueue.isNotEmpty()) {
|
||||
pendingMessageQueue.forEach {
|
||||
appendPrompt { user(it.text) }
|
||||
}
|
||||
events.onQueuedMessagesResolved()
|
||||
pendingMessageQueue.clear()
|
||||
}
|
||||
|
||||
requestAndPublish(
|
||||
executor,
|
||||
config,
|
||||
tokenizer(),
|
||||
events,
|
||||
sessionId,
|
||||
provider,
|
||||
stream
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
edge(nodeStart forwardTo nodeCallLLM)
|
||||
edge(nodeCallLLM forwardTo nodeExecuteTool onMultipleToolCalls { true })
|
||||
edge(nodeCallLLM forwardTo nodeFinish onSingleAssistantResponse { true })
|
||||
edge(nodeExecuteTool forwardTo nodeSendToolResult)
|
||||
edge(nodeSendToolResult forwardTo nodeFinish onSingleAssistantResponse { true })
|
||||
edge(nodeSendToolResult forwardTo nodeExecuteTool onMultipleToolCalls { true })
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun AIAgentLLMWriteSession.requestResponses(
|
||||
stream: Boolean,
|
||||
executor: PromptExecutor,
|
||||
config: AIAgentConfig,
|
||||
appendResponse: (Message.Response) -> Unit
|
||||
): List<Message.Response> {
|
||||
val responses = if (stream) {
|
||||
val streamFrames = mutableListOf<StreamFrame>()
|
||||
val frames = requestLLMStreaming()
|
||||
frames.collect { streamFrames.add(it) }
|
||||
streamFrames.toMessageResponses()
|
||||
} else {
|
||||
val preparedPrompt = config.missingToolsConversionStrategy.convertPrompt(prompt, tools)
|
||||
executor.execute(preparedPrompt, model, tools)
|
||||
}
|
||||
val appendableResponses = appendableResponses(responses)
|
||||
appendableResponses.forEach(appendResponse)
|
||||
return if (stream) responses else appendableResponses
|
||||
}
|
||||
|
||||
private suspend fun AIAgentLLMWriteSession.requestAndPublish(
|
||||
executor: PromptExecutor,
|
||||
config: AIAgentConfig,
|
||||
tokenizer: PromptTokenizer,
|
||||
events: AgentEvents,
|
||||
sessionId: String,
|
||||
provider: ServiceType,
|
||||
stream: Boolean
|
||||
): List<Message.Response> {
|
||||
val response = requestResponses(stream, executor, config) { appendPrompt { message(it) } }
|
||||
publishUsageAndCredits(prompt, tokenizer, events, sessionId, provider, response)
|
||||
return response
|
||||
}
|
||||
|
||||
private fun appendableResponses(responses: List<Message.Response>): List<Message.Response> {
|
||||
return responses
|
||||
.sortedBy { if (it is Message.Assistant) 0 else 1 }
|
||||
.filter { it !is Message.Reasoning }
|
||||
}
|
||||
|
||||
private fun publishUsageAndCredits(
|
||||
prompt: Prompt,
|
||||
tokenizer: PromptTokenizer,
|
||||
events: AgentEvents,
|
||||
sessionId: String,
|
||||
provider: ServiceType,
|
||||
responses: List<Message.Response>,
|
||||
) {
|
||||
events.onTokenUsageAvailable(promptTokenCount(prompt, tokenizer).toLong())
|
||||
publishCreditsIfAvailable(sessionId, provider, responses, events)
|
||||
}
|
||||
|
||||
private fun promptTokenCount(prompt: Prompt, tokenizer: PromptTokenizer): Int {
|
||||
return if (prompt.latestTokenUsage == 0) {
|
||||
tokenizer.tokenCountFor(prompt)
|
||||
} else {
|
||||
prompt.latestTokenUsage
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTagContext(project: Project, tags: List<TagDetails>): String {
|
||||
val contextBuilder = StringBuilder()
|
||||
val tempMessage = ChatMessage("")
|
||||
tags.forEach { tagDetails ->
|
||||
val processor = TagProcessorFactory.getProcessor(project, tagDetails)
|
||||
processor.process(tempMessage, contextBuilder)
|
||||
}
|
||||
|
||||
val fileContext = buildReferencedFilesContext(tempMessage.referencedFilePaths.orEmpty())
|
||||
val tagText = contextBuilder.toString().trim()
|
||||
return buildString {
|
||||
var hasContent = false
|
||||
if (tagText.isNotBlank()) {
|
||||
append(tagText)
|
||||
hasContent = true
|
||||
}
|
||||
if (fileContext.isNotBlank()) {
|
||||
if (hasContent) {
|
||||
append("\n\n")
|
||||
}
|
||||
append(fileContext)
|
||||
}
|
||||
}.trim()
|
||||
}
|
||||
|
||||
private fun buildReferencedFilesContext(referencedFilePaths: List<String>): String {
|
||||
if (referencedFilePaths.isEmpty()) return ""
|
||||
val vfs = LocalFileSystem.getInstance()
|
||||
val referencedFiles = referencedFilePaths
|
||||
.asSequence()
|
||||
.mapNotNull { path -> vfs.findFileByPath(path) }
|
||||
.distinctBy { it.path }
|
||||
.map { ReferencedFile.from(it) }
|
||||
.toList()
|
||||
if (referencedFiles.isEmpty()) return ""
|
||||
return CompletionRequestUtil.buildContextBlock(referencedFiles, null)
|
||||
}
|
||||
|
||||
private fun publishCreditsIfAvailable(
|
||||
sessionId: String,
|
||||
provider: ServiceType,
|
||||
responses: List<Message.Response>,
|
||||
events: AgentEvents,
|
||||
) {
|
||||
if (provider != ServiceType.PROXYAI) return
|
||||
val credits = extractCreditsSnapshot(responses) ?: return
|
||||
val event = AgentCreditsEvent(
|
||||
sessionId = sessionId,
|
||||
remaining = credits.remaining,
|
||||
monthlyRemaining = credits.monthlyRemaining,
|
||||
consumed = credits.total
|
||||
)
|
||||
events.onCreditsAvailable(event)
|
||||
}
|
||||
|
||||
@EdgeTransformationDslMarker
|
||||
private infix fun <IncomingOutput, OutgoingInput> AIAgentEdgeBuilderIntermediate<IncomingOutput, List<Message.Response>, OutgoingInput>.onSingleAssistantResponse(
|
||||
block: suspend (Message.Response) -> Boolean
|
||||
): AIAgentEdgeBuilderIntermediate<IncomingOutput, String, OutgoingInput> {
|
||||
return onIsInstance(List::class)
|
||||
.transformed { response ->
|
||||
response.filter { item -> item is Message.Response && item !is Message.Reasoning }
|
||||
.filterIsInstance<Message.Response>()
|
||||
}
|
||||
.onCondition { it.size == 1 && it[0] is Message.Assistant }
|
||||
.onCondition { messages -> block(messages[0]) }
|
||||
.transformed { it[0].content }
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import ee.carlrobert.codegpt.agent.AgentEvents
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class AskUserQuestionTool(
|
||||
private val events: AgentEvents
|
||||
) : Tool<AskUserQuestionTool.Args, AskUserQuestionTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "AskUserQuestion",
|
||||
description = """
|
||||
Use this tool when you need to ask the user questions during execution. This allows you to:
|
||||
1. Gather user preferences or requirements
|
||||
2. Clarify ambiguous instructions
|
||||
3. Get decisions on implementation choices as you work
|
||||
4. Offer choices to the user about what direction to take.
|
||||
|
||||
Usage notes:
|
||||
- Users will always be able to select "Other" to provide custom text input
|
||||
- Use multiSelect: true to allow multiple answers to be selected for a question
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Option(
|
||||
@property:LLMDescription("The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.")
|
||||
val label: String,
|
||||
@property:LLMDescription("Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.")
|
||||
val description: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Question(
|
||||
@property:LLMDescription("The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: \"Which library should we use for date formatting?\" If multiSelect is true, phrase it accordingly, e.g. \"Which features do you want to enable?\"")
|
||||
val question: String,
|
||||
@property:LLMDescription("Very short label displayed as a chip/tag (max 12 chars). Examples: \"Auth method\", \"Library\", \"Approach\".")
|
||||
val header: String,
|
||||
@property:LLMDescription("The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.")
|
||||
val options: List<Option>,
|
||||
@property:LLMDescription("Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.")
|
||||
val multiSelect: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription("Questions to ask the user (1-4 questions)")
|
||||
val questions: List<Question>,
|
||||
@property:LLMDescription("User answers collected by the permission component")
|
||||
val answers: Map<String, String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class Result {
|
||||
@Serializable
|
||||
data class Success(
|
||||
val answers: Map<String, String>
|
||||
) : Result()
|
||||
|
||||
@Serializable
|
||||
data class Error(
|
||||
val message: String
|
||||
) : Result()
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): Result {
|
||||
if (args.questions.isEmpty()) {
|
||||
return Result.Error("At least one question is required")
|
||||
}
|
||||
if (args.questions.size > 4) {
|
||||
return Result.Error("Maximum of 4 questions allowed")
|
||||
}
|
||||
args.questions.forEach { q ->
|
||||
if (q.options.size !in 2..4) return Result.Error("Question '${q.header}' must have 2-4 options")
|
||||
}
|
||||
|
||||
val collected = try {
|
||||
events.askUserQuestions(
|
||||
AskUserQuestionsModel(
|
||||
questions = args.questions,
|
||||
prefilledAnswers = args.answers ?: emptyMap()
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return Result.Error(e.message ?: "Question flow cancelled or failed")
|
||||
}
|
||||
|
||||
return Result.Success(collected)
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String = when (result) {
|
||||
is Result.Success -> buildString {
|
||||
append("User answered:\n")
|
||||
result.answers.forEach { (k, v) -> append("- ").append(k).append(": ").append(v).append('\n') }
|
||||
}.trimEnd()
|
||||
is Result.Error -> "AskUserQuestion failed: ${result.message}"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AskUserQuestionsModel(
|
||||
val questions: List<Question>,
|
||||
val prefilledAnswers: Map<String, String> = emptyMap()
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object BackgroundProcessManager {
|
||||
|
||||
private val logger = thisLogger()
|
||||
private val processes = ConcurrentHashMap<String, Process>()
|
||||
private val outputBuffers = ConcurrentHashMap<String, ProcessOutput>()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
data class ProcessOutput(
|
||||
val stdout: StringBuilder = StringBuilder(),
|
||||
val stderr: StringBuilder = StringBuilder(),
|
||||
var exitCode: Int? = null,
|
||||
var isComplete: Boolean = false
|
||||
)
|
||||
|
||||
fun registerProcess(id: String, process: Process) {
|
||||
processes[id] = process
|
||||
outputBuffers[id] = ProcessOutput()
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
collectProcessOutput(process, id)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Could not register process with id: $id", e)
|
||||
cleanup(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getProcess(id: String): Process? = processes[id]
|
||||
|
||||
fun getOutput(id: String): ProcessOutput? = outputBuffers[id]
|
||||
|
||||
fun terminateProcess(id: String): Boolean = try {
|
||||
processes[id]?.let { process ->
|
||||
if (process.isAlive) {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
}
|
||||
cleanup(id)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logger.error("Could not terminate process with id: $id", e)
|
||||
false
|
||||
}
|
||||
|
||||
private fun cleanup(id: String) {
|
||||
processes.remove(id)
|
||||
scope.launch {
|
||||
delay(30000)
|
||||
outputBuffers.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun collectProcessOutput(process: Process, bashId: String) {
|
||||
val output = outputBuffers[bashId] ?: return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val stdoutJob = launch {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
output.stdout.appendLine(line)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Stream closed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
val stderrJob = launch {
|
||||
try {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
output.stderr.appendLine(line)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Stream closed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.waitFor()
|
||||
output.exitCode = process.exitValue()
|
||||
output.isComplete = true
|
||||
} catch (_: Exception) {
|
||||
output.isComplete = true
|
||||
}
|
||||
|
||||
stdoutJob.join()
|
||||
stderrJob.join()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
|
||||
import ee.carlrobert.codegpt.agent.ToolRunContext
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class BashOutputTool(private val sessionId: String = "global") :
|
||||
Tool<BashOutputTool.Args, BashOutputTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "BashOutput",
|
||||
description = """
|
||||
- Retrieves output from a running or completed background bash shell
|
||||
- Takes a shell_id parameter identifying the shell
|
||||
- Always returns only new output since the last check
|
||||
- Returns stdout and stderr output along with shell status
|
||||
- Supports optional regex filtering to show only lines matching a pattern
|
||||
- Use this tool when you need to monitor or check the output of a long-running shell
|
||||
- Shell IDs can be found using the Bash tool with run_in_background=true
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The ID of the background shell to retrieve output from"
|
||||
)
|
||||
@SerialName("bash_id")
|
||||
val bashId: String,
|
||||
@property:LLMDescription(
|
||||
"Optional regular expression to filter the output lines. Only lines matching this regex will be included in the result."
|
||||
)
|
||||
val filter: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
@SerialName("bash_id")
|
||||
val bashId: String,
|
||||
val stdout: String,
|
||||
val stderr: String,
|
||||
val status: String,
|
||||
@SerialName("exit_code")
|
||||
val exitCode: Int?
|
||||
)
|
||||
|
||||
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
|
||||
val processOutput = BackgroundProcessManager.getOutput(args.bashId)
|
||||
?: return@withContext Result(
|
||||
bashId = args.bashId,
|
||||
stdout = "",
|
||||
stderr = "",
|
||||
status = "not_found",
|
||||
exitCode = null
|
||||
)
|
||||
|
||||
val stdout = processOutput.stdout.toString()
|
||||
val stderr = processOutput.stderr.toString()
|
||||
val filteredStdout = getFilteredOutput(stdout, args.filter)
|
||||
val filteredStderr = getFilteredOutput(stderr, args.filter)
|
||||
|
||||
val toolId = ToolRunContext.getToolId(sessionId)
|
||||
if (toolId != null) {
|
||||
val publisher = ApplicationManager.getApplication()
|
||||
.messageBus
|
||||
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
|
||||
if (filteredStdout.isNotEmpty()) {
|
||||
filteredStdout.split('\n').forEach { line ->
|
||||
if (line.isNotEmpty()) publisher.toolOutput(toolId, line, false)
|
||||
}
|
||||
}
|
||||
if (filteredStderr.isNotEmpty()) {
|
||||
filteredStderr.split('\n').forEach { line ->
|
||||
if (line.isNotEmpty()) publisher.toolOutput(toolId, line, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val status = when {
|
||||
processOutput.isComplete -> "completed"
|
||||
BackgroundProcessManager.getProcess(args.bashId)?.isAlive == true -> "running"
|
||||
else -> "terminated"
|
||||
}
|
||||
|
||||
Result(
|
||||
bashId = args.bashId,
|
||||
stdout = filteredStdout.trimEnd(),
|
||||
stderr = filteredStderr.trimEnd(),
|
||||
status = status,
|
||||
exitCode = processOutput.exitCode
|
||||
)
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String =
|
||||
buildString {
|
||||
appendLine("Shell ID: ${result.bashId}")
|
||||
appendLine("Status: ${result.status}")
|
||||
|
||||
if (result.exitCode != null) {
|
||||
appendLine("Exit code: ${result.exitCode}")
|
||||
}
|
||||
|
||||
if (result.stdout.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("STDOUT:")
|
||||
appendLine(result.stdout)
|
||||
}
|
||||
|
||||
if (result.stderr.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("STDERR:")
|
||||
appendLine(result.stderr)
|
||||
}
|
||||
|
||||
if (result.stdout.isEmpty() && result.stderr.isEmpty() && result.status == "running") {
|
||||
appendLine()
|
||||
appendLine("(No new output since last check)")
|
||||
}
|
||||
}.trimEnd().truncateToolResult()
|
||||
|
||||
private fun getFilteredOutput(output: String, filter: String?): String {
|
||||
return if (filter != null) {
|
||||
val regex = Regex(filter)
|
||||
output.lines().filter { it.matches(regex) }.joinToString("\n")
|
||||
} else {
|
||||
output
|
||||
}
|
||||
}
|
||||
}
|
||||
502
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BashTool.kt
Normal file
502
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BashTool.kt
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
|
||||
import ee.carlrobert.codegpt.agent.ToolRunContext
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.IOException
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
fun interface BashCommandConfirmationHandler {
|
||||
suspend fun requestConfirmation(args: BashTool.Args): ShellCommandConfirmation
|
||||
}
|
||||
|
||||
class BashTool(
|
||||
private val workingDirectory: String,
|
||||
private val confirmationHandler: BashCommandConfirmationHandler,
|
||||
private val sessionId: String = "global",
|
||||
private val settingsService: ProxyAISettingsService? = null
|
||||
) : Tool<BashTool.Args, BashTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "Bash",
|
||||
description = $$"""
|
||||
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
|
||||
<env>
|
||||
Working directory: $$workingDirectory
|
||||
</env>
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
||||
- Examples of proper quoting:
|
||||
- cd "/Users/name/My Documents" (correct)
|
||||
- cd /Users/name/My Documents (incorrect - will fail)
|
||||
- python "/path/with spaces/script.py" (correct)
|
||||
- python /path/with spaces/script.py (incorrect - will fail)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
|
||||
- You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
|
||||
|
||||
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT find or ls)
|
||||
- Content search: Use Grep (NOT grep or rg)
|
||||
- Read files: Use Read (NOT cat/head/tail)
|
||||
- Edit files: Use Edit (NOT sed/awk)
|
||||
- Write files: Use Write (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
# Committing changes with git
|
||||
|
||||
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
Git Safety Protocol:
|
||||
- NEVER update the git config
|
||||
- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- NEVER run force push to main/master, warn the user if they request it
|
||||
- Avoid git commit --amend. ONLY use --amend when either (1) user explicitly requested amend OR (2) adding edits from pre-commit hook (additional instructions below)
|
||||
- Before amending: ALWAYS check authorship (git log -1 --format='%an %ae')
|
||||
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
|
||||
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure it accurately reflects the changes and their purpose
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Run git status after the commit completes to verify success.
|
||||
Note: git status depends on the commit completing, so run it sequentially after the commit.
|
||||
4. If the commit fails due to pre-commit hook changes, retry ONCE. If it succeeds but files were modified by the hook, verify it's safe to amend:
|
||||
- Check HEAD commit: git log -1 --format='[%h] (%an <%ae>) %s'. VERIFY it matches your commit
|
||||
- Check not pushed: git status shows "Your branch is ahead"
|
||||
- If both true: amend your commit. Otherwise: create NEW commit (never amend other developers' commits)
|
||||
|
||||
Important notes:
|
||||
- NEVER run additional commands to read or explore code, besides git bash commands
|
||||
- NEVER use the TodoWrite or Task tools
|
||||
- DO NOT push to the remote repository unless the user explicitly asks you to do so
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. Run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
- Run a git status command to see all untracked files
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)
|
||||
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:
|
||||
- Create new branch if needed
|
||||
- Push to remote with -u flag if needed
|
||||
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Bulleted markdown checklist of TODOs for testing the pull request...]
|
||||
|
||||
🤖 Generated with [ProxyAI](https://tryproxy.io/)
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- DO NOT use the TodoWrite or Task tools
|
||||
- Return the PR URL when you're done, so the user can see it
|
||||
|
||||
# Other common operations
|
||||
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The command to execute"
|
||||
)
|
||||
val command: String,
|
||||
@property:LLMDescription(
|
||||
"Optional timeout in milliseconds (max 600000)"
|
||||
)
|
||||
val timeout: Int = 60_000,
|
||||
@property:LLMDescription(
|
||||
"Clear, concise description of what this command does in 5-10 words, in active voice. Examples:\n" +
|
||||
"Input: ls\n" +
|
||||
"Output: List files in current directory\n\n" +
|
||||
"Input: git status\n" +
|
||||
"Output: Show working tree status\n\n" +
|
||||
"Input: npm install\n" +
|
||||
"Output: Install package dependencies\n\n" +
|
||||
"Input: mkdir foo\n" +
|
||||
"Output: Create directory 'foo'"
|
||||
)
|
||||
val description: String? = null,
|
||||
@property:LLMDescription(
|
||||
"Set to true to run this command in the background. Use BashOutput to read the output later."
|
||||
)
|
||||
@SerialName("run_in_background")
|
||||
val runInBackground: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
val command: String,
|
||||
val exitCode: Int?,
|
||||
val output: String,
|
||||
@SerialName("bash_id")
|
||||
val bashId: String? = null
|
||||
)
|
||||
|
||||
private fun isWhiteListed(args: Args): Boolean {
|
||||
val permissions = settingsService?.getBashPermissions() ?: emptyList()
|
||||
return permissions.any { pattern ->
|
||||
when {
|
||||
pattern.startsWith("Bash(") && pattern.endsWith(":*)") -> {
|
||||
val commandPattern = pattern.removePrefix("Bash(").removeSuffix(":*)")
|
||||
args.command.startsWith(commandPattern)
|
||||
}
|
||||
|
||||
pattern.startsWith("Bash(") && pattern.endsWith(")") -> {
|
||||
val commandPattern = pattern.removePrefix("Bash(").removeSuffix(")")
|
||||
args.command == commandPattern
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): Result {
|
||||
if (shouldBlockByIgnore(args.command)) {
|
||||
return Result(
|
||||
args.command,
|
||||
null,
|
||||
"Command denied by policy: access to ignored files is blocked",
|
||||
null
|
||||
)
|
||||
}
|
||||
val isAskPattern = shouldAskForConfirmation(args.command)
|
||||
val toolId = ToolRunContext.getToolId(sessionId)
|
||||
?: throw IllegalArgumentException("Tool ID is missing")
|
||||
|
||||
val confirmation = when {
|
||||
isWhiteListed(args) && !isAskPattern -> ShellCommandConfirmation.Approved
|
||||
else -> confirmationHandler.requestConfirmation(args)
|
||||
}
|
||||
return when (confirmation) {
|
||||
is ShellCommandConfirmation.Approved -> {
|
||||
try {
|
||||
if (args.runInBackground == true) {
|
||||
val bashId = executeBackground(args.command)
|
||||
Result(
|
||||
args.command,
|
||||
null,
|
||||
"Background process started with ID: $bashId",
|
||||
bashId
|
||||
)
|
||||
} else {
|
||||
runForegroundWithStreaming(toolId, args)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result(args.command, null, "Failed to execute command: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
|
||||
is ShellCommandConfirmation.Denied -> {
|
||||
Result(
|
||||
args.command,
|
||||
null,
|
||||
"Command execution denied with user response: ${confirmation.userResponse}",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runForegroundWithStreaming(toolId: String, args: Args): Result {
|
||||
val publisher = ApplicationManager.getApplication().messageBus
|
||||
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val stdoutBuilder = StringBuilder()
|
||||
val stderrBuilder = StringBuilder()
|
||||
|
||||
val shellCommand = buildShellCommand(args.command)
|
||||
val process = ProcessBuilder(shellCommand)
|
||||
.apply {
|
||||
directory(java.io.File(workingDirectory))
|
||||
redirectErrorStream(false)
|
||||
}
|
||||
.start()
|
||||
|
||||
try {
|
||||
val job = currentCoroutineContext()[Job]
|
||||
job?.invokeOnCompletion { cause ->
|
||||
if (cause is CancellationException && process.isAlive) {
|
||||
runCatching { process.destroyForcibly() }
|
||||
}
|
||||
}
|
||||
val stdoutJob = launch {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
stdoutBuilder.appendLine(line)
|
||||
publisher.toolOutput(toolId, line, false)
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// Ignore IO exception if the stream is closed
|
||||
}
|
||||
}
|
||||
|
||||
val stderrJob = launch {
|
||||
try {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
stderrBuilder.appendLine(line)
|
||||
publisher.toolOutput(toolId, line, true)
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// Ignore IO exception if the stream is closed
|
||||
}
|
||||
}
|
||||
|
||||
val timeoutMs = args.timeout.coerceIn(1, 600_000)
|
||||
val isCompleted = withTimeoutOrNull(timeoutMs.toLong()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
process.waitFor()
|
||||
}
|
||||
} != null
|
||||
|
||||
if (!isCompleted) {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
|
||||
stdoutJob.join()
|
||||
stderrJob.join()
|
||||
|
||||
val combinedOutput = buildCombinedOutput(
|
||||
stdoutBuilder.toString().trimEnd(),
|
||||
stderrBuilder.toString().trimEnd(),
|
||||
if (!isCompleted) "Command timed out after ${timeoutMs}ms" else null
|
||||
)
|
||||
|
||||
Result(
|
||||
args.command,
|
||||
if (isCompleted) process.exitValue() else null,
|
||||
combinedOutput,
|
||||
null
|
||||
)
|
||||
} finally {
|
||||
if (process.isAlive) {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCombinedOutput(
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
message: String? = null
|
||||
): String {
|
||||
return buildString {
|
||||
if (stdout.isNotEmpty()) appendLine(stdout)
|
||||
if (stderr.isNotEmpty()) appendLine(stderr)
|
||||
message?.let { appendLine(it) }
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String = with(result) {
|
||||
val raw = buildString {
|
||||
appendLine("Command: $command")
|
||||
if (output.isNotEmpty()) {
|
||||
appendLine(output)
|
||||
} else if (exitCode != null) {
|
||||
appendLine("(no output)")
|
||||
}
|
||||
exitCode?.let {
|
||||
appendLine("Exit code: $it")
|
||||
}
|
||||
}.trimEnd()
|
||||
raw.truncateToolResult()
|
||||
}
|
||||
|
||||
private fun executeBackground(command: String): String {
|
||||
val bashId = UUID.randomUUID().toString()
|
||||
val shellCommand = buildShellCommand(command)
|
||||
val process = ProcessBuilder(shellCommand)
|
||||
.apply {
|
||||
directory(java.io.File(workingDirectory))
|
||||
redirectErrorStream(false)
|
||||
}
|
||||
.start()
|
||||
|
||||
BackgroundProcessManager.registerProcess(bashId, process)
|
||||
return bashId
|
||||
}
|
||||
|
||||
private fun buildShellCommand(command: String): List<String> {
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
return if (isWindows) {
|
||||
listOf("cmd", "/c", command)
|
||||
} else {
|
||||
listOf("sh", "-c", command)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldAskForConfirmation(command: String): Boolean {
|
||||
val askPatterns = listOf(
|
||||
"find * -delete*",
|
||||
"find * -exec*",
|
||||
"find * -fprint*",
|
||||
"find * -fls*",
|
||||
"find * -fprintf*",
|
||||
"find * -ok*",
|
||||
"sort --output=*",
|
||||
"sort -o *",
|
||||
"tree -o *"
|
||||
)
|
||||
|
||||
return askPatterns.any { pattern ->
|
||||
if (pattern.contains("*")) {
|
||||
command.startsWith(pattern.removeSuffix("*"))
|
||||
} else {
|
||||
command == pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldBlockByIgnore(command: String): Boolean {
|
||||
val svc = settingsService ?: return false
|
||||
val readers = setOf("cat", "grep", "rg", "sed", "awk", "head", "tail", "less", "more", "wc", "stat", "file")
|
||||
val tokens = tokenize(command)
|
||||
val paths = mutableListOf<String>()
|
||||
var lastWasReader = false
|
||||
var expectFileArg = false
|
||||
tokens.forEach { t ->
|
||||
if (t.startsWith("-")) {
|
||||
if (t == "-f" || t.startsWith("--file=")) {
|
||||
expectFileArg = true
|
||||
val eq = t.indexOf('=')
|
||||
if (eq > 0 && eq < t.length - 1) paths.add(t.substring(eq + 1))
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
if (expectFileArg) {
|
||||
paths.add(t)
|
||||
expectFileArg = false
|
||||
return@forEach
|
||||
}
|
||||
val redirIdx = maxOf(t.lastIndexOf('>'), t.lastIndexOf('<'))
|
||||
if (redirIdx >= 0 && redirIdx < t.length - 1) {
|
||||
val target = t.substring(redirIdx + 1)
|
||||
if (target.isNotBlank()) paths.add(target)
|
||||
}
|
||||
val tokenIsReader = readers.contains(t)
|
||||
if (lastWasReader && !tokenIsReader) paths.add(t)
|
||||
if (!tokenIsReader && looksLikePath(t)) paths.add(t)
|
||||
lastWasReader = tokenIsReader
|
||||
}
|
||||
return paths.any { candidate -> svc.isPathIgnored(toAbsolute(candidate)) }
|
||||
}
|
||||
|
||||
private fun tokenize(s: String): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
var quote: Char? = null
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (quote == null) {
|
||||
if (c == '\'' || c == '"') {
|
||||
quote = c
|
||||
} else if (c.isWhitespace()) {
|
||||
if (sb.isNotEmpty()) { out.add(sb.toString()); sb.setLength(0) }
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
} else {
|
||||
if (c == quote) quote = null else sb.append(c)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if (sb.isNotEmpty()) out.add(sb.toString())
|
||||
return out
|
||||
}
|
||||
|
||||
private fun looksLikePath(token: String): Boolean =
|
||||
token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.contains('/')
|
||||
|
||||
private fun toAbsolute(token: String): String {
|
||||
return try {
|
||||
val p = Paths.get(token)
|
||||
val abs = if (p.isAbsolute) p else Paths.get(workingDirectory).resolve(p)
|
||||
abs.normalize().toString()
|
||||
} catch (_: Exception) {
|
||||
token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
|
||||
class ConfirmingWriteTool(
|
||||
private val delegate: WriteTool,
|
||||
private val approve: suspend (name: String, details: String) -> Boolean
|
||||
) : Tool<WriteTool.Args, WriteTool.Result>(
|
||||
argsSerializer = WriteTool.Args.serializer(),
|
||||
resultSerializer = WriteTool.Result.serializer(),
|
||||
name = delegate.name,
|
||||
description = delegate.descriptor.description
|
||||
) {
|
||||
|
||||
override suspend fun execute(args: WriteTool.Args): WriteTool.Result {
|
||||
val details = buildString {
|
||||
append("Path: ")
|
||||
append(args.filePath)
|
||||
append("\n")
|
||||
append("Bytes: ")
|
||||
append(args.content.toByteArray().size)
|
||||
}
|
||||
val ok = approve("Write", details)
|
||||
if (!ok) {
|
||||
return WriteTool.Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "User rejected write operation"
|
||||
)
|
||||
}
|
||||
return delegate.execute(args)
|
||||
}
|
||||
}
|
||||
|
||||
class ConfirmingEditTool(
|
||||
private val delegate: EditTool,
|
||||
private val approve: suspend (name: String, details: String) -> Boolean
|
||||
) : Tool<EditTool.Args, EditTool.Result>(
|
||||
argsSerializer = EditTool.Args.serializer(),
|
||||
resultSerializer = EditTool.Result.serializer(),
|
||||
name = delegate.name,
|
||||
description = delegate.descriptor.description
|
||||
) {
|
||||
|
||||
override suspend fun execute(args: EditTool.Args): EditTool.Result {
|
||||
val ok = approve("Edit", args.shortDescription)
|
||||
if (!ok) {
|
||||
return EditTool.Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "User rejected edit operation"
|
||||
)
|
||||
}
|
||||
return delegate.execute(args)
|
||||
}
|
||||
}
|
||||
309
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/EditTool.kt
Normal file
309
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/EditTool.kt
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
|
||||
class EditTool(private val project: Project) : Tool<EditTool.Args, EditTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "Edit",
|
||||
description = """
|
||||
Performs an exact string replacement in a file.
|
||||
|
||||
The tool searches for the exact old_string pattern and replaces it with new_string.
|
||||
Matching is exact - whitespace, indentation, and all characters must match precisely.
|
||||
|
||||
Parameters:
|
||||
- file_path: Absolute path to the file
|
||||
- old_string: Exact string to find (case-sensitive, includes whitespace)
|
||||
- new_string: Replacement string
|
||||
- short_description: Short description of the requested edit.
|
||||
- replace_all: If true, replaces ALL occurrences. If false, replaces only the first occurrence
|
||||
|
||||
Important notes:
|
||||
- Always verify the old_string matches exactly before attempting edits
|
||||
- Use replace_all=true for global replacements
|
||||
- The file must exist and be writable
|
||||
- Binary files are not supported
|
||||
- old_string and new_string must be different
|
||||
- Tool will fail if no replacements are made
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
private fun getLineAndColumn(document: Document, offset: Int): Pair<Int, Int> {
|
||||
val line = document.getLineNumber(offset)
|
||||
val lineStart = document.getLineStartOffset(line)
|
||||
val column = offset - lineStart
|
||||
return Pair(line + 1, column)
|
||||
}
|
||||
|
||||
private fun getLineContext(document: Document, line: Int, contextLines: Int = 2): String {
|
||||
val startLine = maxOf(0, line - contextLines)
|
||||
val endLine = minOf(document.lineCount - 1, line + contextLines)
|
||||
return buildString {
|
||||
for (i in startLine..endLine) {
|
||||
val lineStart = document.getLineStartOffset(i)
|
||||
val lineEnd = document.getLineEndOffset(i)
|
||||
appendLine(document.getText(TextRange(lineStart, lineEnd)))
|
||||
}
|
||||
}.trim()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The absolute path to the file to edit. Must be an absolute path, not a relative path."
|
||||
)
|
||||
@SerialName("file_path")
|
||||
val filePath: String,
|
||||
@property:LLMDescription(
|
||||
"The exact string to search for in the file. Must match exactly including whitespace and indentation."
|
||||
)
|
||||
@SerialName("old_string")
|
||||
val oldString: String,
|
||||
@property:LLMDescription(
|
||||
"The new string to replace the old string with."
|
||||
)
|
||||
@SerialName("new_string")
|
||||
val newString: String,
|
||||
@property:LLMDescription(
|
||||
"Short description of the edit."
|
||||
)
|
||||
@SerialName("short_description")
|
||||
val shortDescription: String,
|
||||
@property:LLMDescription(
|
||||
"Replace all occurrences of old_string with new_string. Default is false (only replace first occurrence)."
|
||||
)
|
||||
@SerialName("replace_all")
|
||||
val replaceAll: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EditLocation(
|
||||
val line: Int,
|
||||
val column: Int,
|
||||
val context: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class Result {
|
||||
@Serializable
|
||||
data class Success(
|
||||
val filePath: String,
|
||||
val replacementsMade: Int,
|
||||
val message: String,
|
||||
val oldStringPreview: String? = null,
|
||||
val newStringPreview: String? = null,
|
||||
val editLocations: List<EditLocation> = emptyList()
|
||||
) : Result()
|
||||
|
||||
@Serializable
|
||||
data class Error(
|
||||
val filePath: String,
|
||||
val error: String
|
||||
) : Result()
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): Result {
|
||||
return try {
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
if (svc.isPathIgnored(args.filePath)) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = ".proxyai ignore rules block editing this path"
|
||||
)
|
||||
}
|
||||
val normalizedPath = args.filePath.replace("\\", "/")
|
||||
val file = File(normalizedPath)
|
||||
|
||||
if (!file.exists()) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "File not found: ${args.filePath} (File does not exist on filesystem)"
|
||||
)
|
||||
}
|
||||
|
||||
if (!file.isFile) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Path is not a file: ${args.filePath}"
|
||||
)
|
||||
}
|
||||
|
||||
if (!file.canWrite()) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "File is not writable: ${args.filePath}"
|
||||
)
|
||||
}
|
||||
|
||||
val virtualFile =
|
||||
VirtualFileManager.getInstance().findFileByUrl("file://$normalizedPath")
|
||||
?: VirtualFileManager.getInstance()
|
||||
.refreshAndFindFileByUrl("file://$normalizedPath")
|
||||
?: LocalFileSystem.getInstance().findFileByIoFile(file)
|
||||
?: return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "File not found in IntelliJ VFS: ${args.filePath}"
|
||||
)
|
||||
|
||||
if (args.oldString.isEmpty()) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "old_string cannot be empty"
|
||||
)
|
||||
}
|
||||
|
||||
if (args.oldString == args.newString) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "old_string and new_string are identical. No replacement would be made."
|
||||
)
|
||||
}
|
||||
|
||||
val document =
|
||||
runReadAction { FileDocumentManager.getInstance().getDocument(virtualFile) }
|
||||
?: return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Cannot get document for file: ${args.filePath}"
|
||||
)
|
||||
|
||||
val content = document.text
|
||||
if (!content.contains(args.oldString)) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "String not found in file: '${args.oldString.take(50)}${if (args.oldString.length > 50) "..." else ""}'"
|
||||
)
|
||||
}
|
||||
|
||||
val editLocations = mutableListOf<EditLocation>()
|
||||
|
||||
if (args.replaceAll) {
|
||||
var lastIndex = 0
|
||||
while (true) {
|
||||
val index = content.indexOf(args.oldString, lastIndex)
|
||||
if (index == -1) break
|
||||
|
||||
val (line, column) = getLineAndColumn(document, index)
|
||||
val context = getLineContext(document, line - 1)
|
||||
editLocations.add(EditLocation(line, column, context))
|
||||
lastIndex = index + args.oldString.length
|
||||
}
|
||||
} else {
|
||||
val index = content.indexOf(args.oldString)
|
||||
if (index != -1) {
|
||||
val (line, column) = getLineAndColumn(document, index)
|
||||
val context = getLineContext(document, line - 1)
|
||||
editLocations.add(EditLocation(line, column, context))
|
||||
}
|
||||
}
|
||||
|
||||
val replacementsMade = WriteCommandAction.runWriteCommandAction<Int>(project) {
|
||||
var count = 0
|
||||
|
||||
if (args.replaceAll) {
|
||||
var lastIndex = 0
|
||||
while (true) {
|
||||
val index = content.indexOf(args.oldString, lastIndex)
|
||||
if (index == -1) break
|
||||
|
||||
val range = TextRange(index, index + args.oldString.length)
|
||||
document.replaceString(
|
||||
range.startOffset,
|
||||
range.endOffset,
|
||||
args.newString
|
||||
)
|
||||
count++
|
||||
lastIndex = index + args.newString.length
|
||||
}
|
||||
} else {
|
||||
val index = content.indexOf(args.oldString)
|
||||
if (index != -1) {
|
||||
val range = TextRange(index, index + args.oldString.length)
|
||||
document.replaceString(
|
||||
range.startOffset,
|
||||
range.endOffset,
|
||||
args.newString
|
||||
)
|
||||
count = 1
|
||||
}
|
||||
}
|
||||
|
||||
FileDocumentManager.getInstance().saveDocument(document)
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
if (replacementsMade == 0) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "No replacements made. The old_string and new_string may be identical, or the replacement operation failed."
|
||||
)
|
||||
}
|
||||
|
||||
Result.Success(
|
||||
filePath = args.filePath,
|
||||
replacementsMade = replacementsMade,
|
||||
message = "Successfully made $replacementsMade replacement${if (replacementsMade != 1) "s" else ""}",
|
||||
oldStringPreview = args.oldString.trim().take(200),
|
||||
newStringPreview = args.newString.trim().take(200),
|
||||
editLocations = editLocations
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Failed to edit file: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String = when (result) {
|
||||
is Result.Success -> {
|
||||
val raw = buildString {
|
||||
appendLine("Successfully edited file '${result.filePath}'")
|
||||
appendLine("Made ${result.replacementsMade} replacement${if (result.replacementsMade != 1) "s" else ""}")
|
||||
|
||||
if (result.replacementsMade > 0) {
|
||||
appendLine()
|
||||
appendLine("Changes made:")
|
||||
appendLine("- File: ${result.filePath}")
|
||||
appendLine("- Replacements: ${result.replacementsMade}")
|
||||
|
||||
if (result.oldStringPreview != null || result.newStringPreview != null) {
|
||||
appendLine()
|
||||
if (result.oldStringPreview != null) {
|
||||
val oldPreview = if (result.oldStringPreview.length > 100) {
|
||||
result.oldStringPreview.take(97) + "..."
|
||||
} else result.oldStringPreview
|
||||
appendLine("- Old text: \"$oldPreview\"")
|
||||
}
|
||||
if (result.newStringPreview != null) {
|
||||
val newPreview = if (result.newStringPreview.length > 100) {
|
||||
result.newStringPreview.take(97) + "..."
|
||||
} else result.newStringPreview
|
||||
appendLine("- New text: \"$newPreview\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.trimEnd()
|
||||
raw.truncateToolResult()
|
||||
}
|
||||
|
||||
is Result.Error -> {
|
||||
("Error editing file '${result.filePath}': ${result.error}").truncateToolResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
|
||||
/**
|
||||
* Tool for fetching library documentation from Context7.
|
||||
*
|
||||
* This tool retrieves documentation for a specific library using its
|
||||
* Context7-compatible ID. It supports both code documentation and
|
||||
* informational guides.
|
||||
*/
|
||||
class GetLibraryDocsTool : Tool<GetLibraryDocsTool.Args, GetLibraryDocsTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "GetLibraryDocs",
|
||||
description = """
|
||||
Use when the user asks how to use a library, best practices, APIs, configuration, or conventions. Resolve the library first with ResolveLibraryId unless the user provided a Context7-compatible ID. Use mode='code' for API/code, or mode='info' for guides.
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"Exact Context7-compatible library ID (e.g., '/mongodb/docs', '/vercel/next.js', '/supabase/supabase', '/vercel/next.js/v14.3.0-canary.87') retrieved from 'resolve-library-id' or directly from user query in the format '/org/project' or '/org/project/version'."
|
||||
)
|
||||
val context7CompatibleLibraryID: String,
|
||||
@property:LLMDescription(
|
||||
"Documentation mode: 'code' for API references and code examples (default), 'info' for conceptual guides, narrative information, and architectural questions."
|
||||
)
|
||||
val mode: String = "code",
|
||||
@property:LLMDescription(
|
||||
"Topic to focus documentation on (e.g., 'hooks', 'routing')."
|
||||
)
|
||||
val topic: String? = null,
|
||||
@property:LLMDescription(
|
||||
"Page number for pagination (start: 1, default: 1). If the context is not sufficient, try page=2, page=3, page=4, etc. with the same topic."
|
||||
)
|
||||
val page: Int = 1
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class Result {
|
||||
@Serializable
|
||||
data class Success(
|
||||
val libraryId: String,
|
||||
val documentation: String,
|
||||
val docMode: String,
|
||||
val page: Int
|
||||
) : Result()
|
||||
|
||||
@Serializable
|
||||
data class Error(
|
||||
val error: String
|
||||
) : Result()
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!args.context7CompatibleLibraryID.startsWith("/")) {
|
||||
return@withContext Result.Error(
|
||||
"Invalid library ID format: '${args.context7CompatibleLibraryID}'. Library ID must start with '/' (e.g., '/vercel/next.js'). " +
|
||||
"Use 'ResolveLibraryId' tool first to get the correct library ID format."
|
||||
)
|
||||
}
|
||||
|
||||
if (args.mode.lowercase() !in listOf("code", "info")) {
|
||||
return@withContext Result.Error(
|
||||
"Invalid documentation mode: '${args.mode}'. Must be either 'code' or 'info'."
|
||||
)
|
||||
}
|
||||
|
||||
if (args.page !in 1..10) {
|
||||
return@withContext Result.Error(
|
||||
"Invalid page number: ${args.page}. Page number must be between 1 and 10."
|
||||
)
|
||||
}
|
||||
|
||||
val libraryComponents = parseLibraryId(args.context7CompatibleLibraryID)
|
||||
|
||||
val baseUrl = "https://context7.com/api/v2/docs/${args.mode.lowercase()}"
|
||||
val urlBuilder = StringBuilder("$baseUrl/${libraryComponents.username}/${libraryComponents.library}")
|
||||
|
||||
if (libraryComponents.tag != null) {
|
||||
urlBuilder.append("/${libraryComponents.tag}")
|
||||
}
|
||||
|
||||
urlBuilder.append("?type=txt")
|
||||
if (args.topic != null) {
|
||||
urlBuilder.append("&topic=${URLEncoder.encode(args.topic, StandardCharsets.UTF_8.toString())}")
|
||||
}
|
||||
if (args.page > 1) {
|
||||
urlBuilder.append("&page=${args.page}")
|
||||
}
|
||||
|
||||
val url = urlBuilder.toString()
|
||||
|
||||
val doc = Jsoup.connect(url)
|
||||
.userAgent("Mozilla/5.0 (compatible; ProxyAI/1.0; +https://tryproxy.io)")
|
||||
.timeout(10000)
|
||||
.ignoreContentType(true)
|
||||
.get()
|
||||
|
||||
val documentation = doc.body().text().take(10_000)
|
||||
|
||||
if (documentation.isEmpty()) {
|
||||
return@withContext Result.Error(
|
||||
"No documentation available for library '${args.context7CompatibleLibraryID}' in ${args.mode} mode."
|
||||
)
|
||||
}
|
||||
|
||||
if (documentation.contains("No content available") ||
|
||||
documentation.contains("No context data available")) {
|
||||
val suggestion = if (args.mode.lowercase() == "code") {
|
||||
" Try mode='info' for guides and tutorials."
|
||||
} else {
|
||||
" Try mode='code' for API references and code examples."
|
||||
}
|
||||
return@withContext Result.Error(
|
||||
"No ${args.mode} documentation available for this library.$suggestion"
|
||||
)
|
||||
}
|
||||
|
||||
return@withContext Result.Success(
|
||||
libraryId = args.context7CompatibleLibraryID,
|
||||
documentation = documentation,
|
||||
docMode = args.mode,
|
||||
page = args.page
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
val errorMessage = when {
|
||||
e.message?.contains("404", ignoreCase = true) == true ->
|
||||
"The library '${args.context7CompatibleLibraryID}' was not found. Please check the library ID or use 'ResolveLibraryId' to find the correct ID."
|
||||
e.message?.contains("429", ignoreCase = true) == true ->
|
||||
"Rate limited due to too many requests. Please try again later."
|
||||
e.message?.contains("401", ignoreCase = true) == true ->
|
||||
"Unauthorized access to Context7 service."
|
||||
else ->
|
||||
"Failed to fetch documentation: ${e.message}"
|
||||
}
|
||||
return@withContext Result.Error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String = when (result) {
|
||||
is Result.Success -> result.documentation.truncateToolResult()
|
||||
is Result.Error -> ("Failed to retrieve library documentation: ${result.error}").truncateToolResult()
|
||||
}
|
||||
|
||||
private fun parseLibraryId(libraryId: String): LibraryComponents {
|
||||
val cleaned = libraryId.removePrefix("/")
|
||||
val parts = cleaned.split("/")
|
||||
|
||||
if (parts.size < 2) {
|
||||
throw IllegalArgumentException(
|
||||
"Invalid library ID format: $libraryId. Expected format: /username/library or /username/library/tag"
|
||||
)
|
||||
}
|
||||
|
||||
return LibraryComponents(
|
||||
username = parts[0],
|
||||
library = parts[1],
|
||||
tag = if (parts.size > 2) parts[2] else null
|
||||
)
|
||||
}
|
||||
|
||||
private data class LibraryComponents(
|
||||
val username: String,
|
||||
val library: String,
|
||||
val tag: String?
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.ide.util.gotoByName.ChooseByNameModel
|
||||
import com.intellij.ide.util.gotoByName.GotoClassModel2
|
||||
import com.intellij.ide.util.gotoByName.GotoFileModel
|
||||
import com.intellij.ide.util.gotoByName.GotoSymbolModel2
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.guessProjectDir
|
||||
import com.intellij.openapi.roots.ProjectRootManager
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.*
|
||||
import com.intellij.psi.codeStyle.NameUtil
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import com.intellij.psi.search.GlobalSearchScopesCore
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.nio.file.Paths
|
||||
import com.intellij.openapi.util.TextRange
|
||||
|
||||
/**
|
||||
* Enhanced search tool using IntelliJ's native SearchService and FindModel.
|
||||
*/
|
||||
class IntelliJSearchTool(
|
||||
private val project: Project
|
||||
) : Tool<IntelliJSearchTool.Args, IntelliJSearchTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "IntelliJSearch",
|
||||
description = """
|
||||
Search Everywhere-style name search (files, classes, symbols).
|
||||
|
||||
Features:
|
||||
- Uses the same models as Goto File/Class/Symbol
|
||||
- Respects scope (project/all) and de-duplicates results
|
||||
- Optimized with NameUtil's MinusculeMatcher like the IDE
|
||||
|
||||
Notes:
|
||||
- This is a name-oriented search, not content search
|
||||
- For content search, use the Grep tool instead
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val MAX_CONTEXT_CHARS = 200
|
||||
const val MAX_OUTPUT_LINES = 100
|
||||
const val MAX_OUTPUT_CHARS = 6000
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The text or pattern to search for"
|
||||
)
|
||||
val pattern: String,
|
||||
|
||||
@property:LLMDescription(
|
||||
"Search scope: 'project', 'module', 'directory', 'file', or 'custom'"
|
||||
)
|
||||
val scope: String? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"File or directory to search in. If not specified, uses project scope."
|
||||
)
|
||||
val path: String? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"File type filter (e.g., 'java', 'kotlin', 'js', 'py')"
|
||||
)
|
||||
val fileType: String? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"Search context: 'ANY', 'IN_CODE', 'IN_COMMENTS', 'IN_STRING_LITERALS', 'EXCEPT_COMMENTS', 'EXCEPT_STRING_LITERALS'"
|
||||
)
|
||||
val context: String? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"Case sensitive search. Default: false"
|
||||
)
|
||||
val caseSensitive: Boolean? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"Regular expression search. Default: false"
|
||||
)
|
||||
val regex: Boolean? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"Whole word search. Default: false"
|
||||
)
|
||||
val wholeWords: Boolean? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"Output mode: 'content', 'files_with_matches', 'count'"
|
||||
)
|
||||
val outputMode: String? = null,
|
||||
|
||||
@property:LLMDescription(
|
||||
"Maximum number of results to return"
|
||||
)
|
||||
val limit: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
val pattern: String,
|
||||
val scope: String,
|
||||
val totalMatches: Int,
|
||||
val matches: List<SearchMatch>,
|
||||
val output: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchMatch(
|
||||
val file: String,
|
||||
val line: Int?,
|
||||
val column: Int?,
|
||||
val text: String,
|
||||
val context: String?
|
||||
)
|
||||
|
||||
override suspend fun execute(args: Args): Result {
|
||||
try {
|
||||
val searchScope = createSearchScope(args, project)
|
||||
val effectiveLimit = (args.limit ?: 10).coerceAtLeast(1)
|
||||
val matches =
|
||||
runReadAction { searchEverywhere(args.pattern, searchScope, effectiveLimit) }
|
||||
val output = formatOutput(matches, args)
|
||||
|
||||
return Result(
|
||||
pattern = args.pattern,
|
||||
scope = args.scope ?: "project",
|
||||
totalMatches = matches.size,
|
||||
matches = matches,
|
||||
output = output
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return Result(
|
||||
pattern = args.pattern,
|
||||
scope = args.scope ?: "project",
|
||||
totalMatches = 0,
|
||||
matches = emptyList(),
|
||||
output = "Search failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSearchScope(args: Args, project: Project): GlobalSearchScope {
|
||||
return when (args.scope?.lowercase()) {
|
||||
"project" -> GlobalSearchScope.projectScope(project)
|
||||
"all", "everything", "anywhere" -> GlobalSearchScope.allScope(project)
|
||||
"directory" -> {
|
||||
val vf = args.path?.let { resolvePath(it) }
|
||||
if (vf != null && vf.isDirectory) GlobalSearchScopesCore.directoriesScope(
|
||||
project,
|
||||
true,
|
||||
vf
|
||||
)
|
||||
else GlobalSearchScope.projectScope(project)
|
||||
}
|
||||
|
||||
"file" -> {
|
||||
val vf = args.path?.let { resolvePath(it) }
|
||||
if (vf != null && !vf.isDirectory) GlobalSearchScope.filesScope(project, listOf(vf))
|
||||
else GlobalSearchScope.projectScope(project)
|
||||
}
|
||||
|
||||
"module" -> {
|
||||
val roots = ProjectRootManager.getInstance(project).contentRoots
|
||||
if (roots.isNotEmpty()) {
|
||||
GlobalSearchScopesCore.directoriesScope(project, true, *roots)
|
||||
} else GlobalSearchScope.projectScope(project)
|
||||
}
|
||||
|
||||
else -> GlobalSearchScope.projectScope(project)
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchEverywhere(
|
||||
pattern: String,
|
||||
scope: GlobalSearchScope,
|
||||
limit: Int?
|
||||
): List<SearchMatch> {
|
||||
val results = mutableListOf<SearchMatch>()
|
||||
val max = (limit ?: 10).coerceAtLeast(1)
|
||||
|
||||
val tokens = pattern.split(Regex("[^A-Za-z0-9]+")).filter { it.isNotBlank() }
|
||||
val tokenMatchers = if (tokens.isEmpty()) {
|
||||
listOf(NameUtil.buildMatcher("*${pattern.trim()}*").build())
|
||||
} else {
|
||||
tokens.map { t -> NameUtil.buildMatcher("*$t*").build() }
|
||||
}
|
||||
|
||||
fun addFromModel(model: ChooseByNameModel) {
|
||||
val names: Array<String> = when (model) {
|
||||
is GotoFileModel -> model.getNames(true)
|
||||
is GotoClassModel2 -> model.getNames(true)
|
||||
is GotoSymbolModel2 -> model.getNames(true)
|
||||
else -> emptyArray()
|
||||
}
|
||||
|
||||
val ranked = names.mapNotNull { name ->
|
||||
val degree = tokenMatchers.maxOfOrNull { it.matchingDegree(name) } ?: Int.MIN_VALUE
|
||||
if (degree != Int.MIN_VALUE) name to degree else null
|
||||
}.sortedByDescending { it.second }
|
||||
|
||||
for ((name, _) in ranked) {
|
||||
val elements = when (model) {
|
||||
is GotoFileModel -> model.getElementsByName(name, true, name)
|
||||
is GotoClassModel2 -> model.getElementsByName(name, true, name)
|
||||
is GotoSymbolModel2 -> model.getElementsByName(name, true, name)
|
||||
else -> emptyArray()
|
||||
}.filterIsInstance<PsiElement>()
|
||||
|
||||
for (psi in elements) {
|
||||
val target = psi.navigationElement ?: psi
|
||||
val psiFile = target.containingFile
|
||||
val vf = psiFile?.virtualFile
|
||||
if (vf != null && !scope.contains(vf)) continue
|
||||
|
||||
val offset = when (target) {
|
||||
is PsiNameIdentifierOwner -> target.nameIdentifier?.textOffset
|
||||
?: target.textOffset
|
||||
|
||||
else -> target.textOffset
|
||||
}.coerceAtLeast(0)
|
||||
|
||||
val document =
|
||||
psiFile?.let { PsiDocumentManager.getInstance(project).getDocument(it) }
|
||||
val (line, col) = computeLineCol(document, offset)
|
||||
val context = if (psiFile != null) getContextText(psiFile, offset) else null
|
||||
|
||||
val displayFile = vf?.path ?: (psiFile?.name ?: psi.toString())
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
if (vf?.path != null && svc.isPathIgnored(vf.path)) continue
|
||||
|
||||
results.add(
|
||||
SearchMatch(
|
||||
file = displayFile,
|
||||
line = line,
|
||||
column = col,
|
||||
text = (target as? PsiNamedElement)?.name ?: name,
|
||||
context = context
|
||||
)
|
||||
)
|
||||
|
||||
if (results.size >= max) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addFromModel(GotoFileModel(project))
|
||||
if (results.size >= max) return results.distinctBy { Triple(it.file, it.line, it.column) }
|
||||
addFromModel(GotoClassModel2(project))
|
||||
if (results.size >= max) return results.distinctBy { Triple(it.file, it.line, it.column) }
|
||||
addFromModel(GotoSymbolModel2(project, project))
|
||||
|
||||
return results.distinctBy { Triple(it.file, it.line, it.column) }.take(max)
|
||||
}
|
||||
|
||||
private fun computeLineCol(document: Document?, offset: Int): Pair<Int?, Int?> {
|
||||
return if (document != null) {
|
||||
val line = document.getLineNumber(offset)
|
||||
val col = offset - document.getLineStartOffset(line)
|
||||
Pair(line + 1, col)
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePath(relativeOrAbsolute: String): VirtualFile? {
|
||||
val fs = LocalFileSystem.getInstance()
|
||||
if (Paths.get(relativeOrAbsolute).isAbsolute) {
|
||||
return fs.findFileByPath(relativeOrAbsolute)
|
||||
}
|
||||
val base = project.basePath
|
||||
if (base != null) {
|
||||
val abs = Paths.get(base).resolve(relativeOrAbsolute).normalize().toString()
|
||||
fs.findFileByPath(abs)?.let { return it }
|
||||
}
|
||||
val projDir = guessProjectDir()
|
||||
return projDir?.findFileByRelativePath(relativeOrAbsolute)
|
||||
}
|
||||
|
||||
private fun guessProjectDir(): VirtualFile? = project.guessProjectDir()
|
||||
|
||||
private fun getContextText(psiFile: PsiFile?, offset: Int): String? {
|
||||
if (psiFile == null) return null
|
||||
|
||||
val document = PsiDocumentManager.getInstance(project).getDocument(psiFile) ?: return null
|
||||
val lineNumber = document.getLineNumber(offset)
|
||||
val lineStart = document.getLineStartOffset(lineNumber)
|
||||
val lineEnd = document.getLineEndOffset(lineNumber)
|
||||
|
||||
return document.getText(TextRange(lineStart, lineEnd)).trim()
|
||||
}
|
||||
|
||||
private fun formatOutput(matches: List<SearchMatch>, args: Args): String {
|
||||
val outputMode = args.outputMode?.lowercase() ?: "content"
|
||||
|
||||
return when (outputMode) {
|
||||
"files_with_matches" -> {
|
||||
val lines = matches.map { it.file }.distinct()
|
||||
lines.take(MAX_OUTPUT_LINES).joinToString("\n").truncate(MAX_OUTPUT_CHARS)
|
||||
}
|
||||
|
||||
"count" -> {
|
||||
val fileCounts = matches.groupBy { it.file }.mapValues { it.value.size }
|
||||
val lines = fileCounts.map { "${it.key}: ${it.value}" }
|
||||
lines.take(MAX_OUTPUT_LINES).joinToString("\n").truncate(MAX_OUTPUT_CHARS)
|
||||
}
|
||||
|
||||
"content" -> {
|
||||
val lines = matches.map { match ->
|
||||
val location = if (match.line != null) {
|
||||
"${match.file}:${match.line}"
|
||||
} else {
|
||||
match.file
|
||||
}
|
||||
val context = match.context
|
||||
?.let {
|
||||
it.condenseWhitespace()
|
||||
.take(MAX_CONTEXT_CHARS) + if (it.length > MAX_CONTEXT_CHARS) "..." else ""
|
||||
}
|
||||
?.let { " | $it" } ?: ""
|
||||
"$location: ${match.text}$context"
|
||||
}
|
||||
lines.take(MAX_OUTPUT_LINES).joinToString("\n").truncate(MAX_OUTPUT_CHARS)
|
||||
}
|
||||
|
||||
else -> matches.joinToString("\n") { "${it.file}: ${it.text}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String {
|
||||
val raw = result.output
|
||||
val lines = if (raw.isEmpty()) emptyList() else raw.lines()
|
||||
val limitedLines = lines.take(MAX_OUTPUT_LINES)
|
||||
val limitedOutput = limitedLines.joinToString("\n").truncate(MAX_OUTPUT_CHARS)
|
||||
|
||||
val truncatedInfo = buildString {
|
||||
val linesTruncated = lines.size - limitedLines.size
|
||||
if (linesTruncated > 0) append(" (showing first ${limitedLines.size} lines)")
|
||||
val charsTruncated = raw.length - limitedOutput.length
|
||||
if (charsTruncated > 0) append(" (truncated $charsTruncated chars)")
|
||||
}
|
||||
|
||||
val s = buildString {
|
||||
appendLine("Pattern: ${result.pattern}")
|
||||
appendLine("Scope: ${result.scope}")
|
||||
appendLine("Total matches: ${result.totalMatches}")
|
||||
if (result.matches.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine(limitedOutput)
|
||||
if (truncatedInfo.isNotEmpty()) appendLine(truncatedInfo.trim())
|
||||
} else {
|
||||
appendLine("No matches found.")
|
||||
}
|
||||
}.trimEnd()
|
||||
return s.truncateToolResult()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.truncate(max: Int): String =
|
||||
if (length <= max) this else this.substring(0, max)
|
||||
|
||||
private fun String.condenseWhitespace(): String =
|
||||
this.replace("\r", " ").replace("\n", " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class KillShellTool : Tool<KillShellTool.Args, KillShellTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "KillShell",
|
||||
description = """
|
||||
- Kills a running background bash shell by its ID
|
||||
- Takes a shell_id parameter identifying the shell to kill
|
||||
- Returns a success or failure status
|
||||
- Use this tool when you need to terminate a long-running shell
|
||||
- Shell IDs can be found using the Bash tool with run_in_background=true
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The ID of the background shell to kill"
|
||||
)
|
||||
@SerialName("bash_id")
|
||||
val bashId: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
@SerialName("bash_id")
|
||||
val bashId: String,
|
||||
val success: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
|
||||
val process = BackgroundProcessManager.getProcess(args.bashId)
|
||||
?: return@withContext Result(
|
||||
bashId = args.bashId,
|
||||
success = false,
|
||||
message = "Shell not found. The shell may have already completed or the ID is invalid."
|
||||
)
|
||||
if (!process.isAlive) {
|
||||
val exitCode = runCatching { process.exitValue() }.getOrNull()
|
||||
return@withContext Result(
|
||||
bashId = args.bashId,
|
||||
success = false,
|
||||
message = "Shell has already completed${exitCode?.let { " with exit code $it" } ?: ""}"
|
||||
)
|
||||
}
|
||||
|
||||
return@withContext try {
|
||||
val terminated = BackgroundProcessManager.terminateProcess(args.bashId)
|
||||
if (terminated) {
|
||||
Result(
|
||||
bashId = args.bashId,
|
||||
success = true,
|
||||
message = "Shell successfully terminated."
|
||||
)
|
||||
} else {
|
||||
Result(
|
||||
bashId = args.bashId,
|
||||
success = false,
|
||||
message = "Failed to terminate shell. The process may have already completed."
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result(
|
||||
bashId = args.bashId,
|
||||
success = false,
|
||||
message = "Error terminating shell: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String =
|
||||
buildString {
|
||||
appendLine("Shell ID: ${result.bashId}")
|
||||
|
||||
if (result.success) {
|
||||
appendLine("✓ ${result.message}")
|
||||
} else {
|
||||
appendLine("✗ ${result.message}")
|
||||
}
|
||||
}.trimEnd().truncateToolResult()
|
||||
}
|
||||
208
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt
Normal file
208
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* Reads a file from the local filesystem using IntelliJ's Document and VirtualFile APIs.
|
||||
*/
|
||||
class ReadTool(private val project: Project) : Tool<ReadTool.Args, ReadTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "Read",
|
||||
description = """
|
||||
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
||||
Assume this tool is able to read all files on the machine. If the user provides a path to a file, assume that path is valid.
|
||||
|
||||
By default, reads up to 2000 lines starting from the beginning of the file.
|
||||
You can optionally specify a line offset and limit to read specific sections.
|
||||
Any lines longer than 2000 characters will be truncated.
|
||||
|
||||
Results are returned using cat -n format, with line numbers starting at 1.
|
||||
This tool can read images (eg PNG, JPG, etc), PDF files, and Jupyter notebooks.
|
||||
|
||||
- The file_path parameter must be an absolute path
|
||||
- For large files, use offset and limit to read in chunks
|
||||
- Images are returned visually for analysis
|
||||
- PDFs are processed page by page
|
||||
- Jupyter notebooks return all cells with outputs
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val logger = thisLogger()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The absolute path to the file to read. Must be an absolute path, not a relative path."
|
||||
)
|
||||
@SerialName("file_path")
|
||||
val filePath: String,
|
||||
@property:LLMDescription(
|
||||
"The line number to start reading from (1-indexed). Only provide if the file is too large to read at once."
|
||||
)
|
||||
val offset: Int? = null,
|
||||
@property:LLMDescription(
|
||||
"The number of lines to read. Only provide if the file is too large to read at once."
|
||||
)
|
||||
val limit: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class Result {
|
||||
@Serializable
|
||||
data class Success(
|
||||
val filePath: String,
|
||||
val content: String,
|
||||
val lineCount: Int,
|
||||
val truncated: Boolean,
|
||||
val fileType: String?,
|
||||
val startLine: Int? = null,
|
||||
val endLine: Int? = null
|
||||
) : Result()
|
||||
|
||||
@Serializable
|
||||
data class Error(
|
||||
val filePath: String,
|
||||
val error: String
|
||||
) : Result()
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): Result {
|
||||
return try {
|
||||
return runReadAction {
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
if (svc.isPathIgnored(args.filePath)) {
|
||||
return@runReadAction Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Access to this path is blocked by .proxyai ignore rules"
|
||||
)
|
||||
}
|
||||
|
||||
val virtualFile =
|
||||
VirtualFileManager.getInstance().findFileByUrl("file://${args.filePath}")
|
||||
?: return@runReadAction Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "File not found: ${args.filePath}"
|
||||
)
|
||||
|
||||
if (virtualFile.isDirectory) {
|
||||
return@runReadAction Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Path is a directory, not a file: ${args.filePath}"
|
||||
)
|
||||
}
|
||||
|
||||
val fileType = FileTypeManager.getInstance().getFileTypeByFile(virtualFile)
|
||||
|
||||
when {
|
||||
fileType.isBinary -> {
|
||||
Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Binary files are not supported by ProxyAI yet."
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val content = try {
|
||||
val document =
|
||||
FileDocumentManager.getInstance().getDocument(virtualFile)
|
||||
document?.text ?: String(
|
||||
virtualFile.contentsToByteArray(),
|
||||
StandardCharsets.UTF_8
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error reading document from $virtualFile", e)
|
||||
String(virtualFile.contentsToByteArray(), StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
val lines = content.lines()
|
||||
val totalLines = lines.size
|
||||
|
||||
val (startIdx, endIdx, truncated) = when {
|
||||
args.offset != null && args.limit != null -> {
|
||||
val start = (args.offset - 1).coerceIn(0, totalLines)
|
||||
val end = (start + args.limit).coerceIn(start, totalLines)
|
||||
Triple(start, end, end < totalLines)
|
||||
}
|
||||
|
||||
args.offset != null -> {
|
||||
val start = (args.offset - 1).coerceIn(0, totalLines)
|
||||
Triple(start, totalLines, false)
|
||||
}
|
||||
|
||||
args.limit != null -> {
|
||||
Triple(
|
||||
0,
|
||||
args.limit.coerceIn(0, totalLines),
|
||||
totalLines > args.limit
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val limit = 2000
|
||||
Triple(0, limit.coerceIn(0, totalLines), totalLines > limit)
|
||||
}
|
||||
}
|
||||
|
||||
val startLine = if (startIdx == 0) null else startIdx + 1
|
||||
val endLine = if (endIdx == totalLines) null else endIdx
|
||||
|
||||
val selectedLines = lines.subList(startIdx, endIdx)
|
||||
val numberedContent = buildString {
|
||||
selectedLines.forEachIndexed { index, line ->
|
||||
val lineNumber = startIdx + index + 1
|
||||
appendLine("${lineNumber}\t${line.take(2000)}")
|
||||
}
|
||||
}
|
||||
|
||||
Result.Success(
|
||||
filePath = args.filePath,
|
||||
content = numberedContent.trimEnd(),
|
||||
lineCount = selectedLines.size,
|
||||
truncated = truncated,
|
||||
fileType = fileType.name,
|
||||
startLine = startLine,
|
||||
endLine = endLine
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Failed to read file: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String = when (result) {
|
||||
is Result.Success -> {
|
||||
val raw = buildString {
|
||||
if (result.truncated) {
|
||||
appendLine("Note: Content truncated. Use offset parameter to read more lines.")
|
||||
appendLine()
|
||||
}
|
||||
append(result.content)
|
||||
}.trimEnd()
|
||||
raw.truncateToolResult()
|
||||
}
|
||||
|
||||
is Result.Error -> {
|
||||
("Error reading file '${result.filePath}': ${result.error}").truncateToolResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* Tool for resolving library names to Context7-compatible library IDs.
|
||||
*/
|
||||
class ResolveLibraryIdTool : Tool<ResolveLibraryIdTool.Args, ResolveLibraryIdTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "ResolveLibraryId",
|
||||
description = """
|
||||
Use to resolve a library/package name into a Context7-compatible ID before fetching docs. Prefer this when the user asks about libraries, best practices, APIs, or configuration, unless they already provided a valid '/org/project[/version]' ID.
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription("Library name to search for and retrieve a Context7-compatible library ID.")
|
||||
@SerialName("library_name")
|
||||
val libraryName: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LibraryInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val codeSnippets: Int,
|
||||
val sourceReputation: String,
|
||||
val benchmarkScore: Double,
|
||||
val versions: List<String>?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class Result {
|
||||
@Serializable
|
||||
data class Success(
|
||||
val libraryName: String,
|
||||
val libraries: List<LibraryInfo>
|
||||
) : Result()
|
||||
|
||||
@Serializable
|
||||
data class Error(
|
||||
val error: String
|
||||
) : Result()
|
||||
}
|
||||
|
||||
override val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val encodedQuery =
|
||||
URLEncoder.encode(args.libraryName, StandardCharsets.UTF_8.toString())
|
||||
val url = "https://context7.com/api/v2/search?query=$encodedQuery"
|
||||
|
||||
val doc = Jsoup.connect(url)
|
||||
.userAgent("Mozilla/5.0 (compatible; ProxyAI/1.0; +https://tryproxy.io)")
|
||||
.timeout(10000)
|
||||
.ignoreContentType(true)
|
||||
.get()
|
||||
|
||||
val jsonResponse = doc.body().text()
|
||||
if (jsonResponse.isEmpty()) {
|
||||
return@withContext Result.Error("Empty response from Context7 API")
|
||||
}
|
||||
|
||||
try {
|
||||
val searchResponse = json.decodeFromString<Context7SearchResponse>(jsonResponse)
|
||||
val libraries = searchResponse.results.map { result ->
|
||||
LibraryInfo(
|
||||
id = result.id,
|
||||
name = result.title ?: result.id.split("/").lastOrNull() ?: "Unknown",
|
||||
description = result.description ?: "No description available",
|
||||
codeSnippets = result.totalSnippets ?: 0,
|
||||
sourceReputation = when {
|
||||
result.trustScore == null -> "Unknown"
|
||||
result.trustScore > 8.0 -> "High"
|
||||
result.trustScore > 5.0 -> "Medium"
|
||||
else -> "Low"
|
||||
},
|
||||
benchmarkScore = result.benchmarkScore ?: 0.0,
|
||||
versions = result.versions
|
||||
)
|
||||
}
|
||||
|
||||
if (libraries.isEmpty()) {
|
||||
return@withContext Result.Success(
|
||||
libraryName = args.libraryName,
|
||||
libraries = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
return@withContext Result.Success(
|
||||
libraryName = args.libraryName,
|
||||
libraries = libraries
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@withContext Result.Error("Failed to parse Context7 response: ${e.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errorMessage = when {
|
||||
e.message?.contains("404", ignoreCase = true) == true ->
|
||||
"The Context7 service is currently unavailable. Please try again later."
|
||||
|
||||
e.message?.contains("429", ignoreCase = true) == true ->
|
||||
"Rate limited due to too many requests. Please try again later."
|
||||
|
||||
e.message?.contains("401", ignoreCase = true) == true ->
|
||||
"Unauthorized access to Context7 service."
|
||||
|
||||
else ->
|
||||
"Failed to search for libraries: ${e.message}"
|
||||
}
|
||||
return@withContext Result.Error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String = when (result) {
|
||||
is Result.Success -> {
|
||||
val s = if (result.libraries.isEmpty()) {
|
||||
"No libraries found for '${result.libraryName}'. Please try with different search terms or check the library name spelling."
|
||||
} else {
|
||||
buildString {
|
||||
appendLine("Available Libraries:")
|
||||
appendLine()
|
||||
appendLine("Each result includes:")
|
||||
appendLine("- Library ID: Context7-compatible identifier (format: /org/project)")
|
||||
appendLine("- Name: Library or package name")
|
||||
appendLine("- Description: Short summary")
|
||||
appendLine("- Code Snippets: Number of available code examples")
|
||||
appendLine("- Source Reputation: Authority indicator (High, Medium, Low, or Unknown)")
|
||||
appendLine("- Benchmark Score: Quality indicator (100 is the highest score)")
|
||||
appendLine("- Versions: List of versions if available. Use one of those versions if the user provides a version in their query. The format of the version is /org/project/version.")
|
||||
appendLine()
|
||||
appendLine("For best results, select libraries based on name match, source reputation, snippet coverage, benchmark score, and relevance to your use case.")
|
||||
appendLine()
|
||||
appendLine("----------")
|
||||
appendLine()
|
||||
|
||||
result.libraries.forEach { library ->
|
||||
appendLine("**${library.name}**")
|
||||
appendLine()
|
||||
appendLine("Library ID: `${library.id}`")
|
||||
if (library.description.isNotBlank()) {
|
||||
appendLine("Description: ${library.description}")
|
||||
}
|
||||
appendLine("Code Snippets: ${library.codeSnippets}")
|
||||
appendLine("Source Reputation: ${library.sourceReputation}")
|
||||
appendLine("Benchmark Score: ${library.benchmarkScore}")
|
||||
if (!library.versions.isNullOrEmpty()) {
|
||||
appendLine("Available Versions: ${library.versions.joinToString(", ")}")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
appendLine("**Recommended Selection:**")
|
||||
val topLibrary = result.libraries.maxByOrNull {
|
||||
(it.benchmarkScore * 0.4 + it.codeSnippets * 0.3 + when (it.sourceReputation.lowercase()) {
|
||||
"high" -> 30
|
||||
"medium" -> 20
|
||||
"low" -> 10
|
||||
else -> 0
|
||||
} * 0.3).toInt()
|
||||
}
|
||||
if (topLibrary != null) {
|
||||
appendLine("Based on the search results for '${result.libraryName}', the most relevant library is:")
|
||||
appendLine()
|
||||
appendLine("Library ID: `${topLibrary.id}`")
|
||||
appendLine("Name: ${topLibrary.name}")
|
||||
appendLine("Reasoning: Highest combined score of benchmark (${topLibrary.benchmarkScore}), code snippets (${topLibrary.codeSnippets}), and source reputation (${topLibrary.sourceReputation})")
|
||||
}
|
||||
}.trimEnd()
|
||||
}
|
||||
s.truncateToolResult()
|
||||
}
|
||||
|
||||
is Result.Error -> {
|
||||
("Failed to resolve library ID: ${result.error}").truncateToolResult()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class Context7SearchResponse(
|
||||
val results: List<Context7LibraryResult> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class Context7LibraryResult(
|
||||
val id: String,
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val branch: String? = null,
|
||||
val lastUpdateDate: String? = null,
|
||||
val state: String? = null,
|
||||
val totalTokens: Int? = null,
|
||||
val totalSnippets: Int? = null,
|
||||
val stars: Int? = null,
|
||||
val trustScore: Double? = null,
|
||||
val benchmarkScore: Double? = null,
|
||||
val versions: List<String>? = null
|
||||
)
|
||||
}
|
||||
305
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/TaskTool.kt
Normal file
305
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/TaskTool.kt
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.agent.GraphAIAgent
|
||||
import ai.koog.agents.core.feature.handler.tool.ToolCallCompletedContext
|
||||
import ai.koog.agents.core.feature.handler.tool.ToolCallStartingContext
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.ToolRegistry
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.EncodingManager
|
||||
import ee.carlrobert.codegpt.agent.*
|
||||
import ee.carlrobert.codegpt.conversations.message.TokenUsage
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISubagent
|
||||
import ee.carlrobert.codegpt.settings.agents.SubagentDefaults
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalType
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A tool for launching specialized agents to handle complex, multistep tasks autonomously.
|
||||
*/
|
||||
class TaskTool(
|
||||
private val project: Project,
|
||||
private val sessionId: String,
|
||||
private val provider: ServiceType,
|
||||
private val events: AgentEvents,
|
||||
) : Tool<TaskTool.Args, TaskTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "Task",
|
||||
description = buildTaskDescription(project)
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"A short (3-5 word) description of the task. This serves as a label for the task."
|
||||
)
|
||||
val description: String,
|
||||
@property:LLMDescription(
|
||||
"The detailed task description for the agent to perform autonomously. This should contain " +
|
||||
"clear instructions on what needs to be done, specific context and requirements, " +
|
||||
"expected output or deliverables, and any constraints or preferences."
|
||||
)
|
||||
val prompt: String,
|
||||
@property:LLMDescription(
|
||||
"The type of specialized agent to use. Must match one of: " +
|
||||
"\"general-purpose\" - For complex research, code search, and multi-step tasks, " +
|
||||
"\"explore\" - Fast exploration of codebases, finding files and patterns, " +
|
||||
"\"plan\" - Software architecture and implementation planning"
|
||||
)
|
||||
@SerialName("subagent_type")
|
||||
val subagentType: String,
|
||||
@property:LLMDescription(
|
||||
"Optional model override for this specific task"
|
||||
)
|
||||
val model: String? = null,
|
||||
@property:LLMDescription(
|
||||
"Optional project path for file operations and context"
|
||||
)
|
||||
@SerialName("project_path")
|
||||
val projectPath: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
val agentType: String,
|
||||
val description: String,
|
||||
val prompt: String,
|
||||
val output: String,
|
||||
val executionTime: Long,
|
||||
val totalTokens: Long = 0
|
||||
)
|
||||
|
||||
internal data class InternalResult(
|
||||
val agentType: String,
|
||||
val description: String,
|
||||
val prompt: String,
|
||||
val output: String,
|
||||
val executionTime: Long,
|
||||
val totalTokens: Long = 0,
|
||||
val tokenUsage: TokenUsage? = null
|
||||
)
|
||||
|
||||
override suspend fun execute(args: Args): Result {
|
||||
val startTime = System.currentTimeMillis()
|
||||
val parentId = ToolRunContext.getToolId(sessionId)
|
||||
?: throw IllegalStateException("No parent tool call found for session $sessionId")
|
||||
|
||||
try {
|
||||
val toolCallBridge = SubagentToolCallBridge(events, parentId, sessionId)
|
||||
val approvalHandler = approvalHandler(events)
|
||||
val configuredSubagent = if (isBuiltInAgentType(args.subagentType)) {
|
||||
null
|
||||
} else {
|
||||
findConfiguredSubagent(project, args.subagentType)
|
||||
}
|
||||
val agent = if (configuredSubagent != null) {
|
||||
AgentFactory.createManualAgent(
|
||||
provider,
|
||||
project,
|
||||
sessionId,
|
||||
configuredSubagent.title,
|
||||
configuredSubagent.behavior,
|
||||
configuredSubagent.tools,
|
||||
approveToolCall = approvalHandler,
|
||||
onAgentToolCallStarting = toolCallBridge::onToolCallStarting,
|
||||
onAgentToolCallCompleted = toolCallBridge::onToolCallCompleted,
|
||||
onCreditsAvailable = events::onCreditsAvailable
|
||||
)
|
||||
} else {
|
||||
val agentType = AgentType.fromString(args.subagentType)
|
||||
val builtInConfig = lookupBuiltInConfig(project, agentType)
|
||||
val extraBehavior = builtInConfig?.objective?.takeIf { it.isNotBlank() }
|
||||
val toolOverrides = builtInConfig?.let { SubagentTool.parse(it.tools) }
|
||||
AgentFactory.createAgent(
|
||||
agentType,
|
||||
provider,
|
||||
project,
|
||||
sessionId,
|
||||
approveToolCall = approvalHandler,
|
||||
onAgentToolCallStarting = toolCallBridge::onToolCallStarting,
|
||||
onAgentToolCallCompleted = toolCallBridge::onToolCallCompleted,
|
||||
extraBehavior = extraBehavior,
|
||||
toolOverrides = toolOverrides,
|
||||
onCreditsAvailable = events::onCreditsAvailable
|
||||
)
|
||||
}
|
||||
|
||||
toolCallBridge.setToolRegistry((agent as? GraphAIAgent<*, *>)?.toolRegistry)
|
||||
|
||||
val output = agent.run(args.prompt)
|
||||
return args.createResult(output, startTime)
|
||||
} catch (e: Exception) {
|
||||
return Result(
|
||||
agentType = args.subagentType,
|
||||
description = args.description,
|
||||
prompt = args.prompt,
|
||||
output = "Error: ${e.message}",
|
||||
executionTime = System.currentTimeMillis() - startTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Args.createResult(output: String, startTime: Long): Result {
|
||||
return Result(
|
||||
agentType = this.subagentType,
|
||||
description = this.description,
|
||||
prompt = this.prompt,
|
||||
output = output,
|
||||
executionTime = System.currentTimeMillis() - startTime,
|
||||
totalTokens = EncodingManager.getInstance().countTokens(output).toLong()
|
||||
)
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String {
|
||||
val summary = buildString {
|
||||
appendLine("Agent: ${result.agentType}")
|
||||
appendLine("Description: ${result.description}")
|
||||
appendLine("DurationMs: ${result.executionTime}")
|
||||
if (result.totalTokens > 0) {
|
||||
appendLine("TotalTokens: ${result.totalTokens}")
|
||||
}
|
||||
appendLine()
|
||||
appendLine("Output:")
|
||||
appendLine(result.output)
|
||||
}.trimEnd()
|
||||
return summary.truncateToolResult()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTaskDescription(project: Project): String {
|
||||
return buildString {
|
||||
appendLine(
|
||||
"""
|
||||
Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
|
||||
Built-in agent types:
|
||||
- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks.
|
||||
- explore: Fast agent specialized for exploring codebases.
|
||||
- plan: Software architect agent for designing implementation plans.
|
||||
|
||||
Custom subagents:
|
||||
- You may also pass the exact name of a configured subagent (as shown in ProxyAI Settings > Subagents) in the subagent_type field.
|
||||
|
||||
Usage notes:
|
||||
- Use a single message with multiple tool calls to launch agents in parallel when helpful.
|
||||
- Provide a detailed prompt and expected output.
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val customs = runCatching {
|
||||
project.service<ProxyAISettingsService>().getSubagents()
|
||||
.filterNot { SubagentDefaults.isBuiltInId(it.id) }
|
||||
}.getOrNull()?.takeIf { it.isNotEmpty() }
|
||||
if (customs != null) {
|
||||
appendLine()
|
||||
appendLine("Configured subagents available:")
|
||||
customs.forEach { sa ->
|
||||
val title = sa.title.trim()
|
||||
if (title.isEmpty()) return@forEach
|
||||
val desc = sa.objective.trim()
|
||||
append("- ")
|
||||
append(title)
|
||||
if (desc.isNotBlank()) {
|
||||
append(": ")
|
||||
append(desc.take(140))
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
private data class ConfiguredSubagent(
|
||||
val title: String,
|
||||
val behavior: String,
|
||||
val tools: Set<String>
|
||||
)
|
||||
|
||||
private fun isBuiltInAgentType(value: String): Boolean {
|
||||
return when (value.lowercase().trim()) {
|
||||
"general-purpose", "explore", "plan" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun findConfiguredSubagent(project: Project, name: String): ConfiguredSubagent? {
|
||||
val subagent = runCatching {
|
||||
project.service<ProxyAISettingsService>().getSubagents()
|
||||
.firstOrNull {
|
||||
!SubagentDefaults.isBuiltInId(it.id) && it.title.equals(
|
||||
name,
|
||||
ignoreCase = true
|
||||
)
|
||||
}
|
||||
}.getOrNull() ?: return null
|
||||
val title = subagent.title.takeIf { it.isNotBlank() } ?: name
|
||||
return ConfiguredSubagent(title, subagent.objective, subagent.tools.toSet())
|
||||
}
|
||||
|
||||
private fun lookupBuiltInConfig(project: Project, agentType: AgentType): ProxyAISubagent? {
|
||||
val id = SubagentDefaults.builtInIdFor(agentType) ?: return null
|
||||
return project.service<ProxyAISettingsService>().getSubagents().firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
private fun approvalHandler(events: AgentEvents): suspend (String, String) -> Boolean =
|
||||
{ name, details ->
|
||||
events.approveToolCall(ToolApprovalRequest(approvalTypeFor(name), "Allow $name?", details))
|
||||
}
|
||||
|
||||
private fun approvalTypeFor(name: String): ToolApprovalType {
|
||||
return when {
|
||||
name.equals("Write", true) -> ToolApprovalType.WRITE
|
||||
name.equals("Edit", true) -> ToolApprovalType.EDIT
|
||||
name.equals("Bash", true) -> ToolApprovalType.BASH
|
||||
else -> ToolApprovalType.GENERIC
|
||||
}
|
||||
}
|
||||
|
||||
private class SubagentToolCallBridge(
|
||||
private val events: AgentEvents,
|
||||
private val parentId: String,
|
||||
private val sessionId: String
|
||||
) {
|
||||
private val pendingChildIds = ArrayDeque<String>()
|
||||
private var toolRegistry: ToolRegistry? = null
|
||||
|
||||
fun setToolRegistry(registry: ToolRegistry?) {
|
||||
toolRegistry = registry
|
||||
}
|
||||
|
||||
fun onToolCallStarting(eventContext: ToolCallStartingContext) {
|
||||
val decodedArgs = ToolCallPayloadDecoder.decodeArgs(
|
||||
toolRegistry,
|
||||
eventContext.toolName,
|
||||
eventContext.toolArgs
|
||||
)
|
||||
val childId = events.onSubAgentToolStarting(parentId, eventContext.toolName, decodedArgs)
|
||||
if (childId != null) {
|
||||
pendingChildIds.addLast(childId)
|
||||
ToolRunContext.set(sessionId, childId)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallCompleted(eventContext: ToolCallCompletedContext) {
|
||||
val childId = if (pendingChildIds.isEmpty()) null else pendingChildIds.removeFirst()
|
||||
val decodedResult = ToolCallPayloadDecoder.decodeResult(
|
||||
toolRegistry,
|
||||
eventContext.toolName,
|
||||
eventContext.toolResult
|
||||
)
|
||||
events.onSubAgentToolCompleted(
|
||||
parentId,
|
||||
childId,
|
||||
eventContext.toolName,
|
||||
decodedResult
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.SimpleTool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.agent.ToolRunContext
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentTabTitleNotifier
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class TodoWriteTool(
|
||||
project: Project,
|
||||
private val sessionId: String
|
||||
) : SimpleTool<TodoWriteTool.Args>(
|
||||
argsSerializer = Args.serializer(),
|
||||
name = "TodoWrite",
|
||||
description = """
|
||||
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
|
||||
It also helps the user understand the progress of the task and overall progress of their requests.
|
||||
|
||||
## When to Use This Tool
|
||||
Use this tool proactively in these scenarios:
|
||||
|
||||
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
|
||||
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
|
||||
3. User explicitly requests todo list - When the user directly asks you to use the todo list
|
||||
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
|
||||
5. After receiving new instructions - Immediately capture user requirements as todos
|
||||
6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time
|
||||
7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip using this tool when:
|
||||
1. There is only a single, straightforward task
|
||||
2. The task is trivial and tracking it provides no organizational benefit
|
||||
3. The task can be completed in less than 3 trivial steps
|
||||
4. The task is purely conversational or informational
|
||||
|
||||
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
private val agentTabTitlePublisher =
|
||||
project.messageBus.syncPublisher(AgentTabTitleNotifier.AGENT_TAB_TITLE_TOPIC)
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription("Short task title (4 words max) in imperative form (e.g., 'Fix user authentication bug')")
|
||||
val title: String,
|
||||
@property:LLMDescription("The updated todo list")
|
||||
val todos: List<TodoItem>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TodoItem(
|
||||
@property:LLMDescription("The task description in imperative form (e.g., 'Run tests')")
|
||||
val content: String,
|
||||
@property:LLMDescription("Current status of the task: pending (not started), in_progress (currently working), or completed (finished)")
|
||||
val status: TodoStatus,
|
||||
@property:LLMDescription("The task description in present continuous form (e.g., 'Running tests')")
|
||||
val activeForm: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class TodoStatus {
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): String {
|
||||
val title = args.title
|
||||
if (title.isNotBlank()) {
|
||||
val currentContext = ToolRunContext.get(sessionId)
|
||||
val isMainAgentTodo = currentContext?.parentToolId == null
|
||||
|
||||
if (isMainAgentTodo) {
|
||||
val truncatedTitle = if (title.length > 50) title.take(47) + "..." else title
|
||||
agentTabTitlePublisher.updateTabTitle(sessionId, truncatedTitle)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingCount = args.todos.count { it.status == TodoStatus.PENDING }
|
||||
val inProgressCount = args.todos.count { it.status == TodoStatus.IN_PROGRESS }
|
||||
val completedCount = args.todos.count { it.status == TodoStatus.COMPLETED }
|
||||
|
||||
return buildString {
|
||||
appendLine("📋 Todo List Updated")
|
||||
appendLine()
|
||||
appendLine("Summary:")
|
||||
appendLine(" ⏳ Pending: $pendingCount")
|
||||
appendLine(" 🔄 In Progress: $inProgressCount")
|
||||
appendLine(" ✅ Completed: $completedCount")
|
||||
appendLine()
|
||||
appendLine("Current Tasks:")
|
||||
args.todos.forEachIndexed { index, todo ->
|
||||
val statusIcon = when (todo.status) {
|
||||
TodoStatus.PENDING -> "⏳"
|
||||
TodoStatus.IN_PROGRESS -> "🔄"
|
||||
TodoStatus.COMPLETED -> "✅"
|
||||
}
|
||||
val taskText = if (todo.status == TodoStatus.IN_PROGRESS) {
|
||||
todo.activeForm
|
||||
} else {
|
||||
todo.content
|
||||
}
|
||||
appendLine("${index + 1}. $statusIcon $taskText")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class WebSearchTool(
|
||||
private val userAgent: String = "Mozilla/5.0 (compatible; ProxyAI/1.0; +https://tryproxy.io)"
|
||||
) : Tool<WebSearchTool.Args, WebSearchTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "WebSearch",
|
||||
description = """
|
||||
IMPORTANT - When NOT to use this tool:
|
||||
- DO NOT use WebSearch for library, framework, or technical documentation queries
|
||||
- For questions about libraries, frameworks, SDKs, or APIs (e.g., "How to use React hooks?", "Python pandas documentation", "Spring Boot setup"), you MUST use ResolveLibraryId and GetLibraryDocs tools instead
|
||||
- WebSearch is for general web content, current events, and non-technical documentation queries only
|
||||
|
||||
- Allows Claude to search the web and use the results to inform responses
|
||||
- Provides up-to-date information for current events and recent data
|
||||
- Returns search result information formatted as search result blocks, including links as markdown hyperlinks
|
||||
- Use this tool for accessing information beyond Claude's knowledge cutoff
|
||||
- Searches are performed automatically within a single API call
|
||||
|
||||
CRITICAL REQUIREMENT - You MUST follow this:
|
||||
- After answering the user's question, you MUST include a "Sources:" section at the end of your response
|
||||
- In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)
|
||||
- This is MANDATORY - never skip including sources in your response
|
||||
- Example format:
|
||||
|
||||
[Your answer here]
|
||||
|
||||
Sources:
|
||||
- [Source Title 1](https://example.com/1)
|
||||
- [Source Title 2](https://example.com/2)
|
||||
|
||||
Usage notes:
|
||||
- Domain filtering is supported to include or block specific websites
|
||||
- Web search is only available in the US
|
||||
|
||||
IMPORTANT - Use the correct year in search queries:
|
||||
- Today's date is 2025-12-04. You MUST use this year when searching for recent information, documentation, or current events.
|
||||
- Example: If today is 2025-07-15 and the user asks for "latest React docs", search for "React documentation 2025", NOT "React documentation 2024"
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The search query to use"
|
||||
)
|
||||
val query: String,
|
||||
@property:LLMDescription(
|
||||
"Only include search results from these domains"
|
||||
)
|
||||
@SerialName("allowed_domains")
|
||||
val allowedDomains: List<String>? = null,
|
||||
@property:LLMDescription(
|
||||
"Never include search results from these domains"
|
||||
)
|
||||
@SerialName("blocked_domains")
|
||||
val blockedDomains: List<String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResult(
|
||||
val title: String,
|
||||
val url: String,
|
||||
val content: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
val query: String,
|
||||
val results: List<SearchResult>,
|
||||
val sources: List<String>
|
||||
)
|
||||
|
||||
override suspend fun execute(args: Args): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val searxResults = searchWithSearxNG(args.query)
|
||||
if (searxResults.isNotEmpty()) {
|
||||
val filteredResults =
|
||||
filterResults(searxResults, args.allowedDomains, args.blockedDomains)
|
||||
return@withContext Result(
|
||||
query = args.query,
|
||||
results = filteredResults.take(10),
|
||||
sources = filteredResults.take(10).map { "[${it.title}](${it.url})" }
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fall back to DuckDuckGo
|
||||
}
|
||||
|
||||
try {
|
||||
val duckduckgoResults = searchWithDuckDuckGo(args.query)
|
||||
val filteredResults =
|
||||
filterResults(duckduckgoResults, args.allowedDomains, args.blockedDomains)
|
||||
return@withContext Result(
|
||||
query = args.query,
|
||||
results = filteredResults.take(10),
|
||||
sources = filteredResults.take(10).map { "[${it.title}](${it.url})" }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@withContext Result(
|
||||
query = args.query,
|
||||
results = emptyList(),
|
||||
sources = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun searchWithSearxNG(query: String): List<SearchResult> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
|
||||
val url = "https://searx.space/search?q=$encodedQuery&format=json"
|
||||
|
||||
val doc = Jsoup.connect(url)
|
||||
.userAgent(userAgent)
|
||||
.timeout(10000)
|
||||
.ignoreContentType(true)
|
||||
.get()
|
||||
|
||||
val jsonResponse = doc.body().text()
|
||||
if (jsonResponse.isEmpty()) return@withContext emptyList()
|
||||
|
||||
try {
|
||||
val searxResponse = json.decodeFromString<SearxResponse>(jsonResponse)
|
||||
searxResponse.results.map { result ->
|
||||
SearchResult(
|
||||
title = result.title,
|
||||
url = result.url,
|
||||
content = result.content
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun searchWithDuckDuckGo(query: String): List<SearchResult> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
|
||||
val url = "https://duckduckgo.com/html/?q=$encodedQuery"
|
||||
|
||||
val doc = Jsoup.connect(url)
|
||||
.userAgent(userAgent)
|
||||
.timeout(10000)
|
||||
.get()
|
||||
|
||||
val results = mutableListOf<SearchResult>()
|
||||
|
||||
doc.select("div.result").forEach { resultDiv ->
|
||||
val titleLink = resultDiv.selectFirst("a.result__a")
|
||||
val snippet = resultDiv.selectFirst("a.result__snippet")
|
||||
|
||||
if (titleLink != null) {
|
||||
results.add(
|
||||
SearchResult(
|
||||
title = titleLink.text(),
|
||||
url = titleLink.attr("href"),
|
||||
content = snippet?.text() ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
private fun filterResults(
|
||||
results: List<SearchResult>,
|
||||
allowedDomains: List<String>?,
|
||||
blockedDomains: List<String>?
|
||||
): List<SearchResult> {
|
||||
return results.filter { result ->
|
||||
val urlLower = result.url.lowercase()
|
||||
|
||||
blockedDomains?.any { domain ->
|
||||
urlLower.contains(domain.lowercase())
|
||||
}?.let { if (it) return@filter false }
|
||||
|
||||
allowedDomains?.let { allowed ->
|
||||
allowed.any { domain ->
|
||||
urlLower.contains(domain.lowercase())
|
||||
}
|
||||
} ?: true
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String =
|
||||
buildString {
|
||||
if (result.results.isEmpty()) {
|
||||
appendLine("No search results found for query: ${result.query}")
|
||||
return@buildString
|
||||
}
|
||||
|
||||
result.results.forEach { searchResult ->
|
||||
appendLine()
|
||||
appendLine("[${searchResult.title}](${searchResult.url})")
|
||||
if (searchResult.content.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine(searchResult.content)
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}.trimEnd().truncateToolResult()
|
||||
|
||||
@Serializable
|
||||
private data class SearxResponse(
|
||||
val results: List<SearxResult>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SearxResult(
|
||||
val title: String,
|
||||
val url: String,
|
||||
val content: String
|
||||
)
|
||||
}
|
||||
170
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WriteTool.kt
Normal file
170
src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WriteTool.kt
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package ee.carlrobert.codegpt.agent.tools
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.application.runWriteAction
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* Writes content to files, creating new files or overwriting existing ones.
|
||||
*
|
||||
* Uses IntelliJ's Document API for proper IDE integration with undo/redo support.
|
||||
* Handles both project files and external files with appropriate project context.
|
||||
*/
|
||||
class WriteTool(private val project: Project) : Tool<WriteTool.Args, WriteTool.Result>(
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "Write",
|
||||
description = """
|
||||
Writes content to a file, creating it if it doesn't exist or overwriting if it does.
|
||||
|
||||
This tool creates parent directories if they don't exist and handles both
|
||||
new file creation and existing file overwrites.
|
||||
|
||||
Parameters:
|
||||
- file_path: Absolute path to the file to write (required)
|
||||
- content: The content to write to the file (required, cannot be empty)
|
||||
|
||||
Important notes:
|
||||
- Always use absolute paths
|
||||
- Parent directories are created automatically
|
||||
- Existing files will be overwritten completely
|
||||
- Uses UTF-8 encoding by default
|
||||
- Binary content should be handled differently
|
||||
- Content field is required and cannot be empty
|
||||
""".trimIndent()
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@property:LLMDescription(
|
||||
"The absolute path to the file to write. Must be an absolute path, not a relative path."
|
||||
)
|
||||
@SerialName("file_path")
|
||||
val filePath: String = "",
|
||||
@property:LLMDescription(
|
||||
"The content to write to the file. Must not be empty."
|
||||
)
|
||||
val content: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class Result {
|
||||
@Serializable
|
||||
data class Success(
|
||||
val filePath: String,
|
||||
val bytesWritten: Int,
|
||||
val isNewFile: Boolean,
|
||||
val message: String
|
||||
) : Result()
|
||||
|
||||
@Serializable
|
||||
data class Error(
|
||||
val filePath: String,
|
||||
val error: String
|
||||
) : Result()
|
||||
}
|
||||
|
||||
override suspend fun execute(args: Args): Result {
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
if (svc.isPathIgnored(args.filePath)) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = ".proxyai ignore rules block writing to this path"
|
||||
)
|
||||
}
|
||||
if (args.content.isBlank()) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Content field is required and cannot be empty"
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
val file = File(args.filePath)
|
||||
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val isNewFile = !file.exists()
|
||||
|
||||
if (file.exists() && !file.canWrite()) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "File is not writable: ${args.filePath}"
|
||||
)
|
||||
}
|
||||
|
||||
val virtualFile =
|
||||
VirtualFileManager.getInstance().findFileByUrl("file://${args.filePath}")
|
||||
?: if (isNewFile) {
|
||||
VirtualFileManager.getInstance().findFileByUrl("file://${args.filePath}")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val bytesWritten = args.content.toByteArray(StandardCharsets.UTF_8).size
|
||||
val projectToUse = project
|
||||
|
||||
if (virtualFile != null) {
|
||||
val document =
|
||||
runReadAction { FileDocumentManager.getInstance().getDocument(virtualFile) }
|
||||
runInEdt {
|
||||
if (document != null) {
|
||||
runWriteAction {
|
||||
PsiDocumentManager.getInstance(projectToUse).commitDocument(document)
|
||||
document.setText(StringUtil.convertLineSeparators(args.content))
|
||||
FileDocumentManager.getInstance().saveDocument(document)
|
||||
}
|
||||
} else {
|
||||
runWriteAction {
|
||||
virtualFile.setBinaryContent(args.content.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isNewFile) {
|
||||
runInEdt {
|
||||
runWriteAction {
|
||||
file.writeText(
|
||||
StringUtil.convertLineSeparators(args.content),
|
||||
StandardCharsets.UTF_8
|
||||
)
|
||||
VirtualFileManager.getInstance().syncRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val action = if (isNewFile) "created" else "overwritten"
|
||||
|
||||
Result.Success(
|
||||
filePath = args.filePath,
|
||||
bytesWritten = bytesWritten,
|
||||
isNewFile = isNewFile,
|
||||
message = "File $action successfully. $bytesWritten bytes written."
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Failed to write file: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeResultToString(result: Result): String = when (result) {
|
||||
is Result.Success -> ("File '${result.filePath}' ${result.message}").truncateToolResult()
|
||||
|
||||
is Result.Error -> ("Error writing file '${result.filePath}': ${result.error}").truncateToolResult()
|
||||
}
|
||||
}
|
||||
|
|
@ -65,7 +65,6 @@ class ClaudeRequestFactory : BaseRequestFactory() {
|
|||
if (params.toolResults.isNullOrEmpty()) {
|
||||
when {
|
||||
params.imageDetails != null -> {
|
||||
println("DEBUG [ClaudeRequestFactory]: Adding image message")
|
||||
messages.add(
|
||||
ClaudeCompletionDetailedMessage(
|
||||
"user",
|
||||
|
|
@ -142,7 +141,6 @@ class ClaudeRequestFactory : BaseRequestFactory() {
|
|||
|
||||
try {
|
||||
if (isCurrentMessageWithToolResults) {
|
||||
println(" - This is the current message with tool results, adding them now")
|
||||
return mutableListOf()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package ee.carlrobert.codegpt.conversations.message
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import java.util.UUID
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class QueuedMessage(
|
||||
var prompt: String,
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
var timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package ee.carlrobert.codegpt.conversations.message
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class TokenUsage(
|
||||
val promptTokens: Long,
|
||||
val completionTokens: Long,
|
||||
val toolTokens: Long = 0,
|
||||
val subAgentTokens: Long = 0,
|
||||
val totalTokens: Long = promptTokens + completionTokens + toolTokens + subAgentTokens
|
||||
)
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package ee.carlrobert.codegpt.conversations.message
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class TokenUsageTracker {
|
||||
private val promptTokens = AtomicLong(0)
|
||||
private val completionTokens = AtomicLong(0)
|
||||
private val toolTokens = AtomicLong(0)
|
||||
private val subAgentTokens = AtomicLong(0)
|
||||
|
||||
fun addPromptTokens(tokens: Long) {
|
||||
promptTokens.addAndGet(tokens)
|
||||
}
|
||||
|
||||
fun addCompletionTokens(tokens: Long) {
|
||||
completionTokens.addAndGet(tokens)
|
||||
}
|
||||
|
||||
fun addToolTokens(tokens: Long) {
|
||||
toolTokens.addAndGet(tokens)
|
||||
}
|
||||
|
||||
fun addSubAgentTokens(tokens: Long) {
|
||||
subAgentTokens.addAndGet(tokens)
|
||||
}
|
||||
|
||||
fun getPromptTokens(): Long = promptTokens.get()
|
||||
|
||||
fun getTotalTokens(): Long =
|
||||
promptTokens.get() + completionTokens.get() + toolTokens.get() + subAgentTokens.get()
|
||||
|
||||
fun updateTokenUsage(tokenUsage: TokenUsage?) {
|
||||
tokenUsage?.let { usage ->
|
||||
addPromptTokens(usage.promptTokens)
|
||||
addCompletionTokens(usage.completionTokens)
|
||||
addToolTokens(usage.toolTokens)
|
||||
addSubAgentTokens(usage.subAgentTokens)
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
promptTokens.set(0)
|
||||
completionTokens.set(0)
|
||||
toolTokens.set(0)
|
||||
subAgentTokens.set(0)
|
||||
}
|
||||
|
||||
fun isEmpty(): Boolean = getTotalTokens() == 0L
|
||||
}
|
||||
|
|
@ -513,6 +513,7 @@ class InlineEditInlayRenderer(
|
|||
change.isAccepted = true
|
||||
removeChangeVisuals(change)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error accepting change during bulk apply", e)
|
||||
logger.error("Error accepting change during bulk apply", e)
|
||||
}
|
||||
}
|
||||
|
|
@ -583,6 +584,7 @@ class InlineEditInlayRenderer(
|
|||
try {
|
||||
editor.markupModel.removeHighlighter(highlighter)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error removing highlighter during disposal", e)
|
||||
logger.error("Error removing highlighter during disposal", e)
|
||||
}
|
||||
}
|
||||
|
|
@ -595,6 +597,7 @@ class InlineEditInlayRenderer(
|
|||
change.additionInlay?.dispose()
|
||||
change.buttonInlay?.dispose()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error disposing change inlays during disposal", e)
|
||||
logger.error("Error disposing change inlays during disposal", e)
|
||||
}
|
||||
}
|
||||
|
|
@ -608,6 +611,7 @@ class InlineEditInlayRenderer(
|
|||
ui.buttonInlay?.dispose()
|
||||
ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error disposing hunk UI during disposal", e)
|
||||
logger.error("Error disposing hunk UI during disposal", e)
|
||||
}
|
||||
}
|
||||
|
|
@ -615,7 +619,13 @@ class InlineEditInlayRenderer(
|
|||
}
|
||||
|
||||
private fun disposeTopPanel() {
|
||||
try {
|
||||
topPanelDisposable?.dispose()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Error disposing top panel", e)
|
||||
}
|
||||
topPanelDisposable?.dispose()
|
||||
topPanelDisposable = null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -282,6 +282,8 @@ class McpToolCallEventListener(
|
|||
},
|
||||
onReject = { toolCall, panel ->
|
||||
cleanupAndComplete()
|
||||
toolCallHandler.cancelAllPendingApprovals()
|
||||
toolCallHandler.cancelAllExecutions()
|
||||
},
|
||||
onBatchApprove = { panels, _ ->
|
||||
toolCallsWithServer.forEachIndexed { index, (mcpToolCall, openAIToolCall, serverName) ->
|
||||
|
|
@ -308,6 +310,8 @@ class McpToolCallEventListener(
|
|||
},
|
||||
onBatchReject = { panels ->
|
||||
cleanupAndComplete()
|
||||
toolCallHandler.cancelAllPendingApprovals()
|
||||
toolCallHandler.cancelAllExecutions()
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -547,6 +551,7 @@ class McpToolCallEventListener(
|
|||
toolName = toolCall.name,
|
||||
serverName = serverName,
|
||||
initialStatus = "Executing...",
|
||||
args = toolCall.arguments,
|
||||
)
|
||||
activeStatusPanels[toolCall.id] = statusPanel
|
||||
onToolCallUIUpdate(statusPanel)
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class McpToolCallHandler {
|
|||
toolName = toolCall.name,
|
||||
serverName = actualServerName,
|
||||
initialStatus = "Executing...",
|
||||
args = toolCall.arguments,
|
||||
)
|
||||
statusPanels[toolCall.id] = statusPanel
|
||||
onUIUpdate(statusPanel)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
package ee.carlrobert.codegpt.settings
|
||||
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.settings.agents.SubagentDefaults
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.io.path.inputStream
|
||||
import kotlin.io.path.notExists
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
class ProxyAISettingsService(private val project: Project) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val logger = thisLogger()
|
||||
private val settingsFile: Path by lazy {
|
||||
Paths.get(project.basePath ?: "", ".proxyai", "settings.json")
|
||||
}
|
||||
private val store: ProxyAISettingsStore by lazy { ProxyAISettingsStore(settingsFile, json, logger) }
|
||||
private val cache = AtomicReference<CachedSettings?>(null)
|
||||
private val isWindows = System.getProperty("os.name")?.lowercase()?.contains("windows") == true
|
||||
|
||||
fun getSubagents(): List<ProxyAISubagent> {
|
||||
val settings = snapshot().settings
|
||||
return SubagentDefaults.ensureBuiltIns(settings.subagents)
|
||||
}
|
||||
|
||||
fun saveSubagents(subagents: List<ProxyAISubagent>) {
|
||||
val normalized = SubagentDefaults.ensureBuiltIns(subagents)
|
||||
updateSettings { it.copy(subagents = normalized) }
|
||||
}
|
||||
|
||||
fun getBashPermissions(): List<String> {
|
||||
return snapshot().settings.permissions.allow
|
||||
}
|
||||
|
||||
fun getIgnorePatterns(): List<String> {
|
||||
return snapshot().settings.ignore
|
||||
}
|
||||
|
||||
fun isPathIgnored(path: String): Boolean {
|
||||
return isPathIgnored(path, project.basePath ?: "")
|
||||
}
|
||||
|
||||
fun isPathIgnored(path: String, basePath: String): Boolean {
|
||||
return snapshot().ignoreMatcher.matches(path, basePath)
|
||||
}
|
||||
|
||||
private fun snapshot(): CachedSettings {
|
||||
val cached = cache.get()
|
||||
val lastModified = store.lastModified()
|
||||
if (cached != null && cached.lastModified == lastModified) {
|
||||
return cached
|
||||
}
|
||||
val settings = store.load() ?: ProxyAISettings.default()
|
||||
val ignoreMatcher = IgnoreMatcher.from(settings.ignore, isWindows)
|
||||
val snapshot = CachedSettings(settings, lastModified, ignoreMatcher)
|
||||
cache.set(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun updateSettings(transform: (ProxyAISettings) -> ProxyAISettings) {
|
||||
val current = snapshot().settings
|
||||
val updated = transform(current)
|
||||
if (!store.save(updated)) return
|
||||
val lastModified = store.lastModified()
|
||||
val ignoreMatcher = IgnoreMatcher.from(updated.ignore, isWindows)
|
||||
cache.set(CachedSettings(updated, lastModified, ignoreMatcher))
|
||||
}
|
||||
}
|
||||
|
||||
private data class CachedSettings(
|
||||
val settings: ProxyAISettings,
|
||||
val lastModified: Long,
|
||||
val ignoreMatcher: IgnoreMatcher
|
||||
)
|
||||
|
||||
private class ProxyAISettingsStore(
|
||||
private val settingsFile: Path,
|
||||
private val json: Json,
|
||||
private val logger: Logger
|
||||
) {
|
||||
fun load(): ProxyAISettings? {
|
||||
if (settingsFile.notExists()) return null
|
||||
return try {
|
||||
settingsFile.inputStream().use { inputStream ->
|
||||
json.decodeFromStream<ProxyAISettings>(inputStream)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Failed to load ProxyAI settings from $settingsFile", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun save(settings: ProxyAISettings): Boolean {
|
||||
return try {
|
||||
settingsFile.parent?.let { Files.createDirectories(it) }
|
||||
Files.newOutputStream(
|
||||
settingsFile,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE
|
||||
).use { outputStream ->
|
||||
json.encodeToStream(settings, outputStream)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Failed to write ProxyAI settings to $settingsFile", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun lastModified(): Long {
|
||||
return try {
|
||||
if (settingsFile.notExists()) -1 else Files.getLastModifiedTime(settingsFile).toMillis()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Failed to read ProxyAI settings modified time from $settingsFile", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class IgnoreMatcher(
|
||||
private val compiled: List<Pair<String, Regex>>,
|
||||
private val isWindows: Boolean
|
||||
) {
|
||||
fun matches(path: String, basePath: String): Boolean {
|
||||
val normalizedPath = normalizePath(path)
|
||||
val rel = if (basePath.isNotBlank() && normalizedPath.startsWith(basePath)) {
|
||||
normalizedPath.removePrefix(basePath).removePrefix("/")
|
||||
} else normalizedPath
|
||||
|
||||
val pathToTest = if (isWindows) normalizedPath.lowercase() else normalizedPath
|
||||
val relToTest = if (isWindows) rel.lowercase() else rel
|
||||
return compiled.any { (raw, rx) ->
|
||||
val normalized = if (isWindows) raw.lowercase() else raw
|
||||
simpleMatch(relToTest, normalized) ||
|
||||
simpleMatch(pathToTest, normalized) ||
|
||||
rx.matches(relToTest) ||
|
||||
rx.matches(pathToTest)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(patterns: List<String>, isWindows: Boolean): IgnoreMatcher {
|
||||
val compiled = patterns.mapNotNull { pattern ->
|
||||
val rx = globToRegex(pattern, ignoreCase = isWindows) ?: return@mapNotNull null
|
||||
pattern to rx
|
||||
}
|
||||
return IgnoreMatcher(compiled, isWindows)
|
||||
}
|
||||
private fun globToRegex(glob: String, ignoreCase: Boolean = false): Regex? {
|
||||
return try {
|
||||
var g = glob.trim()
|
||||
if (g.isEmpty()) return null
|
||||
val dirSuffix = g.endsWith("/")
|
||||
g = g.trimEnd('/')
|
||||
val sb = StringBuilder()
|
||||
sb.append('^')
|
||||
var i = 0
|
||||
while (i < g.length) {
|
||||
when (val c = g[i]) {
|
||||
'*' -> {
|
||||
val nextStar = (i + 1 < g.length && g[i + 1] == '*')
|
||||
if (nextStar) {
|
||||
sb.append(".*")
|
||||
i += 1
|
||||
} else {
|
||||
sb.append("[^/]*")
|
||||
}
|
||||
}
|
||||
'?' -> sb.append(".")
|
||||
'.', '(', ')', '+', '|', '^', '$', '@', '%' -> sb.append('\\').append(c)
|
||||
'[' -> sb.append("[")
|
||||
']' -> sb.append("]")
|
||||
'{' -> sb.append('(')
|
||||
'}' -> sb.append(')')
|
||||
',' -> sb.append('|')
|
||||
else -> sb.append(c)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if (dirSuffix) sb.append("(/.*)?")
|
||||
sb.append('$')
|
||||
if (ignoreCase) Regex(sb.toString(), RegexOption.IGNORE_CASE) else Regex(sb.toString())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizePath(path: String): String {
|
||||
return try {
|
||||
Paths.get(path).normalize().toString().replace('\\', '/')
|
||||
} catch (_: Exception) {
|
||||
path.replace('\\', '/')
|
||||
}
|
||||
}
|
||||
|
||||
private fun simpleMatch(path: String, pattern: String): Boolean {
|
||||
val p = path.trimStart('/')
|
||||
val pat = pattern.trim()
|
||||
if (pat == p) return true
|
||||
if (pat.endsWith("/") && p.startsWith(pat.trimEnd('/'))) return true
|
||||
if (!pat.contains('/') && pat.startsWith('.')) {
|
||||
val fileName = p.substringAfterLast('/')
|
||||
if (fileName == pat) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProxyAISettings(
|
||||
val ignore: List<String>,
|
||||
val permissions: Permissions,
|
||||
val subagents: List<ProxyAISubagent> = emptyList()
|
||||
) {
|
||||
companion object {
|
||||
fun default(): ProxyAISettings {
|
||||
return ProxyAISettings(
|
||||
ignore = listOf(
|
||||
".idea/",
|
||||
"*.iml",
|
||||
"build/",
|
||||
"dist/",
|
||||
"node_modules/",
|
||||
".git/",
|
||||
),
|
||||
permissions = Permissions(
|
||||
allow = listOf(
|
||||
"Bash(rg:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(diff:*)",
|
||||
"Bash(du:*)",
|
||||
"Bash(file:*)",
|
||||
"Bash(sort:*)",
|
||||
"Bash(stat:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(uniq:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(whereis:*)",
|
||||
"Bash(which:*)",
|
||||
"Bash(less:*)",
|
||||
"Bash(more:*)",
|
||||
)
|
||||
),
|
||||
subagents = SubagentDefaults.defaults()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Permissions(
|
||||
val allow: List<String>
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProxyAISubagent(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val objective: String,
|
||||
val tools: List<String>
|
||||
)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package ee.carlrobert.codegpt.settings.agents
|
||||
|
||||
import ee.carlrobert.codegpt.agent.AgentType
|
||||
import ee.carlrobert.codegpt.agent.SubagentTool
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISubagent
|
||||
|
||||
object SubagentDefaults {
|
||||
const val EXPLORER_ID = 1
|
||||
const val GENERAL_ID = 2
|
||||
|
||||
fun defaults(): List<ProxyAISubagent> {
|
||||
val explorerTools = SubagentTool.toStoredValues(SubagentTool.readOnly + SubagentTool.BASH)
|
||||
val generalTools = SubagentTool.toStoredValues(SubagentTool.entries)
|
||||
return listOf(
|
||||
ProxyAISubagent(
|
||||
id = EXPLORER_ID,
|
||||
title = "Explorer",
|
||||
objective = "Read-only subagent for codebase exploration and insight gathering.",
|
||||
tools = explorerTools
|
||||
),
|
||||
ProxyAISubagent(
|
||||
id = GENERAL_ID,
|
||||
title = "General Task",
|
||||
objective = "General-purpose subagent for executing concrete coding tasks.",
|
||||
tools = generalTools
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun ensureBuiltIns(subagents: List<ProxyAISubagent>): List<ProxyAISubagent> {
|
||||
val defaults = defaults()
|
||||
val byId = subagents.associateBy { it.id }
|
||||
val explorer = byId[EXPLORER_ID] ?: defaults.first { it.id == EXPLORER_ID }
|
||||
val general = byId[GENERAL_ID] ?: defaults.first { it.id == GENERAL_ID }
|
||||
val custom = subagents.filterNot { isBuiltInId(it.id) }
|
||||
return listOf(explorer, general) + custom
|
||||
}
|
||||
|
||||
fun isBuiltInId(id: Int): Boolean = id == EXPLORER_ID || id == GENERAL_ID
|
||||
|
||||
fun builtInIdFor(agentType: AgentType): Int? {
|
||||
return when (agentType) {
|
||||
AgentType.GENERAL_PURPOSE -> GENERAL_ID
|
||||
AgentType.EXPLORE -> EXPLORER_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package ee.carlrobert.codegpt.settings.agents
|
||||
|
||||
import ai.koog.prompt.message.Message
|
||||
import ai.koog.prompt.dsl.prompt
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.agent.AgentFactory
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
|
||||
data class GeneratedSubagent(val title: String, val description: String)
|
||||
|
||||
object SubagentGenerator {
|
||||
suspend fun generate(query: String): GeneratedSubagent {
|
||||
return try {
|
||||
val modelService = service<ModelSelectionService>()
|
||||
val provider: ServiceType = modelService.getServiceForFeature(FeatureType.AGENT)
|
||||
val model = modelService.getAgentModel()
|
||||
val executor = AgentFactory.createExecutor(provider)
|
||||
|
||||
val p = prompt("subagent-generator") {
|
||||
system(
|
||||
"""
|
||||
You generate concise subagent definitions for a JetBrains plugin.
|
||||
Output only a single compact JSON object with keys: "title" and "description".
|
||||
- title: 3-6 words, concise, sentence case
|
||||
- description: 3-4 sentences, actionable behavior summary
|
||||
No prose, no markdown, no code fences.
|
||||
""".trimIndent()
|
||||
)
|
||||
user(query)
|
||||
}
|
||||
|
||||
val responses = executor.execute(p, model, emptyList())
|
||||
val text = responses.filterIsInstance<Message.Assistant>()
|
||||
.joinToString("\n") { it.content.trim() }
|
||||
|
||||
parseJson(text) ?: fallback(query)
|
||||
} catch (_: Exception) {
|
||||
fallback(query)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJson(text: String): GeneratedSubagent? {
|
||||
return try {
|
||||
val mapper = ObjectMapper().registerKotlinModule()
|
||||
val trimmed = text.trim().trim('`')
|
||||
val node = mapper.readTree(trimmed)
|
||||
val title = node.get("title")?.asText()?.trim().orEmpty()
|
||||
val description = node.get("description")?.asText()?.trim().orEmpty()
|
||||
if (title.isNotBlank() && description.isNotBlank()) GeneratedSubagent(title, description) else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallback(query: String): GeneratedSubagent {
|
||||
val safe = query.trim()
|
||||
val title = safe.split('.', '!', '?', '\u000A')
|
||||
.firstOrNull()
|
||||
?.take(60)
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
?: "Generated Subagent"
|
||||
val description = if (safe.isNotBlank()) safe else "Auto-generated subagent"
|
||||
return GeneratedSubagent(title, description)
|
||||
}
|
||||
|
||||
fun generateBlocking(query: String): GeneratedSubagent = kotlinx.coroutines.runBlocking {
|
||||
generate(query)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package ee.carlrobert.codegpt.settings.agents
|
||||
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.settings.agents.form.SubagentsForm
|
||||
import javax.swing.JComponent
|
||||
|
||||
class SubagentsConfigurable(private val project: Project) : Configurable {
|
||||
private lateinit var form: SubagentsForm
|
||||
|
||||
override fun getDisplayName(): String = "ProxyAI: Subagents"
|
||||
|
||||
override fun createComponent(): JComponent {
|
||||
form = SubagentsForm(project)
|
||||
return form.createPanel()
|
||||
}
|
||||
|
||||
override fun isModified(): Boolean = form.isModified()
|
||||
|
||||
override fun apply() {
|
||||
form.applyChanges()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
form.resetChanges()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package ee.carlrobert.codegpt.settings.agents.form
|
||||
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.ScrollPaneFactory
|
||||
import com.intellij.ui.IdeBorderFactory
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.agent.SubagentTool
|
||||
import java.awt.Dimension
|
||||
import java.awt.BorderLayout
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
data class SubagentDetails(
|
||||
val id: Int,
|
||||
var title: String,
|
||||
var description: String,
|
||||
var tools: MutableSet<SubagentTool>
|
||||
)
|
||||
|
||||
class SubagentDetailsPanel(
|
||||
private val readOnlyTools: List<SubagentTool>,
|
||||
private val writeTools: List<SubagentTool>
|
||||
) {
|
||||
private val titleField = JBTextField()
|
||||
private val descriptionArea = JBTextArea()
|
||||
private val allTools = (readOnlyTools + writeTools).distinct()
|
||||
private val toolBoxes = allTools.associateWith { JBCheckBox(it.displayName) }
|
||||
|
||||
private var current: SubagentDetails? = null
|
||||
private var controlsEnabled = true
|
||||
|
||||
fun getPanel(): JPanel {
|
||||
descriptionArea.lineWrap = true
|
||||
descriptionArea.wrapStyleWord = true
|
||||
descriptionArea.margin = JBUI.insets(6, 8)
|
||||
val descriptionScroll = ScrollPaneFactory.createScrollPane(descriptionArea, true)
|
||||
descriptionScroll.border = IdeBorderFactory.createRoundedBorder()
|
||||
descriptionScroll.preferredSize = Dimension(0, JBUI.scale(188))
|
||||
|
||||
val titleLabel = JBLabel("Title")
|
||||
val descriptionLabel = JBLabel("Description")
|
||||
val descriptionHelp = JBLabel("Describe the subagent's goals and behavior concisely.")
|
||||
descriptionHelp.foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND
|
||||
descriptionHelp.font = JBUI.Fonts.smallFont()
|
||||
|
||||
val toolsPanel = javax.swing.JPanel()
|
||||
toolsPanel.layout = javax.swing.BoxLayout(toolsPanel, javax.swing.BoxLayout.Y_AXIS)
|
||||
allTools.forEach { tool -> toolsPanel.add(toolBoxes.getValue(tool)) }
|
||||
val toolsScroll = JBScrollPane(toolsPanel)
|
||||
toolsScroll.border = JBUI.Borders.empty(4, 4, 0, 4)
|
||||
|
||||
val headerForm = FormBuilder.createFormBuilder()
|
||||
.addComponent(titleLabel)
|
||||
.addComponent(wrapWithMargin(titleField, 4, 4, 4, 4), 1)
|
||||
.addComponent(descriptionLabel)
|
||||
.addComponent(wrapWithMargin(descriptionScroll, 4, 4, 8, 4), 1)
|
||||
.addComponent(descriptionHelp)
|
||||
.addComponent(JBLabel("Tools"))
|
||||
.panel
|
||||
|
||||
toolsScroll.preferredSize = Dimension(0, 0)
|
||||
|
||||
val container = com.intellij.util.ui.components.BorderLayoutPanel(0, 0)
|
||||
container.border = JBUI.Borders.empty(8)
|
||||
container.addToTop(headerForm)
|
||||
container.addToCenter(toolsScroll)
|
||||
return container
|
||||
}
|
||||
|
||||
private fun wrapWithMargin(component: JComponent, top: Int, left: Int, bottom: Int, right: Int): JPanel {
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.border = JBUI.Borders.empty(top, left, bottom, right)
|
||||
panel.add(component, BorderLayout.CENTER)
|
||||
return panel
|
||||
}
|
||||
|
||||
fun updateData(details: SubagentDetails) {
|
||||
current = details
|
||||
titleField.text = details.title
|
||||
descriptionArea.text = details.description
|
||||
toolBoxes.values.forEach { it.isSelected = false }
|
||||
details.tools.forEach { tool -> toolBoxes[tool]?.isSelected = true }
|
||||
refreshState()
|
||||
}
|
||||
|
||||
fun collect(): SubagentDetails? {
|
||||
val d = current ?: return null
|
||||
d.title = titleField.text.trim()
|
||||
d.description = descriptionArea.text.trim()
|
||||
d.tools = toolBoxes.filter { it.value.isSelected }.keys.toMutableSet()
|
||||
return d
|
||||
}
|
||||
|
||||
fun setControlsEnabled(enabled: Boolean) {
|
||||
controlsEnabled = enabled
|
||||
refreshState()
|
||||
}
|
||||
|
||||
private fun refreshState() {
|
||||
val editable = controlsEnabled
|
||||
titleField.isEnabled = controlsEnabled
|
||||
titleField.isEditable = editable
|
||||
descriptionArea.isEnabled = controlsEnabled
|
||||
descriptionArea.isEditable = editable
|
||||
toolBoxes.values.forEach { it.isEnabled = editable }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
package ee.carlrobert.codegpt.settings.agents.form
|
||||
|
||||
import com.intellij.ui.ToolbarDecorator
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.ValidationInfo
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.ui.dsl.builder.*
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.options.ConfigurationException
|
||||
import com.intellij.openapi.progress.ProgressManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.agent.SubagentTool
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISubagent
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.settings.agents.SubagentDefaults
|
||||
import java.awt.Dimension
|
||||
import javax.swing.DefaultListModel
|
||||
import javax.swing.JComponent
|
||||
|
||||
class SubagentsForm(private val project: Project) {
|
||||
private val readOnlyTools = SubagentTool.readOnly
|
||||
private val writeTools = SubagentTool.write
|
||||
private val settingsService = project.service<ProxyAISettingsService>()
|
||||
|
||||
private val listModel = DefaultListModel<SubagentDetails>()
|
||||
private val list = JBList(listModel)
|
||||
private val detailsPanel = SubagentDetailsPanel(readOnlyTools, writeTools)
|
||||
|
||||
fun createPanel(): JComponent {
|
||||
list.cellRenderer = SubagentListCellRenderer()
|
||||
list.visibleRowCount = 10
|
||||
list.addListSelectionListener { event ->
|
||||
if (event.valueIsAdjusting) return@addListSelectionListener
|
||||
detailsPanel.collect()
|
||||
list.selectedValue?.let { detailsPanel.updateData(it) }
|
||||
}
|
||||
|
||||
listModel.removeAllElements()
|
||||
loadSubagents().forEach { listModel.addElement(it) }
|
||||
|
||||
if (listModel.size > 0) list.selectedIndex = 0
|
||||
|
||||
val decorated = ToolbarDecorator.createDecorator(list)
|
||||
.setAddAction {
|
||||
val d = SubagentDetails(nextId(), "New Subagent", "", mutableSetOf())
|
||||
listModel.addElement(d)
|
||||
list.selectedIndex = listModel.size - 1
|
||||
detailsPanel.updateData(d)
|
||||
}
|
||||
.setRemoveAction {
|
||||
val idx = list.selectedIndex
|
||||
if (idx >= 0) listModel.remove(idx)
|
||||
if (listModel.size > 0) list.selectedIndex = 0
|
||||
}
|
||||
.setRemoveActionUpdater {
|
||||
val idx = list.selectedIndex
|
||||
idx >= 0 && !isBuiltIn(listModel.getElementAt(idx))
|
||||
}
|
||||
.addExtraAction(object : AnAction("Generate", "Generate from natural language", AllIcons.Actions.IntentionBulb) {
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val input = showGeneratePromptDialog() ?: return
|
||||
list.isEnabled = false
|
||||
detailsPanel.setControlsEnabled(false)
|
||||
var error: String? = null
|
||||
var generated: ee.carlrobert.codegpt.settings.agents.GeneratedSubagent? = null
|
||||
ProgressManager.getInstance().runProcessWithProgressSynchronously({
|
||||
try {
|
||||
generated = ee.carlrobert.codegpt.settings.agents.SubagentGenerator.generateBlocking(input)
|
||||
} catch (t: Throwable) {
|
||||
error = t.message ?: "Generation failed"
|
||||
}
|
||||
}, "Generating subagent...", true, project)
|
||||
|
||||
val tools = suggestTools(input)
|
||||
runInEdt(ModalityState.any()) {
|
||||
if (generated != null) {
|
||||
val d = SubagentDetails(nextId(), generated!!.title, generated!!.description, tools)
|
||||
listModel.addElement(d)
|
||||
list.selectedIndex = listModel.size - 1
|
||||
detailsPanel.updateData(d)
|
||||
OverlayUtil.showNotification("Subagent generated.")
|
||||
} else if (error != null) {
|
||||
OverlayUtil.showNotification("Failed to generate subagent: $error", com.intellij.notification.NotificationType.ERROR)
|
||||
}
|
||||
list.isEnabled = true
|
||||
detailsPanel.setControlsEnabled(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
.disableUpDownActions()
|
||||
.createPanel()
|
||||
decorated.preferredSize = Dimension(JBUI.scale(260), 0)
|
||||
|
||||
val rightContainer = BorderLayoutPanel()
|
||||
rightContainer.addToCenter(detailsPanel.getPanel())
|
||||
|
||||
return BorderLayoutPanel(8, 0)
|
||||
.addToLeft(decorated)
|
||||
.addToCenter(rightContainer)
|
||||
}
|
||||
|
||||
fun isModified(): Boolean {
|
||||
val stored = loadSubagents()
|
||||
val form = currentFormStates()
|
||||
if (stored.size != form.size) return true
|
||||
return stored.zip(form).any { (a, b) -> a != b }
|
||||
}
|
||||
|
||||
fun applyChanges() {
|
||||
detailsPanel.collect()
|
||||
val error = validateEntries()
|
||||
if (error != null) throw ConfigurationException(error)
|
||||
settingsService.saveSubagents(currentFormStates().map { it.toStored() })
|
||||
}
|
||||
|
||||
fun resetChanges() {
|
||||
listModel.removeAllElements()
|
||||
loadSubagents().forEach { listModel.addElement(it) }
|
||||
if (listModel.size > 0) {
|
||||
list.selectedIndex = 0
|
||||
detailsPanel.updateData(listModel.getElementAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentFormStates(): List<SubagentDetails> {
|
||||
detailsPanel.collect()
|
||||
return (0 until listModel.size).map { idx -> listModel.getElementAt(idx) }
|
||||
}
|
||||
|
||||
private fun loadSubagents(): List<SubagentDetails> {
|
||||
return settingsService.getSubagents().map { it.toDetails() }
|
||||
}
|
||||
|
||||
private fun isBuiltIn(details: SubagentDetails): Boolean = SubagentDefaults.isBuiltInId(details.id)
|
||||
|
||||
private fun validateEntries(): String? {
|
||||
val seen = mutableSetOf<String>()
|
||||
for (i in 0 until listModel.size) {
|
||||
val entry = listModel.getElementAt(i)
|
||||
val title = entry.title.trim()
|
||||
if (title.isEmpty()) return "Title is required."
|
||||
if (entry.description.trim().isEmpty()) return "Description is required."
|
||||
if (entry.tools.isEmpty()) return "Select at least one tool for '$title'."
|
||||
val key = title.lowercase()
|
||||
if (!seen.add(key)) return "Subagent titles must be unique."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun nextId(): Int {
|
||||
val maxId = (0 until listModel.size).maxOfOrNull { listModel.getElementAt(it).id } ?: 0
|
||||
return maxId + 1
|
||||
}
|
||||
|
||||
private fun showGeneratePromptDialog(): String? {
|
||||
val dialog = GenerateSubagentDialog()
|
||||
return if (dialog.showAndGet()) {
|
||||
dialog.inputValue.takeIf { it.isNotBlank() }
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private class GenerateSubagentDialog : DialogWrapper(true) {
|
||||
private val inputField = JBTextField()
|
||||
|
||||
val inputValue: String
|
||||
get() = inputField.text
|
||||
|
||||
init {
|
||||
title = "Generate Subagent"
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
return panel {
|
||||
row {
|
||||
label("Describe the subagent:")
|
||||
}
|
||||
row {
|
||||
cell(inputField)
|
||||
.columns(COLUMNS_LARGE)
|
||||
.focused()
|
||||
}
|
||||
row {
|
||||
comment("Example: Review Kotlin code for style and complexity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doValidate(): ValidationInfo? {
|
||||
return if (inputField.text.trim().isEmpty()) {
|
||||
ValidationInfo("Description cannot be empty", inputField)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun suggestTools(text: String): MutableSet<SubagentTool> {
|
||||
val t = text.lowercase()
|
||||
val selected = SubagentTool.readOnly.toMutableSet()
|
||||
if (listOf("write", "edit", "fix", "implement", "change", "modify", "apply").any { it in t }) {
|
||||
selected.addAll(SubagentTool.write)
|
||||
}
|
||||
if (listOf("review", "analyze", "audit", "inspect").any { it in t }) {
|
||||
selected.removeAll(SubagentTool.write.toSet())
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
private fun ProxyAISubagent.toDetails(): SubagentDetails {
|
||||
return SubagentDetails(
|
||||
id = id,
|
||||
title = title,
|
||||
description = objective,
|
||||
tools = SubagentTool.parse(tools).toMutableSet(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun SubagentDetails.toStored(): ProxyAISubagent {
|
||||
return ProxyAISubagent(
|
||||
id = id,
|
||||
title = title,
|
||||
objective = description,
|
||||
tools = SubagentTool.toStoredValues(tools)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private class SubagentListCellRenderer : javax.swing.ListCellRenderer<SubagentDetails> {
|
||||
private val label = JBLabel()
|
||||
override fun getListCellRendererComponent(
|
||||
list: javax.swing.JList<out SubagentDetails>?,
|
||||
value: SubagentDetails?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): java.awt.Component {
|
||||
label.text = value?.title ?: ""
|
||||
label.background = if (isSelected) list?.selectionBackground else list?.background
|
||||
label.foreground = if (isSelected) list?.selectionForeground else list?.foreground
|
||||
label.isOpaque = true
|
||||
label.border = JBUI.Borders.empty(4, 8)
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ object LegacySettingsMigration {
|
|||
return ModelSettingsState().apply {
|
||||
val chatModel = getLegacyChatModelForService(selectedService)
|
||||
|
||||
setModelSelection(FeatureType.AGENT, "auto", selectedService)
|
||||
setModelSelection(FeatureType.CHAT, chatModel, selectedService)
|
||||
setModelSelection(FeatureType.AUTO_APPLY, chatModel, selectedService)
|
||||
setModelSelection(FeatureType.COMMIT_MESSAGE, chatModel, selectedService)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ object ModelIcons {
|
|||
|
||||
fun getIconForProvider(provider: ServiceType): Icon? {
|
||||
return when (provider) {
|
||||
ServiceType.PROXYAI -> Icons.CodeGPTModel
|
||||
ServiceType.PROXYAI -> Icons.DefaultSmall
|
||||
ServiceType.OPENAI -> Icons.OpenAI
|
||||
ServiceType.ANTHROPIC -> Icons.Anthropic
|
||||
ServiceType.GOOGLE -> Icons.Google
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
package ee.carlrobert.codegpt.settings.models
|
||||
|
||||
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels.Haiku_4_5
|
||||
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels.Opus_4_5
|
||||
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels.Sonnet_4_5
|
||||
import ai.koog.prompt.executor.clients.google.GoogleModels.Gemini2_5Flash
|
||||
import ai.koog.prompt.executor.clients.google.GoogleModels.Gemini2_5Pro
|
||||
import ai.koog.prompt.executor.clients.google.GoogleModels.Gemini3_Pro_Preview
|
||||
import ai.koog.prompt.executor.clients.mistralai.MistralAIModels.Chat.DevstralMedium
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT4_1
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT4_1Mini
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5Mini
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5_1
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5_1Codex
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels.Chat.GPT5_2
|
||||
import ai.koog.prompt.llm.LLMCapability
|
||||
import ai.koog.prompt.llm.LLMProvider
|
||||
import ai.koog.prompt.llm.LLModel
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import ee.carlrobert.codegpt.Icons
|
||||
import ee.carlrobert.codegpt.agent.clients.CustomOpenAILLMClient
|
||||
import ee.carlrobert.codegpt.agent.clients.ProxyAILLMClient
|
||||
import ee.carlrobert.codegpt.completions.llama.LlamaModel
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
|
|
@ -20,7 +38,9 @@ data class ModelSelection(
|
|||
val model: String,
|
||||
val displayName: String,
|
||||
val icon: Icon? = null,
|
||||
val pricingPlan: PricingPlan? = null
|
||||
val pricingPlan: PricingPlan? = null,
|
||||
val llmModel: LLModel? = null,
|
||||
val id: String? = "",
|
||||
) {
|
||||
val fullDisplayName: String = if (provider == ServiceType.LLAMA_CPP) {
|
||||
displayName
|
||||
|
|
@ -44,6 +64,7 @@ class ModelRegistry {
|
|||
ServiceType.PROXYAI to ModelCapability(
|
||||
ServiceType.PROXYAI,
|
||||
setOf(
|
||||
FeatureType.AGENT,
|
||||
FeatureType.CHAT,
|
||||
FeatureType.CODE_COMPLETION,
|
||||
FeatureType.AUTO_APPLY,
|
||||
|
|
@ -56,36 +77,59 @@ class ModelRegistry {
|
|||
ServiceType.OPENAI to ModelCapability(
|
||||
ServiceType.OPENAI,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
FeatureType.AGENT,
|
||||
FeatureType.CHAT,
|
||||
FeatureType.CODE_COMPLETION,
|
||||
FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT,
|
||||
FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.ANTHROPIC to ModelCapability(
|
||||
ServiceType.ANTHROPIC,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
FeatureType.AGENT,
|
||||
FeatureType.CHAT,
|
||||
FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT,
|
||||
FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.GOOGLE to ModelCapability(
|
||||
ServiceType.GOOGLE,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
FeatureType.AGENT,
|
||||
FeatureType.CHAT,
|
||||
FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT,
|
||||
FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.MISTRAL to ModelCapability(
|
||||
ServiceType.MISTRAL,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
FeatureType.AGENT,
|
||||
FeatureType.CHAT,
|
||||
FeatureType.CODE_COMPLETION,
|
||||
FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT,
|
||||
FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.OLLAMA to ModelCapability(
|
||||
ServiceType.OLLAMA,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
FeatureType.AGENT,
|
||||
FeatureType.CHAT,
|
||||
FeatureType.CODE_COMPLETION,
|
||||
FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT,
|
||||
FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.LLAMA_CPP to ModelCapability(
|
||||
|
|
@ -98,8 +142,13 @@ class ModelRegistry {
|
|||
ServiceType.CUSTOM_OPENAI to ModelCapability(
|
||||
ServiceType.CUSTOM_OPENAI,
|
||||
setOf(
|
||||
FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP
|
||||
FeatureType.AGENT,
|
||||
FeatureType.CHAT,
|
||||
FeatureType.CODE_COMPLETION,
|
||||
FeatureType.AUTO_APPLY,
|
||||
FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT,
|
||||
FeatureType.LOOKUP
|
||||
)
|
||||
),
|
||||
ServiceType.INCEPTION to ModelCapability(
|
||||
|
|
@ -114,6 +163,11 @@ class ModelRegistry {
|
|||
|
||||
private val pricingPlanBasedDefaults = mapOf(
|
||||
PricingPlan.ANONYMOUS to mapOf(
|
||||
FeatureType.AGENT to ModelSelection(
|
||||
ServiceType.OPENAI,
|
||||
PROXYAI_AUTO,
|
||||
"Auto"
|
||||
),
|
||||
FeatureType.CHAT to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
GEMINI_FLASH_2_5,
|
||||
|
|
@ -151,6 +205,11 @@ class ModelRegistry {
|
|||
)
|
||||
),
|
||||
PricingPlan.FREE to mapOf(
|
||||
FeatureType.AGENT to ModelSelection(
|
||||
ServiceType.OPENAI,
|
||||
PROXYAI_AUTO,
|
||||
"Auto"
|
||||
),
|
||||
FeatureType.CHAT to ModelSelection(ServiceType.PROXYAI, QWEN3_CODER, "Qwen3 Coder"),
|
||||
FeatureType.AUTO_APPLY to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
|
|
@ -180,6 +239,11 @@ class ModelRegistry {
|
|||
)
|
||||
),
|
||||
PricingPlan.INDIVIDUAL to mapOf(
|
||||
FeatureType.AGENT to ModelSelection(
|
||||
ServiceType.OPENAI,
|
||||
PROXYAI_AUTO,
|
||||
"Auto"
|
||||
),
|
||||
FeatureType.CHAT to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
CLAUDE_4_5_SONNET_THINKING,
|
||||
|
|
@ -190,7 +254,11 @@ class ModelRegistry {
|
|||
MERCURY_CODER,
|
||||
"Mercury Coder"
|
||||
),
|
||||
FeatureType.COMMIT_MESSAGE to ModelSelection(ServiceType.PROXYAI, GPT_5_CODEX, "GPT-5 Codex"),
|
||||
FeatureType.COMMIT_MESSAGE to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
GPT_5_CODEX,
|
||||
"GPT-5 Codex"
|
||||
),
|
||||
FeatureType.INLINE_EDIT to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
CLAUDE_4_5_SONNET,
|
||||
|
|
@ -211,6 +279,11 @@ class ModelRegistry {
|
|||
)
|
||||
|
||||
private val fallbackDefaults = mapOf(
|
||||
FeatureType.AGENT to ModelSelection(
|
||||
ServiceType.OPENAI,
|
||||
PROXYAI_AUTO,
|
||||
"Auto"
|
||||
),
|
||||
FeatureType.CHAT to ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
GEMINI_FLASH_2_5,
|
||||
|
|
@ -242,6 +315,7 @@ class ModelRegistry {
|
|||
|
||||
fun getAllModelsForFeature(featureType: FeatureType): List<ModelSelection> {
|
||||
return when (featureType) {
|
||||
FeatureType.AGENT -> getAllAgentModels()
|
||||
FeatureType.CHAT, FeatureType.COMMIT_MESSAGE,
|
||||
FeatureType.INLINE_EDIT, FeatureType.LOOKUP -> getAllChatModels()
|
||||
|
||||
|
|
@ -272,15 +346,22 @@ class ModelRegistry {
|
|||
fun findModel(provider: ServiceType, modelCode: String): ModelSelection? {
|
||||
return getAllModels()
|
||||
.filter { it.provider == provider }
|
||||
.find { it.model == modelCode }
|
||||
.find { it.id == modelCode || it.model == modelCode }
|
||||
}
|
||||
|
||||
fun getModelDisplayName(provider: ServiceType, modelCode: String): String {
|
||||
return findModel(provider, modelCode)?.displayName ?: modelCode
|
||||
fun getModelDisplayName(provider: ServiceType, modelCode: String?): String {
|
||||
val code = modelCode?.takeIf { it.isNotBlank() }
|
||||
return if (code != null) {
|
||||
findModel(provider, code)?.displayName ?: code
|
||||
} else {
|
||||
// Fallback to the first known model for the provider, or provider name
|
||||
getAllModels().firstOrNull { it.provider == provider }?.displayName ?: provider.name
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAllModels(): List<ModelSelection> {
|
||||
return buildList {
|
||||
addAll(getAllAgentModels())
|
||||
addAll(getAllApplyModels())
|
||||
addAll(getAllChatModels())
|
||||
addAll(getAllCodeModels())
|
||||
|
|
@ -288,6 +369,205 @@ class ModelRegistry {
|
|||
}.distinctBy { "${it.provider}:${it.model}" }
|
||||
}
|
||||
|
||||
fun getAgentModels(serviceType: ServiceType?): List<LLMModelWrapper> {
|
||||
return getAgentModels()[serviceType] ?: emptyList()
|
||||
}
|
||||
|
||||
data class LLMModelWrapper(val model: LLModel, val id: String = "", val name: String = "")
|
||||
|
||||
private fun getProxyAIModelWrapper(id: String, name: String): LLMModelWrapper {
|
||||
return LLMModelWrapper(
|
||||
model = LLModel(
|
||||
id = id,
|
||||
provider = ProxyAILLMClient.ProxyAI,
|
||||
capabilities = listOf(
|
||||
LLMCapability.Temperature,
|
||||
LLMCapability.Schema.JSON.Basic,
|
||||
LLMCapability.Schema.JSON.Standard,
|
||||
LLMCapability.Speculation,
|
||||
LLMCapability.Tools,
|
||||
LLMCapability.ToolChoice,
|
||||
LLMCapability.Vision.Image,
|
||||
LLMCapability.Document,
|
||||
LLMCapability.Completion,
|
||||
LLMCapability.MultipleChoices,
|
||||
LLMCapability.OpenAIEndpoint.Completions,
|
||||
),
|
||||
contextLength = 200_000,
|
||||
maxOutputTokens = 32_768,
|
||||
),
|
||||
name = name
|
||||
)
|
||||
}
|
||||
|
||||
fun getAgentModels(): Map<ServiceType, List<LLMModelWrapper>> {
|
||||
return mapOf(
|
||||
ServiceType.PROXYAI to listOf(
|
||||
getProxyAIModelWrapper(PROXYAI_AUTO, "Auto"),
|
||||
getProxyAIModelWrapper(GPT5_2.id, "GPT-5.2"),
|
||||
getProxyAIModelWrapper(GPT5_1Codex.id, "GPT-5.1 Codex"),
|
||||
getProxyAIModelWrapper(GPT5Mini.id, "GPT-5 Mini"),
|
||||
getProxyAIModelWrapper(Opus_4_5.id, "Claude Opus 4.5"),
|
||||
getProxyAIModelWrapper(Sonnet_4_5.id, "Claude Sonnet 4.5"),
|
||||
getProxyAIModelWrapper(Haiku_4_5.id, "Claude Haiku 4.5"),
|
||||
),
|
||||
ServiceType.OPENAI to listOf(
|
||||
LLMModelWrapper(GPT5_2, name = "GPT-5.2"),
|
||||
LLMModelWrapper(GPT5_1, name = "GPT-5.1"),
|
||||
LLMModelWrapper(GPT5_1Codex, name = "GPT-5.1 Codex"),
|
||||
LLMModelWrapper(GPT5Mini, name = "GPT-5 Mini"),
|
||||
LLMModelWrapper(GPT4_1, name = "GPT-4.1"),
|
||||
LLMModelWrapper(GPT4_1Mini, name = "GPT-4.1 Mini")
|
||||
),
|
||||
ServiceType.ANTHROPIC to listOf(
|
||||
LLMModelWrapper(Opus_4_5, name = "Claude Opus 4.5"),
|
||||
LLMModelWrapper(Sonnet_4_5, name = "Claude Sonnet 4.5"),
|
||||
LLMModelWrapper(Haiku_4_5, name = "Claude Haiku 4.5")
|
||||
),
|
||||
ServiceType.CUSTOM_OPENAI to getCustomOpenAIModels().map { model ->
|
||||
LLMModelWrapper(
|
||||
model = LLModel(
|
||||
id = model.model,
|
||||
provider = CustomOpenAILLMClient.CustomOpenAI,
|
||||
capabilities = listOf(
|
||||
LLMCapability.Temperature,
|
||||
LLMCapability.Schema.JSON.Basic,
|
||||
LLMCapability.Schema.JSON.Standard,
|
||||
LLMCapability.Speculation,
|
||||
LLMCapability.Tools,
|
||||
LLMCapability.ToolChoice,
|
||||
LLMCapability.Vision.Image,
|
||||
LLMCapability.Document,
|
||||
LLMCapability.Completion,
|
||||
LLMCapability.MultipleChoices,
|
||||
LLMCapability.OpenAIEndpoint.Completions,
|
||||
),
|
||||
contextLength = 200_000,
|
||||
maxOutputTokens = 32_768,
|
||||
),
|
||||
id = model.id ?: "",
|
||||
name = model.displayName
|
||||
)
|
||||
},
|
||||
ServiceType.GOOGLE to listOf(
|
||||
LLMModelWrapper(Gemini3_Pro_Preview, name = "Gemini 3 Pro Preview"),
|
||||
LLMModelWrapper(Gemini2_5Pro, name = "Gemini 2.5 Pro"),
|
||||
LLMModelWrapper(Gemini2_5Flash, name = "Gemini 2.5 Flash")
|
||||
),
|
||||
ServiceType.MISTRAL to listOf(
|
||||
LLMModelWrapper(DevstralMedium, name = "Devstral Medium")
|
||||
),
|
||||
ServiceType.OLLAMA to getOllamaModels().map { model ->
|
||||
LLMModelWrapper(
|
||||
LLModel(
|
||||
id = model.model,
|
||||
provider = LLMProvider.Ollama,
|
||||
capabilities = listOf(
|
||||
LLMCapability.Temperature,
|
||||
LLMCapability.Schema.JSON.Basic,
|
||||
LLMCapability.Schema.JSON.Standard,
|
||||
LLMCapability.Speculation,
|
||||
LLMCapability.Tools,
|
||||
LLMCapability.ToolChoice,
|
||||
LLMCapability.Vision.Image,
|
||||
LLMCapability.Document,
|
||||
LLMCapability.Completion,
|
||||
LLMCapability.MultipleChoices,
|
||||
LLMCapability.OpenAIEndpoint.Completions,
|
||||
),
|
||||
contextLength = 200_000,
|
||||
maxOutputTokens = 32_768,
|
||||
),
|
||||
name = model.displayName
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getAllAgentModels(): List<ModelSelection> {
|
||||
return buildList {
|
||||
addAll(getProxyAIAgentModels())
|
||||
addAll(getAnthropicAgentModels())
|
||||
addAll(getOpenAIAgentModels())
|
||||
addAll(getCustomOpenAIModels())
|
||||
addAll(getGoogleAgentModels())
|
||||
addAll(getMistralAgentModels())
|
||||
addAll(getOllamaModels())
|
||||
}
|
||||
}
|
||||
|
||||
fun getOpenAIAgentModels(): List<ModelSelection> {
|
||||
return listOf(
|
||||
ModelSelection(ServiceType.OPENAI, GPT5_2.id, "GPT-5.2", Icons.OpenAI),
|
||||
ModelSelection(ServiceType.OPENAI, GPT5_1.id, "GPT-5.1", Icons.OpenAI),
|
||||
ModelSelection(ServiceType.OPENAI, GPT5_1Codex.id, "GPT-5.1 Codex", Icons.OpenAI),
|
||||
ModelSelection(ServiceType.OPENAI, GPT5Mini.id, "GPT-5 Mini", Icons.OpenAI),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getProxyAIAgentModels(): List<ModelSelection> {
|
||||
return listOf(
|
||||
ModelSelection(ServiceType.PROXYAI, PROXYAI_AUTO, "Auto", Icons.DefaultSmall),
|
||||
ModelSelection(ServiceType.PROXYAI, GPT5_2.id, "GPT-5.2", Icons.OpenAI),
|
||||
ModelSelection(ServiceType.PROXYAI, GPT5_1Codex.id, "GPT-5.1 Codex", Icons.OpenAI),
|
||||
ModelSelection(ServiceType.PROXYAI, GPT5Mini.id, "GPT-5 Mini", Icons.OpenAI),
|
||||
ModelSelection(ServiceType.PROXYAI, Opus_4_5.id, "Claude Opus 4.5", Icons.Anthropic),
|
||||
ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
Sonnet_4_5.id,
|
||||
"Claude Sonnet 4.5",
|
||||
Icons.Anthropic
|
||||
),
|
||||
ModelSelection(
|
||||
ServiceType.PROXYAI,
|
||||
Haiku_4_5.id,
|
||||
"Claude Haiku 4.5",
|
||||
Icons.Anthropic
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getAnthropicAgentModels(): List<ModelSelection> {
|
||||
return listOf(
|
||||
ModelSelection(ServiceType.ANTHROPIC, Opus_4_5.id, "Claude Opus 4.5", Icons.Anthropic),
|
||||
ModelSelection(
|
||||
ServiceType.ANTHROPIC,
|
||||
Sonnet_4_5.id,
|
||||
"Claude Sonnet 4.5",
|
||||
Icons.Anthropic
|
||||
),
|
||||
ModelSelection(
|
||||
ServiceType.ANTHROPIC,
|
||||
Haiku_4_5.id,
|
||||
"Claude Haiku 4.5",
|
||||
Icons.Anthropic
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getGoogleAgentModels(): List<ModelSelection> {
|
||||
return listOf(
|
||||
ModelSelection(ServiceType.GOOGLE, Gemini2_5Pro.id, "Gemini 2.5 Pro", Icons.Google),
|
||||
ModelSelection(
|
||||
ServiceType.GOOGLE,
|
||||
Gemini2_5Flash.id,
|
||||
"Gemini 2.5 Flash",
|
||||
Icons.Google
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getMistralAgentModels(): List<ModelSelection> {
|
||||
return listOf(
|
||||
ModelSelection(
|
||||
ServiceType.MISTRAL,
|
||||
DevstralMedium.id,
|
||||
"Devstral Medium",
|
||||
Icons.Mistral
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getAllChatModels(): List<ModelSelection> {
|
||||
return buildList {
|
||||
addAll(getProxyAIChatModels())
|
||||
|
|
@ -575,16 +855,20 @@ class ModelRegistry {
|
|||
|
||||
fun getCustomOpenAIModels(): List<ModelSelection> {
|
||||
return try {
|
||||
val customServicesSettings = service<CustomServicesSettings>()
|
||||
customServicesSettings.state.services.mapNotNull { service ->
|
||||
service<CustomServicesSettings>().state.services.mapNotNull { service ->
|
||||
val serviceId = service.id ?: return@mapNotNull null
|
||||
val serviceName = service.name ?: ""
|
||||
val modelName = service.chatCompletionSettings.body["model"] as? String
|
||||
val displayName = if (!modelName.isNullOrEmpty()) {
|
||||
if (modelName.length > 20) "$serviceName (...${modelName.takeLast(20)})" else "$serviceName ($modelName)"
|
||||
val model = service.chatCompletionSettings.body["model"] as? String ?: ""
|
||||
val displayName = if (model.isNotEmpty()) {
|
||||
if (model.length > 20) "$serviceName (...${model.takeLast(20)})" else "$serviceName ($model)"
|
||||
} else serviceName
|
||||
|
||||
ModelSelection(ServiceType.CUSTOM_OPENAI, serviceId, displayName)
|
||||
ModelSelection(
|
||||
id = serviceId,
|
||||
provider = ServiceType.CUSTOM_OPENAI,
|
||||
model = model,
|
||||
displayName = displayName
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to get Custom OpenAI models", e)
|
||||
|
|
@ -609,6 +893,7 @@ class ModelRegistry {
|
|||
|
||||
companion object {
|
||||
// ProxyAI Models
|
||||
const val PROXYAI_AUTO = "auto"
|
||||
const val GEMINI_PRO_2_5 = "gemini-pro-2.5"
|
||||
const val GEMINI_FLASH_2_5 = "gemini-flash-2.5"
|
||||
const val CLAUDE_4_SONNET = "claude-4-sonnet"
|
||||
|
|
@ -640,6 +925,7 @@ class ModelRegistry {
|
|||
const val GPT_5 = "gpt-5"
|
||||
const val GPT_5_MINI = "gpt-5-mini"
|
||||
const val GPT_5_CODEX = "gpt-5-codex"
|
||||
const val GPT_5_1_CODEX = "gpt-5.1-codex"
|
||||
|
||||
// Anthropic Models
|
||||
const val CLAUDE_OPUS_4_20250514 = "claude-opus-4-20250514"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class ModelSettings : SimplePersistentStateComponent<ModelSettingsState>(ModelSe
|
|||
)
|
||||
|
||||
private val publisherMethods = mapOf(
|
||||
FeatureType.AGENT to PublisherMethod { publisher, model, serviceType ->
|
||||
publisher.agentModelChanged(model, serviceType)
|
||||
},
|
||||
FeatureType.CHAT to PublisherMethod { publisher, model, serviceType ->
|
||||
publisher.chatModelChanged(model, serviceType)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class ModelSettingsForm(
|
|||
titleKey = "settings.models.chat.section.title",
|
||||
descriptionKey = "settings.models.chat.section.description",
|
||||
features = listOf(
|
||||
FeatureConfig(FeatureType.AGENT, "settings.models.agent.label"),
|
||||
FeatureConfig(FeatureType.CHAT, "settings.models.chat.label"),
|
||||
FeatureConfig(FeatureType.AUTO_APPLY, "settings.models.autoApply.label"),
|
||||
FeatureConfig(
|
||||
|
|
@ -73,6 +74,10 @@ class ModelSettingsForm(
|
|||
messageBusConnection.subscribe(
|
||||
ModelChangeNotifier.getTopic(),
|
||||
object : ModelChangeNotifier {
|
||||
override fun agentModelChanged(newModel: String, serviceType: ServiceType) {
|
||||
modelChanged(FeatureType.AGENT, newModel, serviceType)
|
||||
}
|
||||
|
||||
override fun chatModelChanged(newModel: String, serviceType: ServiceType) {
|
||||
modelChanged(FeatureType.CHAT, newModel, serviceType)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.intellij.util.messages.Topic
|
|||
|
||||
interface ModelChangeNotifier {
|
||||
|
||||
fun agentModelChanged(newModel: String, serviceType: ServiceType)
|
||||
fun chatModelChanged(newModel: String, serviceType: ServiceType)
|
||||
fun codeModelChanged(newModel: String, serviceType: ServiceType)
|
||||
fun autoApplyModelChanged(newModel: String, serviceType: ServiceType)
|
||||
|
|
@ -25,6 +26,7 @@ interface ModelChangeNotifier {
|
|||
|
||||
abstract class ModelChangeNotifierAdapter : ModelChangeNotifier {
|
||||
|
||||
override fun agentModelChanged(newModel: String, serviceType: ServiceType) {}
|
||||
override fun chatModelChanged(newModel: String, serviceType: ServiceType) {}
|
||||
override fun codeModelChanged(newModel: String, serviceType: ServiceType) {}
|
||||
override fun autoApplyModelChanged(newModel: String, serviceType: ServiceType) {}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package ee.carlrobert.codegpt.settings.service
|
||||
|
||||
import ai.koog.prompt.llm.LLModel
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
|
|
@ -12,6 +13,16 @@ import ee.carlrobert.llm.client.codegpt.PricingPlan
|
|||
@Service
|
||||
class ModelSelectionService {
|
||||
|
||||
fun getAgentModel(): LLModel {
|
||||
val modelDetailsState = service<ModelSettings>().state.getModelSelection(FeatureType.AGENT)
|
||||
val model = modelDetailsState?.model
|
||||
val provider = modelDetailsState?.provider
|
||||
val models = service<ModelRegistry>().getAgentModels()[provider]
|
||||
|
||||
return models?.find { it.id == model || it.model.id == model }?.model
|
||||
?: throw IllegalArgumentException("Model '$model' not found for provider $provider")
|
||||
}
|
||||
|
||||
fun getModelSelectionForFeature(
|
||||
featureType: FeatureType,
|
||||
pricingPlan: PricingPlan? = null
|
||||
|
|
@ -19,14 +30,18 @@ class ModelSelectionService {
|
|||
return try {
|
||||
val modelSettings = service<ModelSettings>()
|
||||
val modelDetailsState = modelSettings.state.getModelSelection(featureType)
|
||||
|
||||
|
||||
if (modelDetailsState != null && modelDetailsState.model != null && modelDetailsState.provider != null) {
|
||||
val foundModel = service<ModelRegistry>().findModel(modelDetailsState.provider!!, modelDetailsState.model!!)
|
||||
val foundModel =
|
||||
service<ModelRegistry>().findModel(
|
||||
modelDetailsState.provider!!,
|
||||
modelDetailsState.model!!
|
||||
)
|
||||
if (foundModel != null) {
|
||||
return foundModel
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
service<ModelRegistry>().getDefaultModelForFeature(featureType, pricingPlan)
|
||||
} catch (exception: Exception) {
|
||||
logger.warn(
|
||||
|
|
@ -64,7 +79,11 @@ class ModelSelectionService {
|
|||
val settings = service<ModelSettings>()
|
||||
|
||||
FeatureType.entries.forEach { featureType ->
|
||||
if (!registry.isFeatureSupportedByProvider(featureType, ServiceType.CUSTOM_OPENAI)) return@forEach
|
||||
if (!registry.isFeatureSupportedByProvider(
|
||||
featureType,
|
||||
ServiceType.CUSTOM_OPENAI
|
||||
)
|
||||
) return@forEach
|
||||
|
||||
val current = settings.getModelSelection(featureType)
|
||||
if (current?.provider != ServiceType.CUSTOM_OPENAI) return@forEach
|
||||
|
|
|
|||
|
|
@ -121,14 +121,13 @@ class CustomServicesSettings :
|
|||
val modelSelection = service<ModelSelectionService>()
|
||||
val featureSelection = modelSelection.getModelSelectionForFeature(featureType)
|
||||
|
||||
if (featureSelection?.provider != ServiceType.CUSTOM_OPENAI)
|
||||
if (featureSelection.provider != ServiceType.CUSTOM_OPENAI)
|
||||
throw IllegalStateException(
|
||||
"Current selected ServiceType (${featureSelection}) is not of type 'CUSTOM_OPENAI'. " +
|
||||
"This function should not be called in this context!"
|
||||
)
|
||||
|
||||
return this.state.services
|
||||
.find { it.id == featureSelection.model }
|
||||
return this.state.services.find { it.id == featureSelection.id }
|
||||
?: throw IllegalStateException("Unable to find custom service with id '${featureSelection.model}'.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package ee.carlrobert.codegpt.settings.service.custom.form
|
|||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.icons.AllIcons.General
|
||||
import com.intellij.ide.BrowserUtil
|
||||
import com.intellij.ide.HelpTooltip
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
|
|
@ -14,11 +13,8 @@ import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
|||
import com.intellij.openapi.ui.*
|
||||
import com.intellij.openapi.ui.DialogWrapper.OK_EXIT_CODE
|
||||
import com.intellij.ui.EnumComboBoxModel
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.ToolbarDecorator
|
||||
import com.intellij.ui.components.*
|
||||
import com.intellij.ui.components.ActionLink
|
||||
// removed: com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.concurrency.AppExecutorUtil
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import com.intellij.util.ui.JBUI
|
||||
|
|
@ -36,6 +32,7 @@ import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceTempl
|
|||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import ee.carlrobert.codegpt.ui.UIUtil
|
||||
import ee.carlrobert.codegpt.util.ApplicationUtil
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -44,7 +41,6 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.internal.toImmutableList
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.FlowLayout
|
||||
|
|
@ -294,8 +290,11 @@ class CustomServiceForm(
|
|||
.panel
|
||||
|
||||
private fun createMarketingPanel(): JPanel {
|
||||
val marketingText = CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.text").trim()
|
||||
val learnMoreText = CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.learnMore").trim()
|
||||
val marketingText =
|
||||
CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.text").trim()
|
||||
val learnMoreText =
|
||||
CodeGPTBundle.get("settingsConfigurable.service.custom.openai.marketing.learnMore")
|
||||
.trim()
|
||||
|
||||
val html = """
|
||||
<html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
package ee.carlrobert.codegpt.tokens
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.EncodingManager
|
||||
|
||||
/**
|
||||
* Utilities to enforce token-based truncation on strings passed back to the model.
|
||||
* Applies a head+tail gap strategy by default to preserve the beginning and the end.
|
||||
*/
|
||||
object ToolResultTruncationConfig {
|
||||
const val MAX_TOKENS: Int = 16_000
|
||||
const val DEFAULT_HEAD_RATIO: Double = 0.85 // keep more head than tail by default
|
||||
const val GAP_MARKER: String = "\n...\n"
|
||||
}
|
||||
|
||||
fun String.countTokens(): Int = service<EncodingManager>().countTokens(this)
|
||||
|
||||
fun String.truncateTokens(maxTokens: Int, fromStart: Boolean = true): String =
|
||||
service<EncodingManager>().truncateText(this, maxTokens, fromStart)
|
||||
|
||||
fun String.truncateTokensWithGap(
|
||||
maxTokens: Int = ToolResultTruncationConfig.MAX_TOKENS,
|
||||
headRatio: Double = ToolResultTruncationConfig.DEFAULT_HEAD_RATIO,
|
||||
gapMarker: String = ToolResultTruncationConfig.GAP_MARKER,
|
||||
): String {
|
||||
if (this.isEmpty()) return this
|
||||
val total = this.countTokens()
|
||||
if (total <= maxTokens) return this
|
||||
|
||||
val safeHeadRatio = headRatio.coerceIn(0.1, 0.9)
|
||||
val headTokens = (maxTokens * safeHeadRatio).toInt().coerceAtLeast(1)
|
||||
val tailTokens = (maxTokens - headTokens).coerceAtLeast(1)
|
||||
|
||||
val head = this.truncateTokens(headTokens, fromStart = true)
|
||||
val tail = this.truncateTokens(tailTokens, fromStart = false)
|
||||
return buildString(head.length + gapMarker.length + tail.length) {
|
||||
append(head)
|
||||
append(gapMarker)
|
||||
append(tail)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.truncateToolResult(maxTokens: Int = ToolResultTruncationConfig.MAX_TOKENS): String =
|
||||
this.truncateTokensWithGap(maxTokens)
|
||||
|
||||
|
|
@ -2,10 +2,12 @@ package ee.carlrobert.codegpt.toolwindow
|
|||
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.wm.ToolWindow
|
||||
import com.intellij.openapi.wm.ToolWindowFactory
|
||||
import com.intellij.ui.content.ContentManagerEvent
|
||||
import com.intellij.ui.content.ContentManagerListener
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentToolWindowPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.history.ChatHistoryToolWindow
|
||||
import javax.swing.JComponent
|
||||
|
|
@ -16,9 +18,13 @@ class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware {
|
|||
project: Project,
|
||||
toolWindow: ToolWindow
|
||||
) {
|
||||
var chatToolWindowPanel = ChatToolWindowPanel(project, toolWindow.disposable)
|
||||
var chatHistoryToolWindow = ChatHistoryToolWindow(project)
|
||||
val agentToolWindowPanel = AgentToolWindowPanel(project).also {
|
||||
Disposer.register(toolWindow.disposable, it)
|
||||
}
|
||||
val chatToolWindowPanel = ChatToolWindowPanel(project, toolWindow.disposable)
|
||||
val chatHistoryToolWindow = ChatHistoryToolWindow(project)
|
||||
|
||||
addContent(toolWindow, agentToolWindowPanel, "Agent")
|
||||
addContent(toolWindow, chatToolWindowPanel, "Chat")
|
||||
addContent(toolWindow, chatHistoryToolWindow.getContent(), "Chat History")
|
||||
|
||||
|
|
@ -27,6 +33,10 @@ class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware {
|
|||
if ("Chat History" == event.content.tabName && event.content.isSelected) {
|
||||
chatHistoryToolWindow.refresh()
|
||||
}
|
||||
|
||||
if (event.content.tabName == "Agent" && !event.content.isSelected) {
|
||||
agentToolWindowPanel.getTabbedPane().onTabHidden()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.diff.DiffContentFactory
|
||||
import com.intellij.diff.chains.SimpleDiffRequestChain
|
||||
import com.intellij.diff.editor.ChainDiffVirtualFile
|
||||
import com.intellij.diff.requests.SimpleDiffRequest
|
||||
import com.intellij.diff.DiffManager
|
||||
import com.intellij.diff.util.DiffUserDataKeys
|
||||
import com.intellij.diff.util.DiffUserDataKeysEx
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.Presentation
|
||||
import com.intellij.openapi.actionSystem.ex.CustomComponentAction
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.fileTypes.FileTypes
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.popup.JBPopup
|
||||
import com.intellij.openapi.ui.popup.JBPopupFactory
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.Icons
|
||||
import ee.carlrobert.codegpt.agent.tools.EditTool
|
||||
import ee.carlrobert.codegpt.agent.tools.WriteTool
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.applyStringReplacement
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.getFileContentWithFallback
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Insets
|
||||
import java.awt.Point
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.nio.file.Paths
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
/**
|
||||
* Manages the approval workflow for agent tool calls, particularly for Write and Edit operations.
|
||||
* Handles diff display, approval dialogs, and popup management.
|
||||
*/
|
||||
class AgentApprovalManager(
|
||||
private val project: Project
|
||||
) {
|
||||
private val approvalPopups = ConcurrentHashMap<String, JBPopup>()
|
||||
|
||||
companion object {
|
||||
private val AGENT_DIFF_REQUEST_KEY: Key<String> = Key.create("agent.approval.diffRequest")
|
||||
}
|
||||
|
||||
fun openWriteApprovalDiff(
|
||||
args: WriteTool.Args,
|
||||
decision: CompletableDeferred<Boolean>
|
||||
) {
|
||||
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(args.filePath)
|
||||
val factory = DiffContentFactory.getInstance()
|
||||
|
||||
val left = if (vf != null) factory.create(project, vf) else factory.create(project, "")
|
||||
val rightDoc = EditorFactory.getInstance().createDocument(args.content)
|
||||
.apply {
|
||||
setReadOnly(true)
|
||||
}
|
||||
val right = if (vf != null) factory.create(project, rightDoc, vf)
|
||||
else factory.create(project, rightDoc, FileTypes.PLAIN_TEXT)
|
||||
|
||||
val request = SimpleDiffRequest(
|
||||
"Write File",
|
||||
listOf(left, right),
|
||||
listOf("Current", "Proposed")
|
||||
)
|
||||
attachApprovalActionsAndShowDiff(request, decision)
|
||||
}
|
||||
|
||||
fun openEditApprovalDiff(args: EditTool.Args, decision: CompletableDeferred<Boolean>) {
|
||||
val path = try {
|
||||
Paths.get(args.filePath).normalize().toString()
|
||||
} catch (_: Exception) {
|
||||
args.filePath
|
||||
}
|
||||
|
||||
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(path)
|
||||
val factory = DiffContentFactory.getInstance()
|
||||
|
||||
val current = getFileContentWithFallback(path)
|
||||
val proposed =
|
||||
applyStringReplacement(current, args.oldString, args.newString, args.replaceAll)
|
||||
|
||||
val left = if (vf != null) factory.create(project, vf) else factory.create(project, current)
|
||||
val rightDoc = EditorFactory.getInstance().createDocument(proposed).apply {
|
||||
setReadOnly(true)
|
||||
}
|
||||
val right = if (vf != null) factory.create(project, rightDoc, vf)
|
||||
else factory.create(project, rightDoc, FileTypes.PLAIN_TEXT)
|
||||
|
||||
val request = SimpleDiffRequest(
|
||||
"Edit File",
|
||||
listOf(left, right),
|
||||
listOf("Current", "Proposed")
|
||||
)
|
||||
attachApprovalActionsAndShowDiff(request, decision)
|
||||
}
|
||||
|
||||
private fun attachApprovalActionsAndShowDiff(
|
||||
request: SimpleDiffRequest,
|
||||
decision: CompletableDeferred<Boolean>
|
||||
) {
|
||||
val diffId = UUID.randomUUID().toString()
|
||||
val applyText = CodeGPTBundle.get("shared.apply")
|
||||
val rejectText = CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject")
|
||||
|
||||
fun resolveApproval(approved: Boolean) {
|
||||
if (!decision.isCompleted) decision.complete(approved)
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
closeDiffById(diffId)
|
||||
}
|
||||
}
|
||||
|
||||
val applyTop = createApplyAction(applyText) { resolveApproval(true) }
|
||||
val rejectTop = createRejectAction(rejectText) { resolveApproval(false) }
|
||||
|
||||
request.putUserData(DiffUserDataKeys.CONTEXT_ACTIONS, listOf(applyTop, rejectTop))
|
||||
|
||||
val bottomPanel = createBottomPanel(applyText, rejectText) { resolveApproval(it) }
|
||||
request.putUserData(DiffUserDataKeysEx.BOTTOM_PANEL, bottomPanel)
|
||||
request.putUserData(AGENT_DIFF_REQUEST_KEY, diffId)
|
||||
|
||||
runInEdt { DiffManager.getInstance().showDiff(project, request) }
|
||||
|
||||
decision.invokeOnCompletion {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
closeDiffById(diffId)
|
||||
}
|
||||
}
|
||||
|
||||
showDiffTabApprovalPopup(diffId, decision)
|
||||
}
|
||||
|
||||
private fun createApplyAction(text: String, onApply: () -> Unit): AnAction =
|
||||
object : AnAction(text, null, Icons.GreenCheckmark), CustomComponentAction {
|
||||
override fun actionPerformed(e: AnActionEvent) = onApply()
|
||||
|
||||
override fun createCustomComponent(
|
||||
presentation: Presentation,
|
||||
place: String
|
||||
): JComponent =
|
||||
JButton(text).apply {
|
||||
icon = Icons.GreenCheckmark
|
||||
isFocusable = false
|
||||
isContentAreaFilled = true
|
||||
isOpaque = true
|
||||
margin = Insets(JBUI.scale(4), JBUI.scale(10), JBUI.scale(4), JBUI.scale(10))
|
||||
addActionListener { onApply() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRejectAction(text: String, onReject: () -> Unit): AnAction =
|
||||
object : AnAction(text, null, AllIcons.Actions.Close), CustomComponentAction {
|
||||
override fun actionPerformed(e: AnActionEvent) = onReject()
|
||||
|
||||
override fun createCustomComponent(
|
||||
presentation: Presentation,
|
||||
place: String
|
||||
): JComponent =
|
||||
JButton(text).apply {
|
||||
isFocusable = false
|
||||
isContentAreaFilled = true
|
||||
isOpaque = true
|
||||
margin = Insets(JBUI.scale(4), JBUI.scale(10), JBUI.scale(4), JBUI.scale(10))
|
||||
addActionListener { onReject() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBottomPanel(
|
||||
applyText: String,
|
||||
rejectText: String,
|
||||
onDecision: (Boolean) -> Unit
|
||||
): JPanel =
|
||||
JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(8), JBUI.scale(8))).apply {
|
||||
isOpaque = true
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0),
|
||||
JBUI.Borders.empty(8)
|
||||
)
|
||||
|
||||
val applyBtn = JButton(applyText).apply {
|
||||
isFocusable = false
|
||||
isContentAreaFilled = true
|
||||
isOpaque = true
|
||||
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
|
||||
addActionListener { onDecision(true) }
|
||||
}
|
||||
|
||||
val rejectBtn = JButton(rejectText).apply {
|
||||
isFocusable = false
|
||||
isContentAreaFilled = true
|
||||
isOpaque = true
|
||||
putClientProperty("JButton.buttonType", "roundRect")
|
||||
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
|
||||
addActionListener { onDecision(false) }
|
||||
}
|
||||
|
||||
add(applyBtn)
|
||||
add(rejectBtn)
|
||||
}
|
||||
|
||||
private fun closeDiffById(diffId: String) {
|
||||
val manager = FileEditorManager.getInstance(project)
|
||||
val diffFile = manager.openFiles.firstOrNull {
|
||||
it is ChainDiffVirtualFile && it.chain.requests
|
||||
.filterIsInstance<SimpleDiffRequestChain.DiffRequestProducerWrapper>()
|
||||
.any { wrapper ->
|
||||
wrapper.request.getUserData(AGENT_DIFF_REQUEST_KEY) == diffId
|
||||
}
|
||||
}
|
||||
if (diffFile != null) manager.closeFile(diffFile)
|
||||
|
||||
approvalPopups.remove(diffId)?.let { if (!it.isDisposed) it.cancel() }
|
||||
}
|
||||
|
||||
private fun findDiffFileById(diffId: String): VirtualFile? {
|
||||
val manager = FileEditorManager.getInstance(project)
|
||||
return manager.openFiles.firstOrNull {
|
||||
it is ChainDiffVirtualFile && it.chain.requests
|
||||
.filterIsInstance<SimpleDiffRequestChain.DiffRequestProducerWrapper>()
|
||||
.any { wrapper ->
|
||||
wrapper.request.getUserData(AGENT_DIFF_REQUEST_KEY) == diffId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDiffTabApprovalPopup(diffId: String, decision: CompletableDeferred<Boolean>) {
|
||||
val applyText = CodeGPTBundle.get("shared.apply")
|
||||
val rejectText = CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject")
|
||||
|
||||
val panel = BorderLayoutPanel().apply {
|
||||
isOpaque = true
|
||||
background = JBUI.CurrentTheme.CustomFrameDecorations.paneBackground()
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLine(JBColor.border(), 1),
|
||||
JBUI.Borders.empty(8, 12)
|
||||
)
|
||||
|
||||
val buttons = JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(8), 0)).apply {
|
||||
isOpaque = false
|
||||
val applyBtn = JButton(applyText).apply {
|
||||
isContentAreaFilled = true
|
||||
isOpaque = true
|
||||
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
|
||||
addActionListener {
|
||||
decision.complete(true)
|
||||
closeDiffById(diffId)
|
||||
}
|
||||
}
|
||||
val rejectBtn = JButton(rejectText).apply {
|
||||
isContentAreaFilled = true
|
||||
isOpaque = true
|
||||
margin = Insets(JBUI.scale(6), JBUI.scale(12), JBUI.scale(6), JBUI.scale(12))
|
||||
addActionListener {
|
||||
decision.complete(false)
|
||||
closeDiffById(diffId)
|
||||
}
|
||||
}
|
||||
add(applyBtn)
|
||||
add(rejectBtn)
|
||||
}
|
||||
add(buttons, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
runInEdt {
|
||||
val diffFile = findDiffFileById(diffId) ?: return@runInEdt
|
||||
val manager = FileEditorManager.getInstance(project)
|
||||
val fileEditor = manager.getSelectedEditor(diffFile)
|
||||
?: manager.getEditors(diffFile).firstOrNull()
|
||||
val target = fileEditor?.component ?: return@runInEdt
|
||||
|
||||
val popup = JBPopupFactory.getInstance()
|
||||
.createComponentPopupBuilder(panel, null)
|
||||
.setRequestFocus(false)
|
||||
.setFocusable(false)
|
||||
.setMovable(false)
|
||||
.setResizable(false)
|
||||
.setShowBorder(false)
|
||||
.setCancelOnClickOutside(false)
|
||||
.setCancelOnOtherWindowOpen(true)
|
||||
.createPopup()
|
||||
|
||||
approvalPopups[diffId] = popup
|
||||
|
||||
val margin = JBUI.scale(12)
|
||||
val size = panel.preferredSize
|
||||
val onScreen = target.locationOnScreen
|
||||
val x = onScreen.x + target.width - size.width - margin
|
||||
val y = onScreen.y + target.height - size.height - margin
|
||||
popup.showInScreenCoordinates(target, Point(x, y))
|
||||
|
||||
val listener = object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent?) = relocate()
|
||||
override fun componentMoved(e: ComponentEvent?) = relocate()
|
||||
|
||||
private fun relocate() {
|
||||
if (popup.isDisposed) return
|
||||
val loc = target.locationOnScreen
|
||||
val nx = loc.x + target.width - size.width - margin
|
||||
val ny = loc.y + target.height - size.height - margin
|
||||
popup.setLocation(Point(nx, ny))
|
||||
}
|
||||
}
|
||||
target.addComponentListener(listener)
|
||||
Disposer.register(popup) {
|
||||
try {
|
||||
target.removeComponentListener(listener)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
|
||||
data class AgentCreditsEvent(
|
||||
val sessionId: String,
|
||||
val remaining: Long,
|
||||
val monthlyRemaining: Long,
|
||||
val consumed: Long,
|
||||
)
|
||||
|
||||
interface AgentCreditsListener {
|
||||
fun onCreditsChanged(event: AgentCreditsEvent)
|
||||
|
||||
companion object {
|
||||
val AGENT_CREDITS_TOPIC = Topic("Agent Credits Changes", AgentCreditsListener::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,681 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import ai.koog.agents.core.feature.handler.agent.AgentCompletedContext
|
||||
import ai.koog.http.client.KoogHttpClientException
|
||||
import ai.koog.prompt.executor.clients.LLMClientException
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.agent.*
|
||||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import ee.carlrobert.codegpt.conversations.message.TokenUsage
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.*
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.*
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel
|
||||
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.Component
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.swing.JPanel
|
||||
|
||||
class AgentEventHandler(
|
||||
private val project: Project,
|
||||
private val sessionId: String,
|
||||
private val agentApprovalManager: AgentApprovalManager,
|
||||
private val approvalContainer: JPanel,
|
||||
private val scrollablePanel: ChatToolWindowScrollablePanel,
|
||||
private val todoListPanel: TodoListPanel,
|
||||
private val userInputPanel: UserInputPanel,
|
||||
private val onShowLoading: (String) -> Unit,
|
||||
private val onHideLoading: () -> Unit,
|
||||
private val onQueuedMessagesResolved: (MessageWithContext) -> Unit = {}
|
||||
) : AgentEvents, Disposable {
|
||||
|
||||
companion object {
|
||||
val logger = thisLogger()
|
||||
}
|
||||
|
||||
private val mainToolCards = ConcurrentHashMap<String, ToolCallCard>()
|
||||
|
||||
@Volatile
|
||||
private var lastReportedPromptTokens: Long = 0
|
||||
|
||||
private fun keyFor(toolId: String): String = "$sessionId:$toolId"
|
||||
|
||||
@Volatile
|
||||
private var currentResponseBody: ChatMessageResponseBody? = null
|
||||
|
||||
@Volatile
|
||||
private var lastWriteArgs: WriteTool.Args? = null
|
||||
|
||||
@Volatile
|
||||
private var lastEditArgs: EditTool.Args? = null
|
||||
|
||||
private val approvalQueue: ArrayDeque<ApprovalRequest> = ArrayDeque()
|
||||
|
||||
@Volatile
|
||||
private var currentApproval: ApprovalRequest? = null
|
||||
|
||||
private data class QuestionRequest(
|
||||
val model: AskUserQuestionTool.AskUserQuestionsModel,
|
||||
val deferred: CompletableDeferred<Map<String, String>>
|
||||
)
|
||||
|
||||
private val questionQueue: ArrayDeque<QuestionRequest> = ArrayDeque()
|
||||
|
||||
@Volatile
|
||||
private var currentQuestion: QuestionRequest? = null
|
||||
private var runViewHolder: RunViewHolder? = null
|
||||
|
||||
private val subagentViewHolders = ConcurrentHashMap<String, RunViewHolder>()
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
data class ApprovalRequest(
|
||||
var model: ToolApprovalRequest,
|
||||
val deferred: CompletableDeferred<Boolean>
|
||||
)
|
||||
|
||||
fun resetForNewSubmission() {
|
||||
mainToolCards.clear()
|
||||
currentResponseBody = null
|
||||
lastWriteArgs = null
|
||||
lastEditArgs = null
|
||||
currentApproval = null
|
||||
approvalQueue.clear()
|
||||
currentQuestion = null
|
||||
questionQueue.clear()
|
||||
approvalContainer.removeAll()
|
||||
approvalContainer.isVisible = false
|
||||
todoListPanel.clearTodos()
|
||||
runViewHolder = null
|
||||
subagentViewHolders.clear()
|
||||
lastReportedPromptTokens = 0
|
||||
}
|
||||
|
||||
fun setCurrentResponseBody(responseBody: ChatMessageResponseBody) {
|
||||
currentResponseBody = responseBody
|
||||
}
|
||||
|
||||
override fun onAgentCompleted(ctx: AgentCompletedContext) {
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>().getTabbedPane()
|
||||
.onAgentCompleted(sessionId)
|
||||
}
|
||||
|
||||
val tabTitle = runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.getTabbedPane()
|
||||
.tryFindTabTitle(sessionId)
|
||||
.orElse(null)
|
||||
}.getOrNull()
|
||||
val resolvedAgentId = project.service<AgentService>().getAgentForSession(sessionId)?.id
|
||||
?: ctx.agentId
|
||||
project.service<AgentSessionState>().updateSession(
|
||||
sessionId,
|
||||
lastAgentId = resolvedAgentId,
|
||||
displayName = tabTitle
|
||||
)
|
||||
handleDone()
|
||||
}
|
||||
|
||||
override fun onClientException(provider: ServiceType, ex: LLMClientException) {
|
||||
val cause = ex.cause
|
||||
if (cause is KoogHttpClientException) {
|
||||
if (provider == ServiceType.PROXYAI) {
|
||||
handleProxyAIException(cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProxyAIException(ex: KoogHttpClientException) {
|
||||
when (ex.statusCode) {
|
||||
403 -> {
|
||||
currentResponseBody?.displayMissingCredential()
|
||||
handleDone()
|
||||
}
|
||||
|
||||
429 -> {
|
||||
currentResponseBody?.displayCreditsExhausted()
|
||||
handleDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDone() {
|
||||
runInEdt {
|
||||
currentResponseBody?.finishThinking()
|
||||
onHideLoading()
|
||||
userInputPanel.setStopEnabled(false)
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
todoListPanel.clearTodos()
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.STOPPED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun approveToolCall(request: ToolApprovalRequest): Boolean {
|
||||
val isWrite = request.type == ToolApprovalType.WRITE
|
||||
val isEdit = request.type == ToolApprovalType.EDIT
|
||||
|
||||
if (isWrite || isEdit) {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
runInEdt {
|
||||
approvalQueue.addLast(ApprovalRequest(request, deferred))
|
||||
maybeShowNextApproval()
|
||||
}
|
||||
|
||||
lastWriteArgs?.let {
|
||||
if (isWrite) agentApprovalManager.openWriteApprovalDiff(it, deferred)
|
||||
}
|
||||
lastEditArgs?.let {
|
||||
if (isEdit) agentApprovalManager.openEditApprovalDiff(it, deferred)
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
val decision = CompletableDeferred<Boolean>()
|
||||
runInEdt {
|
||||
approvalQueue.addLast(ApprovalRequest(request, decision))
|
||||
maybeShowNextApproval()
|
||||
}
|
||||
return decision.await()
|
||||
}
|
||||
|
||||
override suspend fun askUserQuestions(
|
||||
model: AskUserQuestionTool.AskUserQuestionsModel
|
||||
): Map<String, String> {
|
||||
val deferred = CompletableDeferred<Map<String, String>>()
|
||||
runInEdt {
|
||||
questionQueue.addLast(QuestionRequest(model, deferred))
|
||||
maybeShowNextQuestion()
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
override fun onTextReceived(text: String) {
|
||||
runInEdt {
|
||||
val cleanedText =
|
||||
text.replace(Regex("<think>.*?</think>", RegexOption.DOT_MATCHES_ALL), "")
|
||||
currentResponseBody?.updateMessage(cleanedText)
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onToolStarting(id: String, toolName: String, args: Any?) {
|
||||
when (args) {
|
||||
is TodoWriteTool.Args -> {
|
||||
runInEdt {
|
||||
val inProgressTask =
|
||||
args.todos.find { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
|
||||
if (inProgressTask != null) {
|
||||
onShowLoading(inProgressTask.activeForm)
|
||||
}
|
||||
todoListPanel.updateTodos(args.todos)
|
||||
todoListPanel.isVisible = true
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
is TaskTool.Args -> {
|
||||
runInEdt {
|
||||
val host = ensureRunViewForSubagent(id)
|
||||
host.addEntry(RunEntry.TaskEntry(id, null, args, null))
|
||||
host.refresh()
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (args is WriteTool.Args) {
|
||||
lastWriteArgs = args
|
||||
} else if (args is EditTool.Args) {
|
||||
lastEditArgs = args
|
||||
}
|
||||
|
||||
if (toolName != "Task") {
|
||||
runInEdt {
|
||||
val key = keyFor(id)
|
||||
if (!mainToolCards.containsKey(key)) {
|
||||
val card = ToolCallCard(project, toolName, args)
|
||||
mainToolCards[key] = card
|
||||
currentResponseBody?.addToolStatusPanel(card)
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onToolCompleted(id: String?, toolName: String, result: Any?) {
|
||||
runInEdt {
|
||||
if (id != null && (toolName == "Task" || result is TaskTool.Result)) {
|
||||
val holder = runViewHolder ?: subagentViewHolders.values.firstOrNull { viewHolder ->
|
||||
viewHolder.getItems().any { entry -> entry.id == id }
|
||||
}
|
||||
holder?.completeEntry(id, result)
|
||||
holder?.refresh()
|
||||
} else if (id != null && mainToolCards.containsKey(keyFor(id))) {
|
||||
val success = result !is ToolError && result != null
|
||||
mainToolCards[keyFor(id)]?.complete(success, result)
|
||||
|
||||
val bgId = (result as? BashTool.Result)?.bashId
|
||||
if (bgId == null) {
|
||||
mainToolCards.remove(keyFor(id))
|
||||
} else {
|
||||
val publisher =
|
||||
ApplicationManager.getApplication()
|
||||
.messageBus
|
||||
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
|
||||
serviceScope.launch {
|
||||
try {
|
||||
var outPos = 0
|
||||
var errPos = 0
|
||||
while (true) {
|
||||
val po = BackgroundProcessManager.getOutput(bgId) ?: break
|
||||
val stdout = po.stdout.toString()
|
||||
val stderr = po.stderr.toString()
|
||||
if (outPos < stdout.length) {
|
||||
stdout.substring(outPos).split('\n').forEach { line ->
|
||||
if (line.isNotEmpty()) publisher.toolOutput(id, line, false)
|
||||
}
|
||||
outPos = stdout.length
|
||||
}
|
||||
if (errPos < stderr.length) {
|
||||
stderr.substring(errPos).split('\n').forEach { line ->
|
||||
if (line.isNotEmpty()) publisher.toolOutput(id, line, true)
|
||||
}
|
||||
errPos = stderr.length
|
||||
}
|
||||
if (po.isComplete) break
|
||||
delay(300)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
runInEdt {
|
||||
mainToolCards.remove(keyFor(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSubAgentToolStarting(parentId: String, toolName: String, args: Any?): String {
|
||||
val cid = UUID.randomUUID().toString()
|
||||
|
||||
if (args is TodoWriteTool.Args) {
|
||||
val inProgressTask =
|
||||
args.todos.find { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
|
||||
if (inProgressTask != null) {
|
||||
onShowLoading(inProgressTask.activeForm)
|
||||
}
|
||||
return cid
|
||||
}
|
||||
|
||||
runInEdt {
|
||||
val host = ensureRunViewForSubagent(parentId)
|
||||
val entry = when (args) {
|
||||
is ReadTool.Args -> RunEntry.ReadEntry(cid, parentId, args, null)
|
||||
is IntelliJSearchTool.Args -> RunEntry.IntelliJSearchEntry(
|
||||
cid,
|
||||
parentId,
|
||||
args,
|
||||
null
|
||||
)
|
||||
|
||||
is BashTool.Args -> RunEntry.BashEntry(cid, parentId, args, null)
|
||||
is BashOutputTool.Args -> RunEntry.BashEntry(cid, parentId, null, null)
|
||||
is KillShellTool.Args -> RunEntry.OtherEntry(
|
||||
cid,
|
||||
parentId,
|
||||
"Kill Bash (${args.bashId.take(6)})"
|
||||
)
|
||||
|
||||
is WebSearchTool.Args -> RunEntry.WebEntry(cid, parentId, args, null)
|
||||
is GetLibraryDocsTool.Args -> RunEntry.LibraryDocsEntry(cid, parentId, args, null)
|
||||
is ResolveLibraryIdTool.Args -> RunEntry.LibraryResolveEntry(
|
||||
cid,
|
||||
parentId,
|
||||
args,
|
||||
null
|
||||
)
|
||||
|
||||
is WriteTool.Args -> {
|
||||
lastWriteArgs = args
|
||||
RunEntry.WriteEntry(cid, parentId, args, null)
|
||||
}
|
||||
|
||||
is EditTool.Args -> {
|
||||
lastEditArgs = args
|
||||
RunEntry.EditEntry(cid, parentId, args, null)
|
||||
}
|
||||
|
||||
is TaskTool.Args -> RunEntry.TaskEntry(cid, parentId, args, null)
|
||||
|
||||
else -> RunEntry.OtherEntry(cid, parentId, toolName)
|
||||
}
|
||||
host.addEntry(entry)
|
||||
host.refresh()
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
}
|
||||
return cid
|
||||
}
|
||||
|
||||
override fun onSubAgentToolCompleted(
|
||||
parentId: String,
|
||||
childId: String?,
|
||||
toolName: String,
|
||||
result: Any?
|
||||
) {
|
||||
when (result) {
|
||||
is TaskTool.InternalResult -> {
|
||||
result.tokenUsage?.let { usage -> onTokenUsageUpdated(usage) }
|
||||
}
|
||||
|
||||
is TokenUsage -> {
|
||||
onTokenUsageUpdated(result)
|
||||
}
|
||||
}
|
||||
|
||||
runInEdt {
|
||||
if (childId != null) {
|
||||
val holder = subagentViewHolders.values.find { viewHolder ->
|
||||
viewHolder.getItems().any { entry -> entry.id == childId }
|
||||
} ?: runViewHolder
|
||||
holder?.completeEntry(childId, result)
|
||||
holder?.refresh()
|
||||
|
||||
val bgId = (result as? BashTool.Result)?.bashId
|
||||
if (bgId != null) {
|
||||
val publisher =
|
||||
ApplicationManager.getApplication()
|
||||
.messageBus
|
||||
.syncPublisher(AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC)
|
||||
serviceScope.launch {
|
||||
try {
|
||||
var outPos = 0
|
||||
var errPos = 0
|
||||
while (true) {
|
||||
val po = BackgroundProcessManager.getOutput(bgId) ?: break
|
||||
val stdout = po.stdout.toString()
|
||||
val stderr = po.stderr.toString()
|
||||
if (outPos < stdout.length) {
|
||||
stdout.substring(outPos).split('\n').forEach { line ->
|
||||
if (line.isNotEmpty()) publisher.toolOutput(
|
||||
childId,
|
||||
line,
|
||||
false
|
||||
)
|
||||
}
|
||||
outPos = stdout.length
|
||||
}
|
||||
if (errPos < stderr.length) {
|
||||
stderr.substring(errPos).split('\n').forEach { line ->
|
||||
if (line.isNotEmpty()) publisher.toolOutput(
|
||||
childId,
|
||||
line,
|
||||
true
|
||||
)
|
||||
}
|
||||
errPos = stderr.length
|
||||
}
|
||||
if (po.isComplete) break
|
||||
delay(300)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollablePanel.update()
|
||||
scrollablePanel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQueuedMessagesResolved() {
|
||||
val pendingMessage =
|
||||
project.service<AgentService>().getPendingMessages(sessionId).firstOrNull() ?: return
|
||||
onQueuedMessagesResolved(pendingMessage)
|
||||
}
|
||||
|
||||
override fun onTokenUsageAvailable(tokenUsage: Long) {
|
||||
val delta = (tokenUsage - lastReportedPromptTokens).coerceAtLeast(0)
|
||||
if (delta > 0) {
|
||||
project.service<AgentService>()
|
||||
.getTokenTrackerForSession(sessionId)
|
||||
.addPromptTokens(delta)
|
||||
}
|
||||
lastReportedPromptTokens = tokenUsage
|
||||
|
||||
val event = TokenUsageEvent(sessionId, tokenUsage)
|
||||
project.messageBus.syncPublisher(TokenUsageListener.TOKEN_USAGE_TOPIC)
|
||||
.onTokenUsageChanged(event)
|
||||
}
|
||||
|
||||
override fun onTokenUsageUpdated(tokenUsage: TokenUsage) {
|
||||
val tracker = project.service<AgentService>().getTokenTrackerForSession(sessionId)
|
||||
tracker.updateTokenUsage(tokenUsage)
|
||||
val event = TokenUsageEvent(sessionId, tracker.getPromptTokens())
|
||||
project.messageBus.syncPublisher(TokenUsageListener.TOKEN_USAGE_TOPIC)
|
||||
.onTokenUsageChanged(event)
|
||||
}
|
||||
|
||||
override fun onCreditsAvailable(event: AgentCreditsEvent) {
|
||||
project.messageBus.syncPublisher(AgentCreditsListener.AGENT_CREDITS_TOPIC)
|
||||
.onCreditsChanged(event)
|
||||
}
|
||||
|
||||
private fun maybeShowNextApproval() {
|
||||
if (currentApproval != null) return
|
||||
val next = approvalQueue.pollFirst() ?: run {
|
||||
approvalContainer.removeAll()
|
||||
approvalContainer.isVisible = false
|
||||
approvalContainer.revalidate()
|
||||
approvalContainer.repaint()
|
||||
maybeShowNextQuestion()
|
||||
return
|
||||
}
|
||||
|
||||
val contentManager = project.service<AgentToolWindowContentManager>()
|
||||
if (contentManager.isSessionAutoApproved(sessionId)) {
|
||||
next.deferred.complete(true)
|
||||
currentApproval = null
|
||||
maybeShowNextApproval()
|
||||
return
|
||||
}
|
||||
|
||||
currentApproval = next
|
||||
if (next.model.type == ToolApprovalType.WRITE && next.model.payload == null) {
|
||||
lastWriteArgs?.let { wa ->
|
||||
next.model = next.model.copy(payload = WritePayload(wa.filePath, wa.content))
|
||||
}
|
||||
} else if (next.model.type == ToolApprovalType.EDIT && next.model.payload == null) {
|
||||
lastEditArgs?.let { ea ->
|
||||
next.model = next.model.copy(
|
||||
payload = EditPayload(
|
||||
ea.filePath,
|
||||
ea.oldString,
|
||||
ea.newString,
|
||||
ea.replaceAll
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.APPROVAL)
|
||||
}
|
||||
|
||||
val panel = DefaultApprovalPanelFactory.create(
|
||||
project,
|
||||
next.model,
|
||||
onApprove = { auto ->
|
||||
if (auto) {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.markSessionAsAutoApproved(sessionId)
|
||||
}
|
||||
|
||||
next.deferred.complete(true)
|
||||
currentApproval = null
|
||||
approvalContainer.removeAll()
|
||||
approvalContainer.isVisible = false
|
||||
approvalContainer.revalidate()
|
||||
approvalContainer.repaint()
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
|
||||
}
|
||||
maybeShowNextApproval()
|
||||
},
|
||||
onReject = {
|
||||
next.deferred.complete(false)
|
||||
currentApproval = null
|
||||
approvalContainer.removeAll()
|
||||
approvalContainer.isVisible = false
|
||||
approvalContainer.revalidate()
|
||||
approvalContainer.repaint()
|
||||
|
||||
try {
|
||||
project.service<AgentService>().cancelCurrentRun(sessionId)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
handleDone()
|
||||
}
|
||||
)
|
||||
approvalContainer.removeAll()
|
||||
panel.alignmentX = Component.LEFT_ALIGNMENT
|
||||
approvalContainer.add(panel)
|
||||
approvalContainer.isVisible = true
|
||||
approvalContainer.revalidate()
|
||||
approvalContainer.repaint()
|
||||
}
|
||||
|
||||
private fun maybeShowNextQuestion() {
|
||||
if (currentApproval != null || currentQuestion != null) return
|
||||
val next = questionQueue.pollFirst() ?: return
|
||||
|
||||
currentQuestion = next
|
||||
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.APPROVAL)
|
||||
}
|
||||
|
||||
val panel = AskUserQuestionPanel(
|
||||
model = next.model,
|
||||
onSubmit = { answers ->
|
||||
next.deferred.complete(answers)
|
||||
currentQuestion = null
|
||||
approvalContainer.removeAll()
|
||||
approvalContainer.isVisible = false
|
||||
approvalContainer.revalidate()
|
||||
approvalContainer.repaint()
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
|
||||
}
|
||||
maybeShowNextApproval()
|
||||
maybeShowNextQuestion()
|
||||
},
|
||||
onCancel = {
|
||||
next.deferred.complete(emptyMap())
|
||||
currentQuestion = null
|
||||
approvalContainer.removeAll()
|
||||
approvalContainer.isVisible = false
|
||||
approvalContainer.revalidate()
|
||||
approvalContainer.repaint()
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
|
||||
}
|
||||
maybeShowNextApproval()
|
||||
maybeShowNextQuestion()
|
||||
}
|
||||
)
|
||||
approvalContainer.removeAll()
|
||||
panel.alignmentX = Component.LEFT_ALIGNMENT
|
||||
approvalContainer.add(panel)
|
||||
approvalContainer.isVisible = true
|
||||
approvalContainer.revalidate()
|
||||
approvalContainer.repaint()
|
||||
}
|
||||
|
||||
private fun ensureRunViewForSubagent(taskId: String): RunViewHolder {
|
||||
val existing = subagentViewHolders[taskId]
|
||||
if (existing != null) return existing
|
||||
|
||||
val vm = AgentRunViewModel()
|
||||
val view = AgentRunDslPanel(vm)
|
||||
currentResponseBody?.addToolStatusPanel(view.component)
|
||||
val viewHolder = RunViewHolder(vm, view)
|
||||
subagentViewHolders[taskId] = viewHolder
|
||||
return viewHolder
|
||||
}
|
||||
|
||||
class RunViewHolder(
|
||||
private val vm: AgentRunViewModel,
|
||||
private val view: AgentRunDslPanel,
|
||||
) {
|
||||
fun addEntry(entry: RunEntry) {
|
||||
vm.addEntry(entry)
|
||||
view.refresh()
|
||||
}
|
||||
|
||||
fun completeEntry(id: String, result: Any?) {
|
||||
vm.completeEntry(id, result)
|
||||
view.refresh()
|
||||
}
|
||||
|
||||
fun refresh() = view.refresh()
|
||||
|
||||
fun getItems(): List<RunEntry> {
|
||||
return vm.items
|
||||
}
|
||||
|
||||
fun appendStreamingLine(id: String, text: String, isError: Boolean): Boolean {
|
||||
return view.appendStreaming(id, text, isError)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleToolOutput(toolId: String, text: String, isError: Boolean) {
|
||||
runInEdt {
|
||||
var handled = false
|
||||
mainToolCards[toolId]?.let {
|
||||
it.appendStreamingLine(text, isError)
|
||||
handled = true
|
||||
}
|
||||
if (!handled) {
|
||||
val rawId =
|
||||
if (toolId.startsWith("$sessionId:")) toolId.substringAfter(":") else toolId
|
||||
subagentViewHolders.values.firstOrNull { holder ->
|
||||
holder.appendStreamingLine(rawId, text, isError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
mainToolCards.clear()
|
||||
approvalQueue.clear()
|
||||
subagentViewHolders.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
|
||||
/**
|
||||
* Represents a single Agent session with its own conversation state and metadata.
|
||||
* Each tab in the Agent tool window corresponds to one AgentSession.
|
||||
*/
|
||||
data class AgentSession(
|
||||
val sessionId: String,
|
||||
val conversation: Conversation,
|
||||
var displayName: String = "",
|
||||
var serviceType: ServiceType? = null,
|
||||
val referencedFiles: List<VirtualFile> = emptyList(),
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
var lastActiveAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.openapi.components.*
|
||||
import com.intellij.util.xmlb.XmlSerializerUtil
|
||||
|
||||
@State(
|
||||
name = "ProxyAI_AgentSessionState",
|
||||
storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]
|
||||
)
|
||||
@Service(Service.Level.PROJECT)
|
||||
class AgentSessionState : PersistentStateComponent<AgentSessionState.State> {
|
||||
|
||||
data class SessionEntry(
|
||||
var sessionId: String = "",
|
||||
var lastAgentId: String? = null,
|
||||
var checkpointId: String? = null,
|
||||
var displayName: String = "",
|
||||
)
|
||||
|
||||
data class State(
|
||||
var sessions: MutableList<SessionEntry> = mutableListOf(),
|
||||
var lastActiveSessionId: String? = null
|
||||
)
|
||||
|
||||
private var state = State()
|
||||
|
||||
override fun getState(): State = state
|
||||
|
||||
override fun loadState(state: State) {
|
||||
XmlSerializerUtil.copyBean(state, this.state)
|
||||
}
|
||||
|
||||
fun getSessionIds(): List<String> {
|
||||
return state.sessions.mapNotNull { it.sessionId.takeIf { id -> id.isNotBlank() } }
|
||||
}
|
||||
|
||||
fun getLastActiveSessionId(): String? = state.lastActiveSessionId
|
||||
|
||||
fun setLastActiveSessionId(sessionId: String) {
|
||||
state.lastActiveSessionId = sessionId
|
||||
}
|
||||
|
||||
fun getLastAgentId(sessionId: String): String? {
|
||||
return state.sessions.firstOrNull { it.sessionId == sessionId }?.lastAgentId
|
||||
}
|
||||
|
||||
fun getDisplayName(sessionId: String): String? {
|
||||
return state.sessions.firstOrNull { it.sessionId == sessionId }?.displayName
|
||||
}
|
||||
|
||||
fun ensureSession(sessionId: String): SessionEntry {
|
||||
val existing = state.sessions.firstOrNull { it.sessionId == sessionId }
|
||||
if (existing != null) return existing
|
||||
|
||||
val entry = SessionEntry(sessionId = sessionId)
|
||||
state.sessions.add(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
fun updateSession(sessionId: String, lastAgentId: String? = null, checkpointId: String? = null, displayName: String? = null) {
|
||||
val entry = ensureSession(sessionId)
|
||||
if (lastAgentId != null) {
|
||||
entry.lastAgentId = lastAgentId
|
||||
}
|
||||
if (displayName != null) {
|
||||
entry.displayName = displayName
|
||||
}
|
||||
if (checkpointId != null) {
|
||||
entry.checkpointId = checkpointId
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSession(sessionId: String) {
|
||||
state.sessions.removeIf { it.sessionId == sessionId }
|
||||
if (state.lastActiveSessionId == sessionId) {
|
||||
state.lastActiveSessionId = null
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceSession(oldSessionId: String, newSessionId: String) {
|
||||
removeSession(oldSessionId)
|
||||
ensureSession(newSessionId)
|
||||
state.lastActiveSessionId = newSessionId
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
|
||||
interface AgentTabTitleNotifier {
|
||||
|
||||
fun updateTabTitle(sessionId: String, title: String)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
val AGENT_TAB_TITLE_TOPIC = Topic.create("agentTabTitle", AgentTabTitleNotifier::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
class AgentToolWindowContentManager(private val project: Project) : Disposable {
|
||||
|
||||
private val activeSessions = mutableMapOf<String, AgentSession>()
|
||||
private val tabPanels = mutableMapOf<String, AgentToolWindowTabPanel>()
|
||||
private val autoApprovedSessions = mutableSetOf<String>()
|
||||
private val sessionState = project.service<AgentSessionState>()
|
||||
private val tabbedPane = AgentToolWindowTabbedPane(project)
|
||||
|
||||
fun initializeTabbedPane(): AgentToolWindowTabbedPane {
|
||||
project.messageBus.connect()
|
||||
.subscribe(AgentTabTitleNotifier.AGENT_TAB_TITLE_TOPIC, object : AgentTabTitleNotifier {
|
||||
override fun updateTabTitle(sessionId: String, title: String) {
|
||||
renameTab(sessionId, title)
|
||||
}
|
||||
})
|
||||
|
||||
val storedSessions = sessionState.getSessionIds()
|
||||
if (storedSessions.isEmpty()) {
|
||||
createNewAgentTab()
|
||||
} else {
|
||||
storedSessions.forEach { sessionId ->
|
||||
createNewAgentTab(sessionId = sessionId, select = false)
|
||||
}
|
||||
sessionState.getLastActiveSessionId()?.let { lastActive ->
|
||||
tabbedPane.tryFindTabTitle(lastActive).ifPresent { title ->
|
||||
val tabIndex = tabbedPane.indexOfTab(title)
|
||||
if (tabIndex >= 0) {
|
||||
tabbedPane.selectedIndex = tabIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tabbedPane
|
||||
}
|
||||
|
||||
fun createNewAgentTab(
|
||||
sessionId: String? = null,
|
||||
select: Boolean = true
|
||||
): AgentToolWindowTabPanel {
|
||||
val tabPanel = if (sessionId != null) {
|
||||
AgentToolWindowTabPanel(project, sessionId)
|
||||
} else {
|
||||
AgentToolWindowTabPanel(project)
|
||||
}
|
||||
val resolvedSessionId = tabPanel.getSessionId()
|
||||
val session = AgentSession(resolvedSessionId, tabPanel.getConversation())
|
||||
val sessionEntry = sessionState.ensureSession(resolvedSessionId)
|
||||
if (sessionEntry.displayName.isNotBlank()) {
|
||||
tabPanel.getAgentSession().displayName = sessionEntry.displayName
|
||||
session.displayName = sessionEntry.displayName
|
||||
}
|
||||
activeSessions[session.sessionId] = session
|
||||
tabPanels[session.sessionId] = tabPanel
|
||||
tabbedPane.addNewTab(tabPanel, select)
|
||||
|
||||
return tabPanel
|
||||
}
|
||||
|
||||
fun getActiveTabPanel(): AgentToolWindowTabPanel? {
|
||||
return tabbedPane.tryFindActiveTabPanel().orElse(null)
|
||||
}
|
||||
|
||||
fun getTabbedPane(): AgentToolWindowTabbedPane = tabbedPane
|
||||
|
||||
fun resetCurrentlyActiveTab() {
|
||||
tabbedPane.resetCurrentlyActiveTabPanel()
|
||||
}
|
||||
|
||||
fun renameTab(sessionId: String, newName: String) {
|
||||
tabbedPane.tryFindTabTitle(sessionId).ifPresent { title ->
|
||||
val tabIndex = tabbedPane.indexOfTab(title)
|
||||
if (tabIndex >= 0) {
|
||||
tabbedPane.renameTab(tabIndex, newName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTabStatus(sessionId: String, status: AgentToolWindowTabbedPane.TabStatus) {
|
||||
tabbedPane.updateStatusForSession(sessionId, status)
|
||||
}
|
||||
|
||||
fun markSessionAsAutoApproved(sessionId: String) {
|
||||
autoApprovedSessions.add(sessionId)
|
||||
}
|
||||
|
||||
fun isSessionAutoApproved(sessionId: String): Boolean {
|
||||
return autoApprovedSessions.contains(sessionId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getInstance(project: Project): AgentToolWindowContentManager {
|
||||
return project.service()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
tabPanels.values.forEach { tabPanel ->
|
||||
Disposer.dispose(tabPanel)
|
||||
}
|
||||
tabPanels.clear()
|
||||
activeSessions.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.*
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.SimpleToolWindowPanel
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentCreditsToolbarLabel
|
||||
import javax.swing.JComponent
|
||||
|
||||
class AgentToolWindowPanel(
|
||||
private val project: Project
|
||||
) : SimpleToolWindowPanel(true), Disposable {
|
||||
|
||||
private val contentManager = project.service<AgentToolWindowContentManager>()
|
||||
private val tabbedPane = contentManager.initializeTabbedPane()
|
||||
|
||||
init {
|
||||
toolbar = createToolbar()
|
||||
setContent(tabbedPane)
|
||||
}
|
||||
|
||||
private fun createToolbar(): JComponent {
|
||||
val actionGroup = DefaultActionGroup().apply {
|
||||
add(object : AnAction(
|
||||
"New Agent",
|
||||
"Create a new Agent session",
|
||||
AllIcons.General.Add
|
||||
) {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
contentManager.createNewAgentTab()
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread {
|
||||
return ActionUpdateThread.EDT
|
||||
}
|
||||
})
|
||||
|
||||
add(object : AnAction(
|
||||
"Reset Current Agent",
|
||||
"Clear all messages and reset the current agent view",
|
||||
AllIcons.General.Reset
|
||||
) {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
contentManager.resetCurrentlyActiveTab()
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread {
|
||||
return ActionUpdateThread.EDT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val toolbar = ActionManager.getInstance()
|
||||
.createActionToolbar("AgentToolWindow", actionGroup, true)
|
||||
toolbar.targetComponent = tabbedPane
|
||||
val creditsLabel = AgentCreditsToolbarLabel(project)
|
||||
Disposer.register(this, creditsLabel)
|
||||
return BorderLayoutPanel()
|
||||
.addToLeft(toolbar.component)
|
||||
.addToRight(creditsLabel)
|
||||
}
|
||||
|
||||
fun getTabbedPane(): AgentToolWindowTabbedPane = tabbedPane
|
||||
|
||||
override fun dispose() {
|
||||
tabbedPane.dispose()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.agent.AgentService
|
||||
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
|
||||
import ee.carlrobert.codegpt.agent.MessageWithContext
|
||||
import ee.carlrobert.codegpt.agent.ToolRunContext
|
||||
import ee.carlrobert.codegpt.conversations.Conversation
|
||||
import ee.carlrobert.codegpt.conversations.message.Message
|
||||
import ee.carlrobert.codegpt.conversations.message.QueuedMessage
|
||||
import ee.carlrobert.codegpt.psistructure.PsiStructureProvider
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentToolWindowLandingPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.TodoListPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.MessageBuilder
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel
|
||||
import ee.carlrobert.codegpt.toolwindow.ui.UserMessagePanel
|
||||
import ee.carlrobert.codegpt.ui.UIUtil.createScrollPaneWithSmartScroller
|
||||
import ee.carlrobert.codegpt.ui.components.TokenUsageCounterPanel
|
||||
import ee.carlrobert.codegpt.ui.queue.QueuedMessagePanel
|
||||
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
|
||||
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
|
||||
import ee.carlrobert.codegpt.util.EditorUtil
|
||||
import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
class AgentToolWindowTabPanel(
|
||||
private val project: Project,
|
||||
private val sessionId: String = UUID.randomUUID().toString()
|
||||
) : BorderLayoutPanel(), Disposable {
|
||||
|
||||
private val scrollablePanel = ChatToolWindowScrollablePanel()
|
||||
private val tagManager = TagManager()
|
||||
private val dispatchers = CoroutineDispatchers()
|
||||
|
||||
private val conversation = Conversation()
|
||||
private val agentSession = AgentSession(sessionId, conversation)
|
||||
private val psiRepository = PsiStructureRepository(
|
||||
this,
|
||||
project,
|
||||
tagManager,
|
||||
PsiStructureProvider(),
|
||||
dispatchers
|
||||
)
|
||||
|
||||
private val approvalContainer = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(4, 0, 4, 0)
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private val queuedMessageContainer = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(4, 0, 4, 0)
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private val loadingLabel =
|
||||
JBLabel(
|
||||
CodeGPTBundle.get("toolwindow.chat.loading"),
|
||||
AnimatedIcon.Default(),
|
||||
JBLabel.LEFT
|
||||
).apply {
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private val userInputPanel = UserInputPanel(
|
||||
project,
|
||||
TotalTokensPanel(
|
||||
conversation,
|
||||
EditorUtil.getSelectedEditorSelectedText(project),
|
||||
this,
|
||||
psiRepository
|
||||
),
|
||||
this,
|
||||
FeatureType.AGENT,
|
||||
tagManager,
|
||||
onSubmit = { text -> handleSubmit(text) },
|
||||
onStop = { handleCancel() },
|
||||
withRemovableSelectedEditorTag = true,
|
||||
agentTokenCounterPanel = TokenUsageCounterPanel(project, sessionId),
|
||||
sessionIdProvider = { sessionId }
|
||||
)
|
||||
private val todoListPanel = TodoListPanel()
|
||||
private val projectMessageBusConnection = project.messageBus.connect()
|
||||
private val appMessageBusConnection = ApplicationManager.getApplication().messageBus.connect()
|
||||
|
||||
private val agentEventHandler = AgentEventHandler(
|
||||
project = project,
|
||||
sessionId = sessionId,
|
||||
agentApprovalManager = AgentApprovalManager(project),
|
||||
approvalContainer = approvalContainer,
|
||||
scrollablePanel = scrollablePanel,
|
||||
todoListPanel = todoListPanel,
|
||||
userInputPanel = userInputPanel,
|
||||
onShowLoading = { text ->
|
||||
loadingLabel.text = text
|
||||
loadingLabel.isVisible = true
|
||||
revalidate()
|
||||
repaint()
|
||||
},
|
||||
onHideLoading = {
|
||||
loadingLabel.isVisible = false
|
||||
revalidate()
|
||||
repaint()
|
||||
},
|
||||
onQueuedMessagesResolved = { message ->
|
||||
runInEdt {
|
||||
clearQueuedMessagesAndCreateNewResponse(
|
||||
message.text
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
init {
|
||||
setupMessageBusSubscriptions()
|
||||
setupUI()
|
||||
|
||||
if (conversation.messages.isEmpty()) {
|
||||
displayLandingView()
|
||||
}
|
||||
|
||||
userInputPanel.setStopEnabled(false)
|
||||
Disposer.register(this, agentEventHandler)
|
||||
}
|
||||
|
||||
private fun setupMessageBusSubscriptions() {
|
||||
project.service<AgentService>().queuedMessageProcessed.let { flow ->
|
||||
kotlinx.coroutines.CoroutineScope(dispatchers.io()).launch {
|
||||
flow.collect { processedMessage ->
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
removeQueuedMessage(processedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appMessageBusConnection.subscribe(
|
||||
AgentToolOutputNotifier.AGENT_TOOL_OUTPUT_TOPIC,
|
||||
object : AgentToolOutputNotifier {
|
||||
override fun toolOutput(toolId: String, text: String, isError: Boolean) {
|
||||
val namespacedToolId = "${sessionId}:${toolId}"
|
||||
agentEventHandler.handleToolOutput(namespacedToolId, text, isError)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupUI() {
|
||||
addToCenter(createScrollPaneWithSmartScroller(scrollablePanel))
|
||||
addToBottom(createUserPromptPanel())
|
||||
}
|
||||
|
||||
private fun createUserPromptPanel(): JComponent {
|
||||
val topContainer = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
todoListPanel.alignmentX = LEFT_ALIGNMENT
|
||||
topContainer.add(todoListPanel)
|
||||
|
||||
queuedMessageContainer.alignmentX = LEFT_ALIGNMENT
|
||||
topContainer.add(queuedMessageContainer)
|
||||
|
||||
approvalContainer.alignmentX = LEFT_ALIGNMENT
|
||||
topContainer.add(approvalContainer)
|
||||
|
||||
val loadingContainer =
|
||||
BorderLayoutPanel().withBorder(JBUI.Borders.empty(8)).addToCenter(loadingLabel)
|
||||
|
||||
return BorderLayoutPanel()
|
||||
.addToTop(loadingContainer)
|
||||
.addToCenter(
|
||||
BorderLayoutPanel().withBorder(
|
||||
JBUI.Borders.compound(
|
||||
JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0),
|
||||
JBUI.Borders.empty(8)
|
||||
)
|
||||
)
|
||||
.addToTop(topContainer)
|
||||
.addToCenter(userInputPanel)
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSubmit(text: String) {
|
||||
if (text.isBlank()) return
|
||||
agentSession.serviceType = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.AGENT)
|
||||
|
||||
val agentService = project.service<AgentService>()
|
||||
|
||||
if (agentService.isSessionRunning(sessionId)) {
|
||||
addQueuedMessage(text)
|
||||
userInputPanel.clearText()
|
||||
userInputPanel.setSubmitEnabled(true)
|
||||
userInputPanel.setStopEnabled(true)
|
||||
|
||||
agentService.submitMessage(
|
||||
MessageWithContext(text, userInputPanel.getSelectedTags()),
|
||||
agentEventHandler,
|
||||
sessionId
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.RUNNING)
|
||||
}
|
||||
|
||||
val message = MessageWithContext(text, userInputPanel.getSelectedTags())
|
||||
val messagePanel = scrollablePanel.addMessage(message.id)
|
||||
val userPanel = UserMessagePanel(
|
||||
project,
|
||||
MessageBuilder(project, text).withTags(userInputPanel.getSelectedTags()).build(),
|
||||
this
|
||||
)
|
||||
val responsePanel = ResponseMessagePanel()
|
||||
val responseBody = ChatMessageResponseBody(
|
||||
project,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
this
|
||||
)
|
||||
|
||||
responsePanel.setResponseContent(responseBody)
|
||||
userPanel.addCopyAction { CopyAction.copyToClipboard(text) }
|
||||
messagePanel.add(userPanel)
|
||||
messagePanel.add(responsePanel)
|
||||
scrollablePanel.update()
|
||||
|
||||
agentEventHandler.resetForNewSubmission()
|
||||
agentEventHandler.setCurrentResponseBody(responseBody)
|
||||
|
||||
loadingLabel.text = CodeGPTBundle.get("toolwindow.chat.loading")
|
||||
loadingLabel.isVisible = true
|
||||
|
||||
clearQueuedMessages()
|
||||
agentService.clearPendingMessages(sessionId)
|
||||
userInputPanel.setStopEnabled(true)
|
||||
|
||||
agentService.submitMessage(message, agentEventHandler, sessionId)
|
||||
}
|
||||
|
||||
private fun handleCancel() {
|
||||
val agentService = project.service<AgentService>()
|
||||
agentService.cancelCurrentRun(sessionId)
|
||||
agentService.clearPendingMessages(sessionId)
|
||||
|
||||
approvalContainer.removeAll()
|
||||
clearQueuedMessages()
|
||||
approvalContainer.isVisible = false
|
||||
loadingLabel.isVisible = false
|
||||
userInputPanel.setStopEnabled(false)
|
||||
|
||||
runCatching {
|
||||
project.service<AgentToolWindowContentManager>()
|
||||
.setTabStatus(sessionId, AgentToolWindowTabbedPane.TabStatus.STOPPED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLandingView() {
|
||||
scrollablePanel.displayLandingView(createLandingView())
|
||||
}
|
||||
|
||||
private fun createLandingView(): AgentToolWindowLandingPanel {
|
||||
return AgentToolWindowLandingPanel(project)
|
||||
}
|
||||
|
||||
fun getSessionId(): String = sessionId
|
||||
|
||||
fun getAgentSession(): AgentSession = agentSession
|
||||
|
||||
fun getConversation(): Conversation = conversation
|
||||
|
||||
fun requestFocusForTextArea() {
|
||||
userInputPanel.requestFocus()
|
||||
}
|
||||
|
||||
fun addQueuedMessage(message: String) {
|
||||
val queuedMessage = QueuedMessage(message)
|
||||
val existingPanel = getQueuedMessagePanel()
|
||||
if (existingPanel != null) {
|
||||
val messages = existingPanel.getQueuedMessages().toMutableList()
|
||||
messages.add(queuedMessage)
|
||||
val updatedPanel = QueuedMessagePanel(messages)
|
||||
val index = queuedMessageContainer.components.indexOf(existingPanel)
|
||||
if (index >= 0) {
|
||||
queuedMessageContainer.remove(existingPanel)
|
||||
queuedMessageContainer.add(updatedPanel, index)
|
||||
}
|
||||
} else {
|
||||
val queuedPanel = QueuedMessagePanel(listOf(queuedMessage))
|
||||
if (queuedMessageContainer.componentCount > 0) {
|
||||
queuedMessageContainer.add(Box.createVerticalStrut(4))
|
||||
}
|
||||
|
||||
queuedPanel.alignmentX = LEFT_ALIGNMENT
|
||||
queuedMessageContainer.add(queuedPanel)
|
||||
}
|
||||
|
||||
queuedMessageContainer.isVisible = true
|
||||
queuedMessageContainer.revalidate()
|
||||
queuedMessageContainer.repaint()
|
||||
}
|
||||
|
||||
private fun getQueuedMessagePanel(): QueuedMessagePanel? {
|
||||
return queuedMessageContainer.components
|
||||
.filterIsInstance<QueuedMessagePanel>()
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
fun clearQueuedMessages() {
|
||||
queuedMessageContainer.removeAll()
|
||||
queuedMessageContainer.isVisible = false
|
||||
queuedMessageContainer.revalidate()
|
||||
queuedMessageContainer.repaint()
|
||||
}
|
||||
|
||||
private fun clearQueuedMessagesAndCreateNewResponse(messageText: String) {
|
||||
clearQueuedMessages()
|
||||
|
||||
val message = Message(messageText)
|
||||
val messagePanel = scrollablePanel.addMessage(message.id)
|
||||
val userPanel = UserMessagePanel(project, message, this)
|
||||
userPanel.addCopyAction { CopyAction.copyToClipboard(message.prompt) }
|
||||
messagePanel.add(userPanel)
|
||||
|
||||
val responseBody = ChatMessageResponseBody(project, false, false, false, false, true, this)
|
||||
val responsePanel = ResponseMessagePanel()
|
||||
responsePanel.setResponseContent(responseBody)
|
||||
messagePanel.add(responsePanel)
|
||||
|
||||
agentEventHandler.resetForNewSubmission()
|
||||
agentEventHandler.setCurrentResponseBody(responseBody)
|
||||
|
||||
scrollablePanel.update()
|
||||
}
|
||||
|
||||
fun removeQueuedMessage(messageText: String) {
|
||||
val panel = getQueuedMessagePanel() ?: return
|
||||
|
||||
val messages = panel.getQueuedMessages().toMutableList()
|
||||
val messageToRemove = messages.find { it.prompt == messageText }
|
||||
|
||||
if (messageToRemove != null) {
|
||||
messages.remove(messageToRemove)
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
queuedMessageContainer.remove(panel)
|
||||
} else {
|
||||
val updatedPanel = QueuedMessagePanel(messages)
|
||||
val index = queuedMessageContainer.components.indexOf(panel)
|
||||
if (index >= 0) {
|
||||
queuedMessageContainer.remove(panel)
|
||||
queuedMessageContainer.add(updatedPanel, index)
|
||||
}
|
||||
}
|
||||
|
||||
queuedMessageContainer.revalidate()
|
||||
queuedMessageContainer.repaint()
|
||||
|
||||
if (queuedMessageContainer.componentCount == 0) {
|
||||
queuedMessageContainer.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
ToolRunContext.cleanupSession(sessionId)
|
||||
|
||||
projectMessageBusConnection.disconnect()
|
||||
appMessageBusConnection.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.InputValidator
|
||||
import com.intellij.openapi.ui.JBMenuItem
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTabbedPane
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.agent.AgentService
|
||||
import java.awt.*
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.ActionListener
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
|
||||
class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(), Disposable {
|
||||
|
||||
private val activeTabMapping = TreeMap<String, AgentToolWindowTabPanel>(
|
||||
Comparator { o1, o2 ->
|
||||
val nums1 = o1.replace("\\D".toRegex(), "")
|
||||
val nums2 = o2.replace("\\D".toRegex(), "")
|
||||
|
||||
if (nums1.isNotEmpty() && nums2.isNotEmpty()) {
|
||||
val n1 = nums1.toInt()
|
||||
val n2 = nums2.toInt()
|
||||
n1.compareTo(n2)
|
||||
} else {
|
||||
when {
|
||||
nums1.isNotEmpty() -> -1
|
||||
nums2.isNotEmpty() -> 1
|
||||
else -> o1.compareTo(o2, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private var isTabActive = true
|
||||
|
||||
init {
|
||||
tabComponentInsets = null
|
||||
setComponentPopupMenu(TabPopupMenu())
|
||||
addChangeListener { refreshTabState() }
|
||||
}
|
||||
|
||||
enum class TabStatus {
|
||||
RUNNING,
|
||||
STOPPED,
|
||||
APPROVAL,
|
||||
COMPLETED_UNSEEN
|
||||
}
|
||||
|
||||
private data class TabState(
|
||||
var status: TabStatus = TabStatus.STOPPED,
|
||||
var unseen: Boolean = false
|
||||
)
|
||||
|
||||
private val sessionStates = mutableMapOf<String, TabState>()
|
||||
|
||||
fun updateStatusForSession(sessionId: String, status: TabStatus) {
|
||||
val state = sessionStates.getOrPut(sessionId) { TabState() }
|
||||
state.status = status
|
||||
applyIconForSession(sessionId)
|
||||
}
|
||||
|
||||
fun onAgentCompleted(sessionId: String) {
|
||||
val state = sessionStates.getOrPut(sessionId) { TabState() }
|
||||
state.status = TabStatus.STOPPED
|
||||
|
||||
val selectedSessionId = tryFindActiveTabPanel()
|
||||
.map { it.getSessionId() }
|
||||
.orElse(null)
|
||||
|
||||
state.unseen = selectedSessionId != sessionId
|
||||
|
||||
applyIconForSession(sessionId)
|
||||
}
|
||||
|
||||
fun onTabHidden() {
|
||||
isTabActive = false
|
||||
}
|
||||
|
||||
private fun statusIcon(status: TabStatus): Icon? = when (status) {
|
||||
TabStatus.RUNNING -> CircleIcon(JBColor(0x43A047, 0x66BB6A), 8)
|
||||
TabStatus.STOPPED -> null
|
||||
TabStatus.APPROVAL -> CircleIcon(JBColor(0xFB8C00, 0xFFA726), 8)
|
||||
TabStatus.COMPLETED_UNSEEN -> CircleIcon(JBColor(0x2196F3, 0x42A5F5), 8)
|
||||
}
|
||||
|
||||
private fun computeIcon(state: TabState): Icon? {
|
||||
return if (state.unseen) {
|
||||
CircleIcon(JBColor(0x2196F3, 0x42A5F5), 8)
|
||||
} else {
|
||||
statusIcon(state.status)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyIconForSession(sessionId: String) {
|
||||
tryFindTabTitle(sessionId).ifPresent { title ->
|
||||
val index = indexOfTab(title)
|
||||
if (index >= 0) {
|
||||
val state = sessionStates[sessionId] ?: return@ifPresent
|
||||
val icon = computeIcon(state)
|
||||
val custom = getTabComponentAt(index)
|
||||
if (custom is JPanel) {
|
||||
val comps = custom.components
|
||||
if (comps.isNotEmpty() && comps[0] is JBLabel) {
|
||||
val label = comps[0] as JBLabel
|
||||
label.icon = icon
|
||||
label.iconTextGap = 6
|
||||
label.revalidate()
|
||||
label.repaint()
|
||||
}
|
||||
} else {
|
||||
setIconAt(index, icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addNewTab(toolWindowPanel: AgentToolWindowTabPanel) {
|
||||
addNewTab(toolWindowPanel, true)
|
||||
}
|
||||
|
||||
fun addNewTab(toolWindowPanel: AgentToolWindowTabPanel, select: Boolean) {
|
||||
val tabIndices = activeTabMapping.keys.toTypedArray()
|
||||
var nextIndex = 0
|
||||
|
||||
for (title in tabIndices) {
|
||||
if (title.matches("Agent \\d+".toRegex())) {
|
||||
val numberPart = title.replace("\\D+".toRegex(), "")
|
||||
val tabNum = numberPart.toInt()
|
||||
if (tabNum - 1 == nextIndex) {
|
||||
nextIndex++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val title = getTitle(toolWindowPanel, nextIndex)
|
||||
val sessionId = toolWindowPanel.getSessionId()
|
||||
toolWindowPanel.getAgentSession().displayName = title
|
||||
project.service<AgentSessionState>().updateSession(sessionId, displayName = title)
|
||||
|
||||
super.insertTab(title, null, toolWindowPanel, null, nextIndex)
|
||||
activeTabMapping[title] = toolWindowPanel
|
||||
if (select) {
|
||||
selectedIndex = nextIndex
|
||||
}
|
||||
|
||||
sessionStates[sessionId] = TabState(status = TabStatus.STOPPED, unseen = false)
|
||||
|
||||
setTabComponentAt(nextIndex, createTabButtonPanel(title, nextIndex > 0, TabStatus.STOPPED))
|
||||
toolWindowPanel.requestFocusForTextArea()
|
||||
|
||||
Disposer.register(this, toolWindowPanel)
|
||||
}
|
||||
|
||||
private fun getTitle(toolWindowTabPanel: AgentToolWindowTabPanel, nextIndex: Int): String {
|
||||
val session = toolWindowTabPanel.getAgentSession()
|
||||
val customName = session.displayName
|
||||
|
||||
return if (customName.isNotBlank()) {
|
||||
ensureUniqueName(customName, null)
|
||||
} else {
|
||||
"Agent ${nextIndex + 1}"
|
||||
}
|
||||
}
|
||||
|
||||
fun tryFindTabTitle(sessionId: String): Optional<String> {
|
||||
return activeTabMapping.entries.stream()
|
||||
.filter { entry -> entry.value.getSessionId() == sessionId }
|
||||
.findFirst()
|
||||
.map { it.key }
|
||||
}
|
||||
|
||||
fun tryFindActiveTabPanel(): Optional<AgentToolWindowTabPanel> {
|
||||
val selectedIndex = selectedIndex
|
||||
if (selectedIndex == -1) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
return Optional.ofNullable(activeTabMapping[getTitleAt(selectedIndex)])
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
removeAll()
|
||||
activeTabMapping.clear()
|
||||
}
|
||||
|
||||
fun renameTab(tabIndex: Int, newName: String) {
|
||||
if (tabIndex !in 0..<tabCount) {
|
||||
return
|
||||
}
|
||||
|
||||
val oldTitle = getTitleAt(tabIndex)
|
||||
val panel = activeTabMapping[oldTitle] ?: return
|
||||
|
||||
val uniqueName = ensureUniqueName(newName, oldTitle)
|
||||
|
||||
setTitleAt(tabIndex, uniqueName)
|
||||
|
||||
val sessionId = panel.getSessionId()
|
||||
val currentStatus = if (project.service<AgentService>().isSessionRunning(sessionId)) {
|
||||
TabStatus.RUNNING
|
||||
} else {
|
||||
TabStatus.STOPPED
|
||||
}
|
||||
|
||||
setTabComponentAt(tabIndex, createTabButtonPanel(uniqueName, tabIndex > 0, currentStatus))
|
||||
|
||||
activeTabMapping.remove(oldTitle)
|
||||
activeTabMapping[uniqueName] = panel
|
||||
|
||||
panel.getAgentSession().displayName = uniqueName
|
||||
project.service<AgentSessionState>().updateSession(sessionId, displayName = uniqueName)
|
||||
|
||||
applyIconForSession(sessionId)
|
||||
}
|
||||
|
||||
private fun ensureUniqueName(desiredName: String, currentTitle: String?): String {
|
||||
val baseName = desiredName.trim()
|
||||
var uniqueName = baseName
|
||||
var counter = 2
|
||||
|
||||
while (activeTabMapping.containsKey(uniqueName) && uniqueName != currentTitle) {
|
||||
uniqueName = "$baseName ($counter)"
|
||||
counter++
|
||||
}
|
||||
|
||||
return uniqueName
|
||||
}
|
||||
|
||||
private fun refreshTabState() {
|
||||
val selectedIndex = selectedIndex
|
||||
if (selectedIndex == -1) {
|
||||
return
|
||||
}
|
||||
|
||||
isTabActive = true
|
||||
|
||||
tryFindActiveTabPanel().ifPresent { selectedPanel ->
|
||||
val selectedSessionId = selectedPanel.getSessionId()
|
||||
val selectedState = sessionStates[selectedSessionId]
|
||||
if (selectedState != null && selectedState.unseen) {
|
||||
selectedState.unseen = false
|
||||
applyIconForSession(selectedSessionId)
|
||||
}
|
||||
project.service<AgentSessionState>().setLastActiveSessionId(selectedSessionId)
|
||||
}
|
||||
|
||||
for (i in 0 until tabCount) {
|
||||
val title = getTitleAt(i)
|
||||
val panel = activeTabMapping[title]
|
||||
panel?.let {
|
||||
val sessionId = it.getSessionId()
|
||||
val state = sessionStates[sessionId] ?: TabState()
|
||||
|
||||
if (project.service<AgentService>().isSessionRunning(sessionId)) {
|
||||
state.status = TabStatus.RUNNING
|
||||
state.unseen = false
|
||||
}
|
||||
|
||||
applyIconForSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameAgentSession(tabIndex: Int) {
|
||||
if (tabIndex <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val currentTitle = getTitleAt(tabIndex)
|
||||
val newName = Messages.showInputDialog(
|
||||
"Enter new title:",
|
||||
"Rename Agent Session",
|
||||
Messages.getQuestionIcon(),
|
||||
currentTitle,
|
||||
AgentSessionNameValidator()
|
||||
)
|
||||
|
||||
if (newName != null && newName != currentTitle) {
|
||||
renameTab(tabIndex, newName)
|
||||
}
|
||||
}
|
||||
|
||||
private class AgentSessionNameValidator : InputValidator {
|
||||
private val maxNameLength = 50
|
||||
|
||||
override fun checkInput(inputString: String?): Boolean {
|
||||
if (inputString == null || inputString.trim().isEmpty()) {
|
||||
return false
|
||||
}
|
||||
if (inputString.length > maxNameLength) {
|
||||
return false
|
||||
}
|
||||
return !inputString.contains("\n") && !inputString.contains("\t")
|
||||
}
|
||||
|
||||
override fun canClose(inputString: String?): Boolean {
|
||||
return checkInput(inputString)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetCurrentlyActiveTabPanel() {
|
||||
tryFindActiveTabPanel().ifPresent { tabPanel ->
|
||||
val oldSessionId = tabPanel.getSessionId()
|
||||
Disposer.dispose(tabPanel)
|
||||
activeTabMapping.remove(getTitleAt(selectedIndex))
|
||||
removeTabAt(selectedIndex)
|
||||
sessionStates.remove(oldSessionId)
|
||||
|
||||
val newTabPanel = AgentToolWindowTabPanel(project)
|
||||
project.service<AgentSessionState>()
|
||||
.replaceSession(oldSessionId, newTabPanel.getSessionId())
|
||||
addNewTab(newTabPanel)
|
||||
repaint()
|
||||
revalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTabButtonPanel(
|
||||
title: String,
|
||||
closeable: Boolean,
|
||||
status: TabStatus = TabStatus.STOPPED
|
||||
): JPanel {
|
||||
val titleLabel = JBLabel(title).apply {
|
||||
icon = statusIcon(status)
|
||||
iconTextGap = 4
|
||||
}
|
||||
|
||||
val panel = JBUI.Panels.simplePanel(4, 0)
|
||||
.addToLeft(titleLabel)
|
||||
|
||||
if (closeable) {
|
||||
val closeIcon = AllIcons.Actions.Close
|
||||
val button = JButton(closeIcon).apply {
|
||||
addActionListener(CloseActionListener(title))
|
||||
preferredSize = Dimension(closeIcon.iconWidth, closeIcon.iconHeight)
|
||||
border = BorderFactory.createEmptyBorder()
|
||||
isContentAreaFilled = false
|
||||
toolTipText = "Close Agent"
|
||||
rolloverIcon = AllIcons.Actions.CloseHovered
|
||||
}
|
||||
panel.addToRight(button)
|
||||
}
|
||||
|
||||
return panel.andTransparent()
|
||||
}
|
||||
|
||||
private class CircleIcon(private val color: Color, private val diameter: Int = 8) :
|
||||
Icon {
|
||||
override fun getIconWidth(): Int = diameter
|
||||
override fun getIconHeight(): Int = diameter
|
||||
override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
|
||||
val g2 = g as? Graphics2D ?: return
|
||||
val oldAA = g2.getRenderingHint(RenderingHints.KEY_ANTIALIASING)
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
g2.color = color
|
||||
g2.fillOval(x, y, diameter, diameter)
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldAA)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CloseActionListener(private val title: String) : ActionListener {
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
val tabIndex = indexOfTab(title)
|
||||
if (tabIndex >= 0) {
|
||||
activeTabMapping[title]?.let { panel ->
|
||||
sessionStates.remove(panel.getSessionId())
|
||||
project.service<AgentSessionState>().removeSession(panel.getSessionId())
|
||||
Disposer.dispose(panel)
|
||||
}
|
||||
removeTabAt(tabIndex)
|
||||
activeTabMapping.remove(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class TabPopupMenu : JPopupMenu() {
|
||||
private var selectedPopupTabIndex = -1
|
||||
|
||||
init {
|
||||
add(createPopupMenuItem("Rename Title") {
|
||||
if (selectedPopupTabIndex > 0) {
|
||||
renameAgentSession(selectedPopupTabIndex)
|
||||
}
|
||||
})
|
||||
addSeparator()
|
||||
add(createPopupMenuItem("Close") {
|
||||
if (selectedPopupTabIndex > 0) {
|
||||
val title = getTitleAt(selectedPopupTabIndex)
|
||||
activeTabMapping[title]?.let { panel ->
|
||||
sessionStates.remove(panel.getSessionId())
|
||||
project.service<AgentSessionState>().removeSession(panel.getSessionId())
|
||||
Disposer.dispose(panel)
|
||||
}
|
||||
removeTabAt(selectedPopupTabIndex)
|
||||
activeTabMapping.remove(title)
|
||||
}
|
||||
})
|
||||
add(createPopupMenuItem("Close Other Tabs") {
|
||||
val selectedPopupTabTitle = getTitleAt(selectedPopupTabIndex)
|
||||
val tabPanel = activeTabMapping[selectedPopupTabTitle]
|
||||
val keepSessionId = tabPanel?.getSessionId()
|
||||
sessionStates.keys
|
||||
.filter { it != keepSessionId }
|
||||
.forEach { sessionStates.remove(it) }
|
||||
activeTabMapping.values
|
||||
.map { it.getSessionId() }
|
||||
.filter { it != keepSessionId }
|
||||
.forEach { project.service<AgentSessionState>().removeSession(it) }
|
||||
|
||||
clearAll()
|
||||
tabPanel?.let { addNewTab(it) }
|
||||
})
|
||||
}
|
||||
|
||||
override fun show(invoker: Component, x: Int, y: Int) {
|
||||
selectedPopupTabIndex = this@AgentToolWindowTabbedPane.getUI()
|
||||
.tabForCoordinate(this@AgentToolWindowTabbedPane, x, y)
|
||||
if (selectedPopupTabIndex > 0) {
|
||||
super.show(invoker, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPopupMenuItem(label: String, listener: ActionListener): JBMenuItem {
|
||||
return JBMenuItem(label).apply {
|
||||
addActionListener(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
clearAll()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
|
||||
data class TokenUsageEvent(
|
||||
val sessionId: String,
|
||||
val totalTokens: Long,
|
||||
)
|
||||
|
||||
interface TokenUsageListener {
|
||||
fun onTokenUsageChanged(event: TokenUsageEvent)
|
||||
|
||||
companion object {
|
||||
val TOKEN_USAGE_TOPIC = Topic("Token Usage Changes", TokenUsageListener::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.messages.MessageBusConnection
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.CodeGPTKeys
|
||||
import ee.carlrobert.codegpt.settings.service.*
|
||||
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTService
|
||||
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTUserDetailsNotifier
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsListener
|
||||
import ee.carlrobert.llm.client.codegpt.CodeGPTUserDetails
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
|
||||
class AgentCreditsToolbarLabel(
|
||||
private val project: Project
|
||||
) : JBLabel(), Disposable {
|
||||
|
||||
private val numberFormat = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||
isGroupingUsed = true
|
||||
}
|
||||
private val messageBusConnection: MessageBusConnection = project.messageBus.connect(this)
|
||||
private var currentCredits: AgentCreditsEvent? = null
|
||||
private var currentUserDetails: CodeGPTUserDetails? = null
|
||||
private var userDetailsRequested: Boolean = false
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
font = JBFont.small()
|
||||
border = JBUI.Borders.empty(0, 6, 0, 6)
|
||||
currentUserDetails = project.getUserData(CodeGPTKeys.CODEGPT_USER_DETAILS)
|
||||
subscribeToUpdates()
|
||||
updateDisplay()
|
||||
}
|
||||
|
||||
private fun subscribeToUpdates() {
|
||||
messageBusConnection.subscribe(
|
||||
CodeGPTUserDetailsNotifier.CODEGPT_USER_DETAILS_TOPIC,
|
||||
object : CodeGPTUserDetailsNotifier {
|
||||
override fun userDetailsObtained(userDetails: CodeGPTUserDetails?) {
|
||||
currentUserDetails = userDetails
|
||||
updateDisplay()
|
||||
}
|
||||
}
|
||||
)
|
||||
messageBusConnection.subscribe(
|
||||
AgentCreditsListener.AGENT_CREDITS_TOPIC,
|
||||
object : AgentCreditsListener {
|
||||
override fun onCreditsChanged(event: AgentCreditsEvent) {
|
||||
currentCredits = event
|
||||
updateDisplay()
|
||||
}
|
||||
}
|
||||
)
|
||||
messageBusConnection.subscribe(
|
||||
ModelChangeNotifier.getTopic(),
|
||||
object : ModelChangeNotifierAdapter() {
|
||||
override fun agentModelChanged(newModel: String, serviceType: ServiceType) {
|
||||
updateDisplay()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateDisplay() {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
val provider = ModelSelectionService.getInstance()
|
||||
.getServiceForFeature(FeatureType.AGENT)
|
||||
if (provider != ServiceType.PROXYAI) {
|
||||
isVisible = false
|
||||
return@invokeLater
|
||||
}
|
||||
|
||||
isVisible = true
|
||||
if (!userDetailsRequested && currentUserDetails == null) {
|
||||
userDetailsRequested = true
|
||||
project.getService(CodeGPTService::class.java).syncUserDetailsAsync()
|
||||
}
|
||||
val credits = currentCredits
|
||||
val userDetails = currentUserDetails
|
||||
val userTotal = userDetails?.creditsTotal
|
||||
val userRemaining = if (userTotal != null && userDetails?.creditsUsed != null) {
|
||||
(userTotal - userDetails.creditsUsed).coerceAtLeast(0)
|
||||
} else null
|
||||
val remaining = credits?.remaining ?: userRemaining
|
||||
|
||||
text = if (remaining != null) {
|
||||
"Credits: ${numberFormat.format(remaining)} left"
|
||||
} else {
|
||||
"Credits: --"
|
||||
}
|
||||
|
||||
toolTipText = buildString {
|
||||
append("<html><body>")
|
||||
append("<b>Credits</b><br>")
|
||||
if (remaining != null) {
|
||||
append("Remaining: ${numberFormat.format(remaining)}<br>")
|
||||
}
|
||||
if (userTotal != null) {
|
||||
append("Total: ${numberFormat.format(userTotal)}<br>")
|
||||
}
|
||||
append("</body></html>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallView
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallViewAdapter
|
||||
import java.awt.BorderLayout
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
class AgentRunDslPanel(private val vm: AgentRunViewModel) {
|
||||
|
||||
private val rowsPanel = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
}
|
||||
private val scrollPane = JBScrollPane(rowsPanel).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty()
|
||||
viewport.isOpaque = false
|
||||
horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER
|
||||
}
|
||||
|
||||
private val expandLink = ActionLink("") {
|
||||
vm.expandAll = !vm.expandAll
|
||||
refresh()
|
||||
}.apply { isVisible = false }
|
||||
|
||||
private val root = BorderLayoutPanel().apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty()
|
||||
addToCenter(scrollPane)
|
||||
addToBottom(expandLink)
|
||||
}
|
||||
|
||||
val component: JComponent get() = root
|
||||
private val viewByEntryId = mutableMapOf<String, ToolCallView>()
|
||||
|
||||
fun refresh() {
|
||||
viewByEntryId.clear()
|
||||
val items = vm.items
|
||||
val childrenByParent = LinkedHashMap<String, MutableList<RunEntry>>()
|
||||
val tasksInOrder = mutableListOf<RunEntry.TaskEntry>()
|
||||
val standalone = mutableListOf<RunEntry>()
|
||||
|
||||
items.forEach { e ->
|
||||
when (e) {
|
||||
is RunEntry.TaskEntry -> {
|
||||
tasksInOrder.add(e)
|
||||
childrenByParent.putIfAbsent(e.id, mutableListOf())
|
||||
}
|
||||
|
||||
else -> {
|
||||
val pid = e.parentId
|
||||
if (pid != null) {
|
||||
childrenByParent.getOrPut(pid) { mutableListOf() }.add(e)
|
||||
} else {
|
||||
standalone.add(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val finalList = mutableListOf<RunEntry>()
|
||||
val taskIds = tasksInOrder.map { it.id }.toSet()
|
||||
tasksInOrder.forEach { t ->
|
||||
finalList.add(t)
|
||||
childrenByParent[t.id]?.let { kids ->
|
||||
if (vm.expandAll) {
|
||||
finalList.addAll(kids)
|
||||
} else {
|
||||
val from =
|
||||
if (kids.size > AgentUiConfig.TASK_CHILDREN_MAX) kids.size - AgentUiConfig.TASK_CHILDREN_MAX else 0
|
||||
finalList.addAll(kids.subList(from, kids.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (standalone.isNotEmpty()) {
|
||||
finalList.addAll(standalone)
|
||||
}
|
||||
childrenByParent.forEach { (pid, kids) ->
|
||||
if (!taskIds.contains(pid)) {
|
||||
finalList.addAll(if (vm.expandAll) kids else kids.takeLast(AgentUiConfig.TASK_CHILDREN_MAX))
|
||||
}
|
||||
}
|
||||
|
||||
rowsPanel.removeAll()
|
||||
finalList.forEach { value ->
|
||||
val view = ToolCallViewAdapter.build(value)
|
||||
viewByEntryId[value.id] = view
|
||||
val leftIndent = if (value.parentId != null) 12 else 0
|
||||
rowsPanel.add(BorderLayoutPanel().apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(2, leftIndent, 2, 0)
|
||||
add(view, BorderLayout.CENTER)
|
||||
})
|
||||
}
|
||||
rowsPanel.revalidate()
|
||||
rowsPanel.repaint()
|
||||
val hasOverflow = tasksInOrder.any { task ->
|
||||
(childrenByParent[task.id]?.size ?: 0) > AgentUiConfig.TASK_CHILDREN_MAX
|
||||
} || childrenByParent.any { (pid, kids) ->
|
||||
!taskIds.contains(pid) && kids.size > AgentUiConfig.TASK_CHILDREN_MAX
|
||||
}
|
||||
expandLink.isVisible = hasOverflow
|
||||
expandLink.text = if (!vm.expandAll) "Show all" else "Collapse"
|
||||
expandLink.border = JBUI.Borders.emptyLeft(12)
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
}
|
||||
|
||||
fun appendStreaming(entryId: String, text: String, isError: Boolean): Boolean {
|
||||
val view = viewByEntryId[entryId] ?: return false
|
||||
view.appendStreamingLine(text, isError)
|
||||
return true
|
||||
}
|
||||
|
||||
fun complete(entryId: String, success: Boolean, result: Any?) {
|
||||
viewByEntryId[entryId]?.complete(success, result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolKind
|
||||
|
||||
sealed class RunEntry {
|
||||
abstract val id: String
|
||||
abstract val parentId: String?
|
||||
abstract val kind: ToolKind
|
||||
abstract fun withAnyResult(result: Any?): RunEntry
|
||||
|
||||
data class ReadEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: ReadTool.Args? = null,
|
||||
val result: ReadTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.READ
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? ReadTool.Result)
|
||||
}
|
||||
|
||||
data class IntelliJSearchEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: IntelliJSearchTool.Args? = null,
|
||||
val result: IntelliJSearchTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.SEARCH
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? IntelliJSearchTool.Result)
|
||||
}
|
||||
|
||||
data class BashEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: BashTool.Args? = null,
|
||||
val result: BashTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.BASH
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? BashTool.Result)
|
||||
}
|
||||
|
||||
data class WebEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: WebSearchTool.Args? = null,
|
||||
val result: WebSearchTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.WEB
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? WebSearchTool.Result)
|
||||
}
|
||||
|
||||
data class WriteEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: WriteTool.Args? = null,
|
||||
val result: WriteTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.WRITE
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? WriteTool.Result)
|
||||
}
|
||||
|
||||
data class EditEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: EditTool.Args? = null,
|
||||
val result: EditTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.EDIT
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? EditTool.Result)
|
||||
}
|
||||
|
||||
data class TaskEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: TaskTool.Args? = null,
|
||||
val result: TaskTool.Result? = null,
|
||||
val summary: TaskSummary? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.TASK
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? TaskTool.Result)
|
||||
}
|
||||
|
||||
data class LibraryResolveEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: ResolveLibraryIdTool.Args? = null,
|
||||
val result: ResolveLibraryIdTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.LIBRARY_RESOLVE
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? ResolveLibraryIdTool.Result)
|
||||
}
|
||||
|
||||
data class LibraryDocsEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val args: GetLibraryDocsTool.Args? = null,
|
||||
val result: GetLibraryDocsTool.Result? = null,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.LIBRARY_DOCS
|
||||
override fun withAnyResult(result: Any?): RunEntry =
|
||||
copy(result = result as? GetLibraryDocsTool.Result)
|
||||
}
|
||||
|
||||
data class OtherEntry(
|
||||
override val id: String,
|
||||
override val parentId: String? = null,
|
||||
val name: String,
|
||||
) : RunEntry() {
|
||||
override val kind: ToolKind = ToolKind.OTHER
|
||||
override fun withAnyResult(result: Any?): RunEntry = this
|
||||
}
|
||||
}
|
||||
|
||||
class AgentRunViewModel {
|
||||
var expandAll: Boolean = false
|
||||
var items: List<RunEntry> = emptyList()
|
||||
|
||||
fun addEntry(entry: RunEntry) {
|
||||
items = items + entry
|
||||
}
|
||||
|
||||
fun completeEntry(id: String, result: Any?) {
|
||||
val updated = items.map { existing ->
|
||||
if (existing.id != id) existing else when (existing) {
|
||||
is RunEntry.TaskEntry -> {
|
||||
val children = items.filter { it.parentId == existing.id }
|
||||
val calls = children.size
|
||||
|
||||
val totalTokens = when (val taskResult = result as? TaskTool.Result) {
|
||||
null -> TokenCounter.countForEntries(children)
|
||||
else -> if (taskResult.totalTokens > 0) taskResult.totalTokens else TokenCounter.countForEntries(
|
||||
children
|
||||
)
|
||||
}.toLong()
|
||||
|
||||
existing.copy(
|
||||
result = result as? TaskTool.Result,
|
||||
summary = TaskSummary(calls, totalTokens)
|
||||
)
|
||||
}
|
||||
|
||||
else -> existing.withAnyResult(result)
|
||||
}
|
||||
}
|
||||
items = updated
|
||||
}
|
||||
}
|
||||
|
||||
data class TaskSummary(
|
||||
val toolCalls: Int,
|
||||
val tokens: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VfsUtilCore
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.settings.GeneralSettings
|
||||
import ee.carlrobert.codegpt.settings.agents.SubagentsConfigurable
|
||||
import ee.carlrobert.codegpt.tokens.TokenComputationService
|
||||
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel
|
||||
import ee.carlrobert.codegpt.ui.UIUtil.createTextPane
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JPanel
|
||||
|
||||
class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessagePanel() {
|
||||
|
||||
companion object {
|
||||
private val logger = thisLogger()
|
||||
}
|
||||
|
||||
private fun createLabel(text: String) = JBLabel(text)
|
||||
private fun createLink(text: String, onClick: () -> Unit) = ActionLink(text) { onClick() }
|
||||
|
||||
private fun formatFileSize(bytes: Long): String = when {
|
||||
bytes < 1024 -> "${bytes}B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024}KB"
|
||||
else -> String.format("%.1fMB", bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
init {
|
||||
addContent(buildContent())
|
||||
}
|
||||
|
||||
private fun buildContent(): JPanel {
|
||||
return BorderLayoutPanel().apply {
|
||||
border = JBUI.Borders.empty(0)
|
||||
add(createTextPane(welcomeMessage(), false), BorderLayout.NORTH)
|
||||
add(centerPanel(), BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
private fun centerPanel(): JPanel {
|
||||
val panel = JPanel()
|
||||
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
|
||||
panel.add(actionsListPanel())
|
||||
panel.add(Box.createVerticalStrut(12))
|
||||
panel.add(projectInfoPanel())
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun actionsListPanel(): JPanel {
|
||||
val panel = JPanel()
|
||||
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
|
||||
panel.border = JBUI.Borders.emptyTop(8)
|
||||
panel.alignmentX = LEFT_ALIGNMENT
|
||||
|
||||
panel.add(createLabel("Quick Actions"))
|
||||
panel.add(Box.createVerticalStrut(6))
|
||||
|
||||
val itemsPanel = JPanel()
|
||||
itemsPanel.layout = BoxLayout(itemsPanel, BoxLayout.Y_AXIS)
|
||||
itemsPanel.border = JBUI.Borders.emptyLeft(16)
|
||||
itemsPanel.alignmentX = LEFT_ALIGNMENT
|
||||
|
||||
itemsPanel.add(createLink("Manage Subagents") {
|
||||
ShowSettingsUtil.getInstance()
|
||||
.showSettingsDialog(project, SubagentsConfigurable::class.java)
|
||||
})
|
||||
itemsPanel.add(Box.createVerticalStrut(6))
|
||||
itemsPanel.add(createLink("Documentation & Tips") {
|
||||
try {
|
||||
Desktop.getDesktop().browse(URI("https://docs.tryproxy.io/agent"))
|
||||
} catch (e: Exception) {
|
||||
logger.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
panel.add(itemsPanel)
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun projectInfoPanel(): JPanel {
|
||||
val panel = JPanel()
|
||||
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
|
||||
panel.border = JBUI.Borders.empty()
|
||||
panel.alignmentX = LEFT_ALIGNMENT
|
||||
|
||||
panel.add(createLabel("Project Setup"))
|
||||
panel.add(Box.createVerticalStrut(6))
|
||||
|
||||
val container = JPanel()
|
||||
container.layout = BoxLayout(container, BoxLayout.Y_AXIS)
|
||||
container.border = JBUI.Borders.emptyLeft(16)
|
||||
container.alignmentX = LEFT_ALIGNMENT
|
||||
|
||||
val vf = findProxyAiFile()
|
||||
if (vf == null) {
|
||||
container.add(createLabel("PROXYAI.md not found"))
|
||||
container.add(Box.createVerticalStrut(6))
|
||||
container.add(createLink("Initialize Project") { createProxyAiFile() })
|
||||
} else {
|
||||
val topRow = JPanel()
|
||||
topRow.layout = BoxLayout(topRow, BoxLayout.X_AXIS)
|
||||
topRow.alignmentX = LEFT_ALIGNMENT
|
||||
topRow.add(createLink("PROXYAI.md") { openProxyAiFile() })
|
||||
|
||||
topRow.add(JBLabel(" • "))
|
||||
|
||||
val fileSize = vf.length
|
||||
val fileSizeLabel = JBLabel(formatFileSize(fileSize))
|
||||
fileSizeLabel.foreground = SimpleTextAttributes.GRAYED_ATTRIBUTES.fgColor
|
||||
topRow.add(fileSizeLabel)
|
||||
|
||||
topRow.add(JBLabel(" • "))
|
||||
|
||||
val tokens = countFileTokens(vf)
|
||||
topRow.add(JBLabel("Tokens: "))
|
||||
val tokensLabel = JBLabel(formatTokens(tokens))
|
||||
tokensLabel.foreground = healthColor(tokens)
|
||||
topRow.add(tokensLabel)
|
||||
|
||||
val bottomRow = JPanel()
|
||||
bottomRow.layout = BoxLayout(bottomRow, BoxLayout.X_AXIS)
|
||||
bottomRow.alignmentX = LEFT_ALIGNMENT
|
||||
|
||||
val ts = vf.timeStamp
|
||||
val now = Instant.now()
|
||||
val fileTime = Instant.ofEpochMilli(ts)
|
||||
val zonedNow = now.atZone(ZoneId.systemDefault())
|
||||
val zonedFileTime = fileTime.atZone(ZoneId.systemDefault())
|
||||
|
||||
val timeLabel = when {
|
||||
zonedFileTime.toLocalDate() == zonedNow.toLocalDate() -> {
|
||||
val timeFormat =
|
||||
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
|
||||
JBLabel("Modified today at ${timeFormat.format(fileTime)}")
|
||||
}
|
||||
|
||||
zonedFileTime.toLocalDate() == zonedNow.minusDays(1).toLocalDate() -> {
|
||||
val timeFormat =
|
||||
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
|
||||
JBLabel("Modified yesterday at ${timeFormat.format(fileTime)}")
|
||||
}
|
||||
|
||||
fileTime.isAfter(now.minusSeconds(7 * 24 * 60 * 60)) -> {
|
||||
val dayFormat =
|
||||
DateTimeFormatter.ofPattern("EEEE").withZone(ZoneId.systemDefault())
|
||||
val timeFormat =
|
||||
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
|
||||
JBLabel(
|
||||
"Modified on ${dayFormat.format(fileTime)} at ${
|
||||
timeFormat.format(
|
||||
fileTime
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
|
||||
zonedFileTime.toLocalDate().year == zonedNow.toLocalDate().year -> {
|
||||
val dateFormat =
|
||||
DateTimeFormatter.ofPattern("MMM d").withZone(ZoneId.systemDefault())
|
||||
val timeFormat =
|
||||
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
|
||||
JBLabel(
|
||||
"Modified on ${dateFormat.format(fileTime)} at ${
|
||||
timeFormat.format(
|
||||
fileTime
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val dateFormat =
|
||||
DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneId.systemDefault())
|
||||
val timeFormat =
|
||||
DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
|
||||
JBLabel(
|
||||
"Modified on ${dateFormat.format(fileTime)} at ${
|
||||
timeFormat.format(
|
||||
fileTime
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
}
|
||||
timeLabel.foreground = SimpleTextAttributes.GRAYED_ATTRIBUTES.fgColor
|
||||
bottomRow.add(timeLabel)
|
||||
|
||||
container.add(topRow)
|
||||
container.add(Box.createVerticalStrut(2))
|
||||
container.add(bottomRow)
|
||||
}
|
||||
panel.add(container)
|
||||
return panel
|
||||
}
|
||||
|
||||
|
||||
private fun findProxyAiFile() =
|
||||
project.basePath?.let {
|
||||
LocalFileSystem.getInstance().findFileByPath(Path.of(it, "PROXYAI.md").toString())
|
||||
}
|
||||
|
||||
private fun createProxyAiFile() {
|
||||
val basePath = project.basePath ?: return
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
val dir = LocalFileSystem.getInstance().refreshAndFindFileByPath(basePath)
|
||||
?: return@runWriteCommandAction
|
||||
val existing = dir.findChild("PROXYAI.md")
|
||||
val vf = existing ?: dir.createChildData(this, "PROXYAI.md")
|
||||
vf.setBinaryContent(defaultProxyAiContent().toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun openProxyAiFile() {
|
||||
val vf = findProxyAiFile() ?: return
|
||||
FileEditorManager.getInstance(project).openFile(vf, true)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
removeAll()
|
||||
addContent(buildContent())
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun welcomeMessage(): String {
|
||||
val name = GeneralSettings.getCurrentState().displayName
|
||||
return """
|
||||
<html>
|
||||
<p style="margin-top: 4px; margin-bottom: 4px;">
|
||||
Hi <strong>$name</strong>, I'm <strong>ProxyAI Agent</strong>! I can break down complex tasks, modify your codebase, navigate project files, execute terminal commands, and orchestrate multi-phase development workflows.
|
||||
</p>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun defaultProxyAiContent() = """
|
||||
# ProxyAI Instructions
|
||||
|
||||
Describe goals, conventions, risky areas, and review rules here.
|
||||
""".trimIndent()
|
||||
|
||||
private fun countFileTokens(vf: VirtualFile): Int {
|
||||
val text = runCatching { VfsUtilCore.loadText(vf) }.getOrNull() ?: ""
|
||||
return TokenComputationService.getInstance().countTextTokens(text)
|
||||
}
|
||||
|
||||
private fun formatTokens(n: Int): String =
|
||||
if (n < 1000) n.toString() else String.format("%.1fk", n / 1000.0)
|
||||
|
||||
private fun healthColor(tokens: Int): Color {
|
||||
val green = Color(76, 175, 80)
|
||||
val yellow = Color(255, 193, 7)
|
||||
val red = Color(244, 67, 54)
|
||||
if (tokens <= 4096) return green
|
||||
if (tokens <= 8192) return lerpColor(green, yellow, (tokens - 4096f) / (8192f - 4096f))
|
||||
if (tokens <= 16384) return lerpColor(yellow, red, (tokens - 8192f) / (16384f - 8192f))
|
||||
return red
|
||||
}
|
||||
|
||||
private fun lerpColor(a: Color, b: Color, tRaw: Float): Color {
|
||||
val t = tRaw.coerceIn(0f, 1f)
|
||||
val r = (a.red + (b.red - a.red) * t).toInt()
|
||||
val g = (a.green + (b.green - a.green) * t).toInt()
|
||||
val bl = (a.blue + (b.blue - a.blue) * t).toInt()
|
||||
return Color(r, g, bl)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
object AgentUiConfig {
|
||||
const val GREP_PATTERN_MAX = 40
|
||||
const val WEB_QUERY_MAX = 60
|
||||
const val BASH_CMD_MAX = 80
|
||||
const val TASK_CHILDREN_MAX = 6
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBPanel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.Icons
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.Box
|
||||
|
||||
class SimpleAgentApprovalPanel(
|
||||
title: String,
|
||||
details: String?,
|
||||
private val onApprove: (autoApproveSession: Boolean) -> Unit,
|
||||
private val onReject: () -> Unit,
|
||||
) : JBPanel<SimpleAgentApprovalPanel>() {
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
border = BorderFactory.createCompoundBorder(
|
||||
JBUI.Borders.empty(2, 0),
|
||||
BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(
|
||||
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
|
||||
1
|
||||
),
|
||||
JBUI.Borders.empty(1)
|
||||
)
|
||||
)
|
||||
|
||||
val content = JBPanel<JBPanel<*>>().apply {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(8, 12)
|
||||
}
|
||||
|
||||
val header = JBPanel<JBPanel<*>>().apply {
|
||||
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
|
||||
isOpaque = false
|
||||
}
|
||||
header.add(JBLabel(Icons.MCP))
|
||||
header.add(Box.createHorizontalStrut(8))
|
||||
header.add(JBLabel(title).apply {
|
||||
font = JBUI.Fonts.label().asBold()
|
||||
})
|
||||
content.add(header, BorderLayout.NORTH)
|
||||
|
||||
details?.takeIf { it.isNotBlank() }?.let { txt ->
|
||||
val detailsPanel = JBPanel<JBPanel<*>>().apply {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(6, 0, 8, 0)
|
||||
}
|
||||
detailsPanel.add(JBLabel(txt).apply {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}, BorderLayout.CENTER)
|
||||
content.add(detailsPanel, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
val actions = JBPanel<JBPanel<*>>().apply {
|
||||
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
|
||||
isOpaque = false
|
||||
}
|
||||
actions.add(ActionLink("Accept") { approve(false) })
|
||||
actions.add(JBLabel("|").apply { foreground = JBUI.CurrentTheme.Label.disabledForeground() })
|
||||
actions.add(ActionLink("Always accept for this session") { approve(true) })
|
||||
actions.add(JBLabel(" | ").apply { foreground = JBUI.CurrentTheme.Label.disabledForeground() })
|
||||
actions.add(ActionLink("Reject") { reject() })
|
||||
content.add(actions, BorderLayout.SOUTH)
|
||||
|
||||
add(content, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun approve(auto: Boolean) {
|
||||
onApprove(auto)
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun reject() {
|
||||
onReject()
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun removeSelf() {
|
||||
isVisible = false
|
||||
parent?.remove(this)
|
||||
parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.agent.tools.TodoWriteTool
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.GridBagConstraints
|
||||
import java.awt.GridBagLayout
|
||||
import javax.swing.*
|
||||
|
||||
class TodoListPanel : BorderLayoutPanel() {
|
||||
|
||||
private var currentTodos: List<TodoWriteTool.TodoItem> = emptyList()
|
||||
private var expanded = false
|
||||
private val expandLink = ActionLink("") {
|
||||
expanded = !expanded
|
||||
rebuild()
|
||||
}.apply {
|
||||
isVisible = false
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}
|
||||
private val headerPanel = BorderLayoutPanel()
|
||||
.addToLeft(JLabel("Tasks").apply { font = JBUI.Fonts.label().asBold() })
|
||||
.addToRight(expandLink)
|
||||
.andTransparent()
|
||||
.withBorder(JBUI.Borders.emptyBottom(8))
|
||||
|
||||
private val contentHolder = BorderLayoutPanel()
|
||||
|
||||
init {
|
||||
isVisible = false
|
||||
andTransparent()
|
||||
withBorder(JBUI.Borders.empty(8, 0))
|
||||
addToTop(headerPanel)
|
||||
addToCenter(contentHolder)
|
||||
}
|
||||
|
||||
fun updateTodos(todos: List<TodoWriteTool.TodoItem>) {
|
||||
currentTodos = todos
|
||||
runInEdt { rebuild() }
|
||||
}
|
||||
|
||||
fun clearTodos() {
|
||||
runInEdt {
|
||||
currentTodos = emptyList()
|
||||
contentHolder.removeAll()
|
||||
isVisible = false
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rebuild() {
|
||||
val shown = if (getCurrent() != null) 1 + if (getNext() != null) 1 else 0 else 0
|
||||
val remaining = (currentTodos.size - shown).coerceAtLeast(0)
|
||||
expandLink.text = if (expanded) "Collapse" else "Show $remaining more"
|
||||
expandLink.isVisible = expanded || currentTodos.size > 2
|
||||
contentHolder.removeAll()
|
||||
val content = if (expanded) buildExpandedContent() else buildCollapsedContent()
|
||||
contentHolder.addToCenter(content)
|
||||
contentHolder.revalidate()
|
||||
contentHolder.repaint()
|
||||
}
|
||||
|
||||
private fun buildCollapsedContent(): JPanel {
|
||||
val current = getCurrent()
|
||||
val container = JPanel()
|
||||
container.isOpaque = false
|
||||
container.layout = BoxLayout(container, BoxLayout.Y_AXIS)
|
||||
val currentText = currentLine(current)
|
||||
val currentComp = createWrappingText(currentText, bold = true, small = true, gray = false)
|
||||
container.add(currentComp)
|
||||
|
||||
if (current != null) {
|
||||
val currentIdx = currentTodos.indexOf(current)
|
||||
val isLastItem = currentIdx == currentTodos.size - 1
|
||||
|
||||
if (isLastItem && currentIdx > 0) {
|
||||
val previous = currentTodos[currentIdx - 1]
|
||||
val previousLine = buildString {
|
||||
append("Previous: ")
|
||||
append(taskText(previous))
|
||||
}
|
||||
val previousComp =
|
||||
createWrappingText(previousLine, bold = false, small = true, gray = true)
|
||||
container.add(previousComp)
|
||||
} else {
|
||||
val next = getNext()
|
||||
if (next != null) {
|
||||
val nextLine = buildString {
|
||||
append("Next: ")
|
||||
append(taskText(next))
|
||||
}
|
||||
val nextComp =
|
||||
createWrappingText(nextLine, bold = false, small = true, gray = true)
|
||||
container.add(nextComp)
|
||||
}
|
||||
}
|
||||
}
|
||||
return BorderLayoutPanel().andTransparent().addToCenter(container)
|
||||
}
|
||||
|
||||
private fun buildExpandedContent(): JPanel {
|
||||
val listPanel = JPanel(GridBagLayout()).apply { isOpaque = false }
|
||||
currentTodos.forEachIndexed { index, todo ->
|
||||
val item = JPanel(BorderLayout(4, 0)).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(4, 8)
|
||||
val cb = JBCheckBox().apply {
|
||||
isSelected = todo.status == TodoWriteTool.TodoStatus.COMPLETED
|
||||
isEnabled = false
|
||||
isOpaque = false
|
||||
margin = JBUI.emptyInsets()
|
||||
}
|
||||
val label = createWrappingText(
|
||||
taskText(todo),
|
||||
bold = todo.status == TodoWriteTool.TodoStatus.IN_PROGRESS,
|
||||
small = false,
|
||||
gray = todo.status == TodoWriteTool.TodoStatus.COMPLETED
|
||||
)
|
||||
add(cb, BorderLayout.WEST)
|
||||
add(label, BorderLayout.CENTER)
|
||||
}
|
||||
val gbc = GridBagConstraints().apply {
|
||||
gridx = 0
|
||||
gridy = index
|
||||
weightx = 1.0
|
||||
fill = GridBagConstraints.HORIZONTAL
|
||||
anchor = GridBagConstraints.NORTHWEST
|
||||
insets = JBUI.insets(2, 0, 2, 0)
|
||||
}
|
||||
listPanel.add(item, gbc)
|
||||
}
|
||||
val scroll = JScrollPane(listPanel).apply {
|
||||
isOpaque = false
|
||||
viewport.isOpaque = false
|
||||
border = JBUI.Borders.empty()
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
}
|
||||
return BorderLayoutPanel().apply {
|
||||
isOpaque = false
|
||||
add(scroll, BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrent(): TodoWriteTool.TodoItem? {
|
||||
return currentTodos.firstOrNull { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
|
||||
?: currentTodos.firstOrNull { it.status == TodoWriteTool.TodoStatus.PENDING }
|
||||
?: currentTodos.firstOrNull()
|
||||
}
|
||||
|
||||
private fun getNext(): TodoWriteTool.TodoItem? {
|
||||
val current = getCurrent() ?: return null
|
||||
val idx = currentTodos.indexOf(current)
|
||||
return currentTodos.drop(idx + 1)
|
||||
.firstOrNull { it.status != TodoWriteTool.TodoStatus.COMPLETED }
|
||||
?: currentTodos.firstOrNull { it.status == TodoWriteTool.TodoStatus.PENDING && it != current }
|
||||
}
|
||||
|
||||
private fun currentLine(current: TodoWriteTool.TodoItem?): String {
|
||||
return current?.let {
|
||||
val total = currentTodos.size
|
||||
val pos = currentTodos.indexOf(it) + 1
|
||||
buildString {
|
||||
append(taskText(it))
|
||||
if (total > 0) {
|
||||
append(" (")
|
||||
append(pos)
|
||||
append('/')
|
||||
append(total)
|
||||
append(')')
|
||||
}
|
||||
}
|
||||
} ?: "No tasks"
|
||||
}
|
||||
|
||||
private fun taskText(todo: TodoWriteTool.TodoItem): String {
|
||||
return if (todo.status == TodoWriteTool.TodoStatus.IN_PROGRESS) todo.activeForm else todo.content
|
||||
}
|
||||
|
||||
private fun createWrappingText(
|
||||
text: String,
|
||||
bold: Boolean,
|
||||
small: Boolean,
|
||||
gray: Boolean
|
||||
): JBTextArea {
|
||||
return JBTextArea(text).apply {
|
||||
isEditable = false
|
||||
isOpaque = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
border = JBUI.Borders.empty(0, 0, 2, 0)
|
||||
font = when {
|
||||
bold && !small -> JBUI.Fonts.label().asBold()
|
||||
bold && small -> JBUI.Fonts.smallFont().asBold()
|
||||
small -> JBUI.Fonts.smallFont()
|
||||
else -> JBUI.Fonts.label()
|
||||
}
|
||||
foreground =
|
||||
if (gray) JBUI.CurrentTheme.Label.disabledForeground() else JBUI.CurrentTheme.Label.foreground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import ee.carlrobert.codegpt.EncodingManager
|
||||
import ee.carlrobert.codegpt.agent.tools.EditTool
|
||||
import ee.carlrobert.codegpt.agent.tools.ReadTool
|
||||
import ee.carlrobert.codegpt.agent.tools.WriteTool
|
||||
import kotlin.math.max
|
||||
import kotlin.math.round
|
||||
|
||||
object TokenCounter {
|
||||
private val logger = thisLogger()
|
||||
|
||||
fun countForEntries(entries: List<RunEntry>): Int = entries.sumOf { countForEntry(it) }
|
||||
|
||||
private fun countForEntry(entry: RunEntry): Int = when (entry) {
|
||||
is RunEntry.ReadEntry -> when (val r = entry.result) {
|
||||
is ReadTool.Result.Success -> count(r.content)
|
||||
is ReadTool.Result.Error -> count(r.error)
|
||||
else -> 0
|
||||
}
|
||||
|
||||
is RunEntry.IntelliJSearchEntry -> entry.result?.let { count(it.output) } ?: 0
|
||||
is RunEntry.BashEntry -> entry.result?.let { count(it.output) } ?: 0
|
||||
is RunEntry.WebEntry -> entry.result?.let { res ->
|
||||
val titles = res.results.joinToString("\n") { it.title + "\n" + it.content }
|
||||
count(res.query) + count(titles)
|
||||
} ?: 0
|
||||
|
||||
is RunEntry.WriteEntry -> when (val r = entry.result) {
|
||||
is WriteTool.Result.Success -> count(r.message)
|
||||
is WriteTool.Result.Error -> count(r.error)
|
||||
else -> 0
|
||||
}
|
||||
|
||||
is RunEntry.EditEntry -> when (val r = entry.result) {
|
||||
is EditTool.Result.Success -> count(r.message)
|
||||
is EditTool.Result.Error -> count(r.error)
|
||||
else -> 0
|
||||
}
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun count(text: String?): Int {
|
||||
if (text.isNullOrBlank()) return 0
|
||||
return try {
|
||||
EncodingManager.getInstance().countTokens(text)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(
|
||||
"Something went wrong when calculating tokens. Using fallback calculation logic.",
|
||||
e
|
||||
)
|
||||
val approx = round(text.length / 4.0).toInt()
|
||||
max(approx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.ui.components.JBPanel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallDescriptorFactory
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolCallView
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor.ToolKind
|
||||
|
||||
class ToolCallCard(
|
||||
project: Project,
|
||||
private val toolName: String,
|
||||
private val args: Any?,
|
||||
private val overrideKind: ToolKind? = null
|
||||
) : JBPanel<ToolCallCard>() {
|
||||
|
||||
private val view: ToolCallView
|
||||
private val projectId: String = project.locationHash
|
||||
|
||||
init {
|
||||
layout = java.awt.BorderLayout()
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty()
|
||||
|
||||
val descriptor = ToolCallDescriptorFactory.create(
|
||||
toolName = toolName,
|
||||
args = args ?: Unit,
|
||||
result = null,
|
||||
projectId = projectId,
|
||||
overrideKind = overrideKind
|
||||
)
|
||||
view = ToolCallView(descriptor)
|
||||
add(view, java.awt.BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
fun complete(success: Boolean, result: Any?) {
|
||||
val updated = ToolCallDescriptorFactory.create(
|
||||
toolName = toolName,
|
||||
args = args ?: Unit,
|
||||
result = result,
|
||||
projectId = projectId,
|
||||
overrideKind = overrideKind
|
||||
)
|
||||
view.refreshDescriptor(updated)
|
||||
view.complete(success, result)
|
||||
}
|
||||
|
||||
fun appendStreamingLine(text: String, isError: Boolean) {
|
||||
view.appendStreamingLine(text, isError)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.SimpleAgentApprovalPanel
|
||||
import javax.swing.JPanel
|
||||
|
||||
interface ApprovalPanelFactory {
|
||||
fun create(
|
||||
project: Project,
|
||||
request: ToolApprovalRequest,
|
||||
onApprove: (autoApproveSession: Boolean) -> Unit,
|
||||
onReject: () -> Unit
|
||||
): JPanel
|
||||
}
|
||||
|
||||
object DefaultApprovalPanelFactory : ApprovalPanelFactory {
|
||||
override fun create(
|
||||
project: Project,
|
||||
request: ToolApprovalRequest,
|
||||
onApprove: (autoApproveSession: Boolean) -> Unit,
|
||||
onReject: () -> Unit
|
||||
): JPanel = when (request.type) {
|
||||
ToolApprovalType.BASH -> BashApprovalPanel(project, request, onApprove, onReject)
|
||||
ToolApprovalType.WRITE -> WriteApprovalPanel(project, request, onApprove, onReject)
|
||||
ToolApprovalType.EDIT -> EditApprovalPanel(project, request, onApprove, onReject)
|
||||
ToolApprovalType.GENERIC -> SimpleAgentApprovalPanel(
|
||||
request.title,
|
||||
request.details,
|
||||
onApprove,
|
||||
onReject
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.dsl.builder.Align
|
||||
import com.intellij.ui.dsl.builder.TopGap
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.agent.tools.AskUserQuestionTool
|
||||
import java.awt.BorderLayout
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JCheckBox
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JRadioButton
|
||||
import javax.swing.JTextField
|
||||
import javax.swing.border.EmptyBorder
|
||||
|
||||
class AskUserQuestionPanel(
|
||||
private val model: AskUserQuestionTool.AskUserQuestionsModel,
|
||||
private val onSubmit: (Map<String, String>) -> Unit,
|
||||
private val onCancel: () -> Unit
|
||||
) : JPanel(BorderLayout()) {
|
||||
|
||||
private var index = 0
|
||||
private val answers = model.prefilledAnswers.toMutableMap()
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = BorderFactory.createCompoundBorder(
|
||||
JBUI.Borders.empty(2, 0),
|
||||
BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(), 1),
|
||||
JBUI.Borders.empty(8)
|
||||
)
|
||||
)
|
||||
render()
|
||||
}
|
||||
|
||||
private fun render() {
|
||||
removeAll()
|
||||
val q = model.questions[index]
|
||||
val isFirst = index == 0
|
||||
val isLast = index == model.questions.lastIndex
|
||||
|
||||
val otherField = JTextField()
|
||||
val radios = mutableListOf<JRadioButton>()
|
||||
val checks = mutableListOf<JCheckBox>()
|
||||
|
||||
val content = panel {
|
||||
row {
|
||||
val chip = JBLabel(q.header).apply { border = EmptyBorder(2, 6, 2, 6) }
|
||||
cell(chip)
|
||||
}
|
||||
row {
|
||||
comment(q.question)
|
||||
}
|
||||
|
||||
if (q.multiSelect) {
|
||||
q.options.forEach { opt ->
|
||||
row {
|
||||
val cb = JCheckBox(opt.label)
|
||||
checks.add(cb)
|
||||
cell(cb)
|
||||
comment(opt.description)
|
||||
}
|
||||
}
|
||||
row {
|
||||
val cbOther = JCheckBox("Other")
|
||||
checks.add(cbOther)
|
||||
otherField.columns = 24
|
||||
otherField.isEnabled = false
|
||||
cbOther.addActionListener { otherField.isEnabled = cbOther.isSelected }
|
||||
cell(cbOther)
|
||||
cell(otherField).align(Align.FILL)
|
||||
}
|
||||
} else {
|
||||
buttonsGroup {
|
||||
q.options.forEach { opt ->
|
||||
row {
|
||||
val rb = radioButton(opt.label).component
|
||||
radios.add(rb)
|
||||
comment(opt.description)
|
||||
}
|
||||
}
|
||||
row {
|
||||
val rbOther = radioButton("Other").component
|
||||
radios.add(rbOther)
|
||||
otherField.columns = 24
|
||||
otherField.isEnabled = false
|
||||
rbOther.addActionListener { otherField.isEnabled = rbOther.isSelected }
|
||||
cell(otherField).align(Align.FILL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row {
|
||||
if (!isFirst) link("Back") { saveAndBack(q, radios, checks, otherField) }
|
||||
if (!isFirst) text("|").applyToComponent { foreground = JBUI.CurrentTheme.Label.disabledForeground() }
|
||||
if (!isLast) link("Next") { saveAndNext(q, radios, checks, otherField) }
|
||||
if (!isLast) text("|").applyToComponent { foreground = JBUI.CurrentTheme.Label.disabledForeground() }
|
||||
if (isLast) link("Submit") { saveAndSubmit(q, radios, checks, otherField) }
|
||||
if (isLast) text("|").applyToComponent { foreground = JBUI.CurrentTheme.Label.disabledForeground() }
|
||||
link("Cancel") { cancel() }
|
||||
}.topGap(TopGap.SMALL)
|
||||
}
|
||||
|
||||
prefill(q, radios, checks, otherField)
|
||||
if (!q.multiSelect && radios.isNotEmpty() && radios.none { it.isSelected }) {
|
||||
radios.first().isSelected = true
|
||||
}
|
||||
add(content, BorderLayout.CENTER)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun collect(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField): String {
|
||||
return if (q.multiSelect) {
|
||||
val selected = checks.filter { it.isSelected }.map { it.text }.toMutableList()
|
||||
if (checks.isNotEmpty() && checks.last().isSelected) {
|
||||
val t = other.text.trim()
|
||||
if (t.isNotEmpty()) {
|
||||
selected.removeIf { it == "Other" }
|
||||
selected.add(t)
|
||||
}
|
||||
}
|
||||
selected.joinToString(", ")
|
||||
} else {
|
||||
val rb = radios.firstOrNull { it.isSelected }
|
||||
if (rb != null) {
|
||||
if (rb.text == "Other") other.text.trim() else rb.text
|
||||
} else ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefill(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
|
||||
val ans = answers[q.header] ?: return
|
||||
if (q.multiSelect) {
|
||||
val parts = ans.split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
q.options.forEachIndexed { idx, opt -> if (parts.contains(opt.label)) checks.getOrNull(idx)?.isSelected = true }
|
||||
val extra = parts.filter { p -> q.options.none { it.label == p } }
|
||||
if (extra.isNotEmpty()) {
|
||||
checks.lastOrNull()?.isSelected = true
|
||||
other.text = extra.joinToString(", ")
|
||||
other.isEnabled = true
|
||||
}
|
||||
} else {
|
||||
val idx = q.options.indexOfFirst { it.label == ans }
|
||||
if (idx >= 0) {
|
||||
radios.getOrNull(idx)?.isSelected = true
|
||||
} else if (ans.isNotBlank()) {
|
||||
radios.lastOrNull()?.isSelected = true
|
||||
other.text = ans
|
||||
other.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAndNext(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
|
||||
answers[q.header] = collect(q, radios, checks, other)
|
||||
index += 1
|
||||
render()
|
||||
}
|
||||
|
||||
private fun saveAndBack(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
|
||||
answers[q.header] = collect(q, radios, checks, other)
|
||||
index -= 1
|
||||
render()
|
||||
}
|
||||
|
||||
private fun saveAndSubmit(q: AskUserQuestionTool.Question, radios: List<JRadioButton>, checks: List<JCheckBox>, other: JTextField) {
|
||||
answers[q.header] = collect(q, radios, checks, other)
|
||||
onSubmit(answers)
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun cancel() {
|
||||
onCancel()
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun removeSelf() {
|
||||
isVisible = false
|
||||
parent?.remove(this)
|
||||
parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.ui.dsl.builder.Align
|
||||
import com.intellij.ui.dsl.builder.TopGap
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.parser.Code
|
||||
import java.awt.BorderLayout
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.EditorFactory as ChatEditorFactory
|
||||
|
||||
class BashApprovalPanel(
|
||||
private val project: Project,
|
||||
private val request: ToolApprovalRequest,
|
||||
private val onApprove: (autoApproveSession: Boolean) -> Unit,
|
||||
private val onReject: () -> Unit
|
||||
) : BorderLayoutPanel() {
|
||||
init {
|
||||
isOpaque = false
|
||||
border = BorderFactory.createCompoundBorder(
|
||||
JBUI.Borders.empty(2, 0),
|
||||
BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(
|
||||
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
|
||||
1
|
||||
),
|
||||
JBUI.Borders.empty(1)
|
||||
)
|
||||
)
|
||||
|
||||
val payload = request.payload as? BashPayload
|
||||
val editorComponent = createEditorComponent(payload?.command ?: request.details)
|
||||
|
||||
add(
|
||||
panel {
|
||||
row {
|
||||
label(request.title).bold().applyToComponent {
|
||||
border = JBUI.Borders.emptyLeft(6)
|
||||
}
|
||||
}
|
||||
|
||||
row {
|
||||
cell(editorComponent).align(Align.FILL).resizableColumn()
|
||||
}.topGap(TopGap.SMALL)
|
||||
|
||||
payload?.description?.let {
|
||||
row {
|
||||
comment(it).applyToComponent {
|
||||
border = JBUI.Borders.emptyLeft(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row {
|
||||
link("Run") { approve(false) }.applyToComponent {
|
||||
border = JBUI.Borders.emptyLeft(6)
|
||||
}
|
||||
text("|").applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.Label.disabledForeground()
|
||||
}
|
||||
link("Always run for this session") { approve(true) }
|
||||
text("|").applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.Label.disabledForeground()
|
||||
}
|
||||
link("Reject") { reject() }
|
||||
}
|
||||
},
|
||||
BorderLayout.CENTER
|
||||
)
|
||||
}
|
||||
|
||||
private fun createEditorComponent(command: String): JComponent {
|
||||
val segment = Code(content = command, language = "bash", filePath = "temp.bash")
|
||||
val editor = ChatEditorFactory.createEditor(project, segment)
|
||||
ChatEditorFactory.configureEditor(editor, headerComponent = null)
|
||||
return editor.component.apply {
|
||||
border =
|
||||
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
|
||||
preferredSize = java.awt.Dimension(0, JBUI.scale(80))
|
||||
}
|
||||
}
|
||||
|
||||
private fun approve(auto: Boolean) {
|
||||
onApprove(auto)
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun reject() {
|
||||
onReject()
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun removeSelf() {
|
||||
isVisible = false
|
||||
parent?.remove(this)
|
||||
parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
import com.intellij.diff.DiffContentFactory
|
||||
import com.intellij.diff.DiffManager
|
||||
import com.intellij.diff.requests.SimpleDiffRequest
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import java.awt.Component
|
||||
|
||||
object DiffViewAction {
|
||||
|
||||
fun showDiff(filePath: String, component: Component) {
|
||||
val project = ProjectManager.getInstance().openProjects.firstOrNull()
|
||||
if (project != null) {
|
||||
showDiff(filePath, project)
|
||||
}
|
||||
}
|
||||
|
||||
fun showDiff(filePath: String, project: Project) {
|
||||
val virtualFile = VirtualFileManager.getInstance().findFileByUrl("file://$filePath")
|
||||
if (virtualFile != null) {
|
||||
val contentFactory = DiffContentFactory.getInstance()
|
||||
val currentContent = contentFactory.create(project, virtualFile)
|
||||
val emptyContent = contentFactory.createEmpty()
|
||||
|
||||
val diffRequest = SimpleDiffRequest(
|
||||
"Changes in ${virtualFile.name}",
|
||||
emptyContent,
|
||||
currentContent,
|
||||
"Before",
|
||||
"After"
|
||||
)
|
||||
|
||||
DiffManager.getInstance().showDiff(project, diffRequest)
|
||||
}
|
||||
}
|
||||
|
||||
fun showDiff(before: String, after: String, title: String, project: Project?) {
|
||||
val proj = project ?: ProjectManager.getInstance().openProjects.firstOrNull() ?: return
|
||||
val contentFactory = DiffContentFactory.getInstance()
|
||||
val left = contentFactory.create(before)
|
||||
val right = contentFactory.create(after)
|
||||
val diffRequest = SimpleDiffRequest(
|
||||
title,
|
||||
left,
|
||||
right,
|
||||
"Before",
|
||||
"After"
|
||||
)
|
||||
DiffManager.getInstance().showDiff(proj, diffRequest)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
import com.intellij.diff.util.DiffUtil
|
||||
import com.intellij.diff.util.Side
|
||||
import com.intellij.ide.actions.OpenFileAction
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.dsl.builder.Align
|
||||
import com.intellij.ui.dsl.builder.RightGap
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.ChangeColors
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.applyStringReplacement
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.getFileContentWithFallback
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.renderer.lineDiffStats
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting
|
||||
import java.awt.BorderLayout
|
||||
import java.nio.file.Paths
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.EditorFactory as ChatEditorFactory
|
||||
|
||||
class EditApprovalPanel(
|
||||
private val project: Project,
|
||||
private val request: ToolApprovalRequest,
|
||||
private val onApprove: (autoApproveSession: Boolean) -> Unit,
|
||||
private val onReject: () -> Unit
|
||||
) : BorderLayoutPanel(), Disposable {
|
||||
private var diffCounts: Triple<Int, Int, Int>? = null // +ins, -del, ~changed
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = BorderFactory.createCompoundBorder(
|
||||
JBUI.Borders.empty(2, 0),
|
||||
BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(
|
||||
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
|
||||
1
|
||||
),
|
||||
JBUI.Borders.empty(1)
|
||||
)
|
||||
)
|
||||
|
||||
val payload = request.payload as? EditPayload
|
||||
val filePath = payload?.filePath ?: ""
|
||||
val diffComponent = createInlineDiffComponent(payload)
|
||||
|
||||
add(
|
||||
panel {
|
||||
row { label(request.title).bold() }
|
||||
|
||||
row {
|
||||
label(request.details).applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.Label.disabledForeground()
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath.isNotBlank()) {
|
||||
row("File:") {
|
||||
val baseName = extractBaseName(filePath)
|
||||
val link = ActionLink(baseName) {
|
||||
LocalFileSystem.getInstance().findFileByPath(filePath)
|
||||
?.let { OpenFileAction.openFile(it, project) }
|
||||
}.apply {
|
||||
toolTipText = filePath
|
||||
setExternalLinkIcon()
|
||||
}
|
||||
cell(link).gap(RightGap.SMALL)
|
||||
diffCounts?.let { (ins, del, changed) ->
|
||||
if (ins + del + changed > 0) {
|
||||
cell(colorLabel("+${ins}", ChangeColors.inserted).apply {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
})
|
||||
cell(colorLabel("-${del}", ChangeColors.deleted).apply {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
})
|
||||
cell(colorLabel("~${changed}", ChangeColors.modified).apply {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
diffComponent?.let {
|
||||
row {
|
||||
cell(it).align(Align.FILL).resizableColumn()
|
||||
}
|
||||
}
|
||||
|
||||
row {
|
||||
link("Accept") { approve(false) }
|
||||
label("|").applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.Label.disabledForeground()
|
||||
}
|
||||
link("Always accept for this session") { approve(true) }
|
||||
label("|").applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.Label.disabledForeground()
|
||||
}
|
||||
link("Reject") { reject() }
|
||||
}
|
||||
}.apply {
|
||||
border = JBUI.Borders.empty(4, 8)
|
||||
},
|
||||
BorderLayout.CENTER
|
||||
)
|
||||
}
|
||||
|
||||
private fun createInlineDiffComponent(payload: EditPayload?): JComponent? {
|
||||
if (payload == null) return null
|
||||
val path = try {
|
||||
Paths.get(payload.filePath).normalize().toString()
|
||||
} catch (_: Exception) {
|
||||
payload.filePath
|
||||
}
|
||||
val vfs = LocalFileSystem.getInstance().refreshAndFindFileByPath(path)
|
||||
|
||||
val current = getFileContentWithFallback(path)
|
||||
val proposed = applyStringReplacement(
|
||||
current,
|
||||
payload.oldString,
|
||||
payload.newString,
|
||||
payload.replaceAll
|
||||
)
|
||||
|
||||
val (insRaw, delRaw, changed) = lineDiffStats(current, proposed)
|
||||
diffCounts = Triple(insRaw, delRaw, changed)
|
||||
|
||||
val language = vfs?.extension ?: "text"
|
||||
val segment = ReplaceWaiting(current, proposed, language, path)
|
||||
val editor = ChatEditorFactory.createEditor(project, segment)
|
||||
ResponseEditorPanel.RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor)?.let { viewer ->
|
||||
val rightDoc = viewer.getDocument(Side.RIGHT)
|
||||
if (DiffUtil.executeWriteCommand(rightDoc, project, "Update proposed") {
|
||||
rightDoc.setText(StringUtil.convertLineSeparators(proposed))
|
||||
viewer.scheduleRediff()
|
||||
}) {
|
||||
viewer.rediff(true)
|
||||
}
|
||||
}
|
||||
ChatEditorFactory.configureEditor(editor, headerComponent = null)
|
||||
val comp = editor.component
|
||||
comp.border =
|
||||
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
|
||||
comp.preferredSize = java.awt.Dimension(0, JBUI.scale(140))
|
||||
return comp
|
||||
}
|
||||
|
||||
private fun approve(auto: Boolean) {
|
||||
onApprove(auto)
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun reject() {
|
||||
onReject()
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun removeSelf() {
|
||||
isVisible = false
|
||||
parent?.remove(this)
|
||||
parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
|
||||
override fun dispose() {}
|
||||
|
||||
private fun extractBaseName(path: String?): String {
|
||||
if (path.isNullOrBlank()) return ""
|
||||
val noSlash = path.substringAfterLast('/')
|
||||
return noSlash.substringAfterLast('\\')
|
||||
}
|
||||
|
||||
private fun colorLabel(text: String, color: JBColor): JBLabel =
|
||||
JBLabel(text).apply { foreground = color }
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
sealed interface ToolApprovalPayload
|
||||
|
||||
data class BashPayload(
|
||||
val command: String,
|
||||
val description: String?
|
||||
) : ToolApprovalPayload
|
||||
|
||||
data class WritePayload(
|
||||
val filePath: String,
|
||||
val content: String
|
||||
) : ToolApprovalPayload
|
||||
|
||||
data class EditPayload(
|
||||
val filePath: String,
|
||||
val oldString: String,
|
||||
val newString: String,
|
||||
val replaceAll: Boolean
|
||||
) : ToolApprovalPayload
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
data class ToolApprovalRequest(
|
||||
val type: ToolApprovalType,
|
||||
val title: String,
|
||||
val details: String,
|
||||
val payload: ToolApprovalPayload? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
enum class ToolApprovalType {
|
||||
BASH,
|
||||
WRITE,
|
||||
EDIT,
|
||||
GENERIC
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.approval
|
||||
|
||||
import com.intellij.ide.actions.OpenFileAction
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.dsl.builder.Align
|
||||
import com.intellij.ui.dsl.builder.RightGap
|
||||
import com.intellij.ui.dsl.builder.TopGap
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.parser.Code
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.factory.EditorFactory as ChatEditorFactory
|
||||
|
||||
class WriteApprovalPanel(
|
||||
private val project: Project,
|
||||
private val request: ToolApprovalRequest,
|
||||
private val onApprove: (autoApproveSession: Boolean) -> Unit,
|
||||
private val onReject: () -> Unit
|
||||
) : BorderLayoutPanel() {
|
||||
init {
|
||||
isOpaque = false
|
||||
border = BorderFactory.createCompoundBorder(
|
||||
JBUI.Borders.empty(2, 0),
|
||||
BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(
|
||||
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground(),
|
||||
1
|
||||
),
|
||||
JBUI.Borders.empty(1)
|
||||
)
|
||||
)
|
||||
|
||||
val payload = request.payload as? WritePayload
|
||||
val filePath = payload?.filePath
|
||||
val content = payload?.content
|
||||
val editorComponent = createEditorComponent(filePath, content)
|
||||
val metrics = content?.let { "${it.length} bytes • ${it.lines().size} lines" }
|
||||
|
||||
addToCenter(panel {
|
||||
row { label(request.title).bold() }
|
||||
|
||||
if (!filePath.isNullOrBlank()) {
|
||||
row("File:") {
|
||||
val baseName = extractBaseName(filePath)
|
||||
val link = ActionLink(baseName) {
|
||||
LocalFileSystem.getInstance().findFileByPath(filePath)
|
||||
?.let { OpenFileAction.openFile(it, project) }
|
||||
}.apply {
|
||||
toolTipText = filePath
|
||||
setExternalLinkIcon()
|
||||
}
|
||||
cell(link).gap(RightGap.SMALL)
|
||||
}
|
||||
}
|
||||
|
||||
if (!metrics.isNullOrBlank()) row { comment(metrics) }
|
||||
|
||||
if (editorComponent != null) row {
|
||||
cell(editorComponent).align(Align.FILL).resizableColumn()
|
||||
}.topGap(
|
||||
TopGap.SMALL
|
||||
)
|
||||
|
||||
row {
|
||||
link("Accept") { approve(false) }
|
||||
label("|").applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.Label.disabledForeground()
|
||||
}
|
||||
link("Always accept for this session") { approve(true) }
|
||||
label("|").applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.Label.disabledForeground()
|
||||
}
|
||||
link("Reject") { reject() }
|
||||
}.topGap(TopGap.SMALL)
|
||||
})
|
||||
}
|
||||
|
||||
private fun createEditorComponent(filePath: String?, content: String?): JComponent? {
|
||||
if (content == null) return null
|
||||
val language = filePath?.substringAfterLast('.', missingDelimiterValue = "text") ?: "text"
|
||||
val segment = Code(content = content, language = language, filePath = filePath)
|
||||
val editor = ChatEditorFactory.createEditor(project, segment)
|
||||
ChatEditorFactory.configureEditor(editor, headerComponent = null)
|
||||
return editor.component.apply {
|
||||
border =
|
||||
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
|
||||
preferredSize = java.awt.Dimension(0, JBUI.scale(140))
|
||||
}
|
||||
}
|
||||
|
||||
private fun approve(auto: Boolean) {
|
||||
onApprove(auto)
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun reject() {
|
||||
onReject()
|
||||
removeSelf()
|
||||
}
|
||||
|
||||
private fun removeSelf() {
|
||||
isVisible = false
|
||||
parent?.remove(this)
|
||||
parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
|
||||
private fun extractBaseName(path: String?): String {
|
||||
if (path.isNullOrBlank()) return ""
|
||||
val noSlash = path.substringAfterLast('/')
|
||||
return noSlash.substringAfterLast('\\')
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.ui.JBColor
|
||||
import java.awt.Component
|
||||
import javax.swing.Icon
|
||||
|
||||
enum class ToolKind {
|
||||
READ, WRITE, EDIT, BASH, SEARCH, WEB, TASK, LIBRARY_RESOLVE, LIBRARY_DOCS, ASK_QUESTION, EXIT, OTHER
|
||||
}
|
||||
|
||||
data class Badge(
|
||||
val text: String,
|
||||
val color: JBColor = JBColor.GRAY,
|
||||
val tooltip: String? = null
|
||||
)
|
||||
|
||||
data class FileLink(
|
||||
val path: String,
|
||||
val displayName: String,
|
||||
val enabled: Boolean = true,
|
||||
val action: ((Project) -> Unit)? = null
|
||||
)
|
||||
|
||||
data class ToolAction(
|
||||
val name: String,
|
||||
val icon: Icon,
|
||||
val action: (Component) -> Unit
|
||||
)
|
||||
|
||||
data class ToolCallDescriptor(
|
||||
val kind: ToolKind,
|
||||
val icon: Icon?,
|
||||
val titlePrefix: String,
|
||||
val titleMain: String,
|
||||
val tooltip: String?,
|
||||
val secondaryBadges: List<Badge> = emptyList(),
|
||||
val fileLink: FileLink? = null,
|
||||
val actions: List<ToolAction> = emptyList(),
|
||||
val supportsStreaming: Boolean = false,
|
||||
val args: Any,
|
||||
val result: Any? = null,
|
||||
val projectId: String? = null,
|
||||
val prefixColor: JBColor? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,679 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentUiConfig
|
||||
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.DiffViewAction
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JDialog
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JScrollPane
|
||||
import javax.swing.JTextArea
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
object ToolCallDescriptorFactory {
|
||||
|
||||
fun create(
|
||||
toolName: String,
|
||||
args: Any,
|
||||
result: Any? = null,
|
||||
projectId: String? = null,
|
||||
overrideKind: ToolKind? = null
|
||||
): ToolCallDescriptor {
|
||||
val kind = overrideKind ?: detectToolKind(toolName, args)
|
||||
|
||||
return when (kind) {
|
||||
ToolKind.SEARCH -> createSearchDescriptor(args, result, projectId)
|
||||
ToolKind.READ -> createReadDescriptor(args, result, projectId)
|
||||
ToolKind.WRITE -> createWriteDescriptor(args, result, projectId)
|
||||
ToolKind.EDIT -> createEditDescriptor(args, result, projectId)
|
||||
ToolKind.BASH -> createBashDescriptor(args, result, projectId)
|
||||
ToolKind.WEB -> createWebDescriptor(args, result, projectId)
|
||||
ToolKind.TASK -> createTaskDescriptor(args, result, projectId)
|
||||
ToolKind.LIBRARY_RESOLVE -> createLibraryResolveDescriptor(args, result, projectId)
|
||||
ToolKind.LIBRARY_DOCS -> createLibraryDocsDescriptor(args, result, projectId)
|
||||
ToolKind.ASK_QUESTION -> createAskDescriptor(args, result, projectId)
|
||||
ToolKind.EXIT -> createExitDescriptor(toolName, args, result, projectId)
|
||||
ToolKind.OTHER -> createOtherDescriptor(toolName, args, result, projectId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectToolKind(toolName: String, args: Any): ToolKind {
|
||||
return when {
|
||||
toolName == "IntelliJSearch" || args is IntelliJSearchTool.Args -> ToolKind.SEARCH
|
||||
toolName == "Read" || args is ReadTool.Args -> ToolKind.READ
|
||||
toolName == "Write" || args is WriteTool.Args -> ToolKind.WRITE
|
||||
toolName == "Edit" || args is EditTool.Args -> ToolKind.EDIT
|
||||
toolName == "Bash" || args is BashTool.Args -> ToolKind.BASH
|
||||
toolName == "BashOutput" || args is BashOutputTool.Args -> ToolKind.BASH
|
||||
toolName == "KillShell" || args is KillShellTool.Args -> ToolKind.BASH
|
||||
toolName == "WebSearch" || args is WebSearchTool.Args -> ToolKind.WEB
|
||||
toolName == "ResolveLibraryId" || args is ResolveLibraryIdTool.Args -> ToolKind.LIBRARY_RESOLVE
|
||||
toolName == "GetLibraryDocs" || args is GetLibraryDocsTool.Args -> ToolKind.LIBRARY_DOCS
|
||||
toolName == "Task" || args is TaskTool.Args -> ToolKind.TASK
|
||||
toolName == "TodoWrite" || toolName == "TodoWriteTool" || args is TodoWriteTool.Args -> ToolKind.TASK
|
||||
toolName == "AskUserQuestion" || args is AskUserQuestionTool.Args -> ToolKind.ASK_QUESTION
|
||||
toolName == "Exit" -> ToolKind.EXIT
|
||||
else -> ToolKind.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAskDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.ASK_QUESTION,
|
||||
icon = AllIcons.General.ContextHelp,
|
||||
titlePrefix = "Clarify Requirements",
|
||||
titleMain = "",
|
||||
tooltip = "Ask the user clarifying questions",
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createExitDescriptor(
|
||||
toolName: String,
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.EXIT,
|
||||
icon = AllIcons.Actions.Exit,
|
||||
titlePrefix = "Exit",
|
||||
titleMain = "",
|
||||
tooltip = "Agent task completed",
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun showTextDialog(content: String, title: String) {
|
||||
val dialog = JDialog().apply {
|
||||
this.title = title
|
||||
isModal = true
|
||||
}
|
||||
val textArea = JTextArea(content).apply {
|
||||
isEditable = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}
|
||||
val scrollPane = JScrollPane(textArea).apply {
|
||||
preferredSize = JBUI.size(700, 400)
|
||||
border = JBUI.Borders.customLine(
|
||||
JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground()
|
||||
)
|
||||
}
|
||||
val footer = JPanel(FlowLayout(FlowLayout.RIGHT)).apply {
|
||||
isOpaque = false
|
||||
add(JButton("Copy").apply {
|
||||
addActionListener {
|
||||
val selection = StringSelection(content)
|
||||
Toolkit.getDefaultToolkit().systemClipboard.setContents(
|
||||
selection,
|
||||
null
|
||||
)
|
||||
}
|
||||
})
|
||||
add(JButton("Close").apply { addActionListener { dialog.dispose() } })
|
||||
}
|
||||
dialog.contentPane = BorderLayoutPanel().apply {
|
||||
add(scrollPane, BorderLayout.CENTER)
|
||||
add(footer, BorderLayout.SOUTH)
|
||||
}
|
||||
dialog.pack()
|
||||
dialog.setLocationRelativeTo(null)
|
||||
dialog.isVisible = true
|
||||
}
|
||||
|
||||
private fun createReadDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val readArgs = args as? ReadTool.Args
|
||||
val fileName = extractBaseName(readArgs?.filePath ?: "")
|
||||
|
||||
val lineBadge = when (result) {
|
||||
is ReadTool.Result.Success -> Badge("[${result.lineCount} lines]")
|
||||
else -> null
|
||||
}
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.READ,
|
||||
icon = AllIcons.FileTypes.Text,
|
||||
titlePrefix = "Read:",
|
||||
titleMain = fileName,
|
||||
tooltip = "Read file: ${readArgs?.filePath ?: ""}",
|
||||
fileLink = FileLink(
|
||||
path = readArgs?.filePath ?: "",
|
||||
displayName = fileName,
|
||||
enabled = true
|
||||
),
|
||||
secondaryBadges = listOfNotNull(lineBadge),
|
||||
actions = emptyList(),
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createWriteDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val writeArgs = args as? WriteTool.Args
|
||||
val fileName = extractBaseName(writeArgs?.filePath ?: "")
|
||||
|
||||
val badges = mutableListOf<Badge>()
|
||||
val actions = mutableListOf<ToolAction>()
|
||||
|
||||
if (result is WriteTool.Result && writeArgs != null) {
|
||||
when (result) {
|
||||
is WriteTool.Result.Success -> {
|
||||
badges.add(Badge("${result.bytesWritten} bytes", JBColor.GREEN))
|
||||
actions.add(
|
||||
ToolAction("View Changes", AllIcons.Actions.Diff) { component ->
|
||||
DiffViewAction.showDiff(writeArgs.filePath, component)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
is WriteTool.Result.Error -> {
|
||||
badges.add(Badge("Error", JBColor.RED))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.WRITE,
|
||||
icon = AllIcons.FileTypes.Text,
|
||||
titlePrefix = "Write:",
|
||||
titleMain = fileName,
|
||||
tooltip = "Write file: ${writeArgs?.filePath ?: ""}",
|
||||
secondaryBadges = badges,
|
||||
fileLink = FileLink(
|
||||
path = writeArgs?.filePath ?: "",
|
||||
displayName = fileName,
|
||||
enabled = result != null
|
||||
),
|
||||
actions = actions,
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createEditDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val editArgs = args as? EditTool.Args
|
||||
val fileName = extractBaseName(editArgs?.filePath ?: "")
|
||||
|
||||
val badges = mutableListOf<Badge>()
|
||||
val actions = mutableListOf<ToolAction>()
|
||||
|
||||
if (result is EditTool.Result && editArgs != null) {
|
||||
when (result) {
|
||||
is EditTool.Result.Success -> {
|
||||
val oldLines = editArgs.oldString.split('\n').size
|
||||
val newLines = editArgs.newString.split('\n').size
|
||||
val changedPer = minOf(oldLines, newLines)
|
||||
val addedPer = (newLines - oldLines).coerceAtLeast(0)
|
||||
val deletedPer = (oldLines - newLines).coerceAtLeast(0)
|
||||
val changed = changedPer * result.replacementsMade
|
||||
val inserted = addedPer * result.replacementsMade
|
||||
val deleted = deletedPer * result.replacementsMade
|
||||
|
||||
if (changed > 0) badges.add(Badge("[~$changed]", JBColor.YELLOW))
|
||||
if (deleted > 0) badges.add(Badge("[-$deleted]", JBColor.RED))
|
||||
if (inserted > 0) badges.add(Badge("[+$inserted]", JBColor.GREEN))
|
||||
actions.add(
|
||||
ToolAction("View Changes", AllIcons.Actions.Diff) { component ->
|
||||
try {
|
||||
val path = Path.of(editArgs.filePath)
|
||||
val after = Files.readString(path)
|
||||
val before = buildString {
|
||||
append(after)
|
||||
}.let { cur ->
|
||||
if (editArgs.replaceAll) {
|
||||
cur.replace(editArgs.newString, editArgs.oldString)
|
||||
} else {
|
||||
replaceFirstNOccurrences(
|
||||
cur,
|
||||
editArgs.newString,
|
||||
editArgs.oldString,
|
||||
result.replacementsMade
|
||||
)
|
||||
}
|
||||
}
|
||||
DiffViewAction.showDiff(
|
||||
before,
|
||||
after,
|
||||
"Changes in ${extractBaseName(editArgs.filePath)}",
|
||||
null
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
DiffViewAction.showDiff(editArgs.filePath, component)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
is EditTool.Result.Error -> {
|
||||
badges.add(Badge("Error", JBColor.RED))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.EDIT,
|
||||
icon = AllIcons.Actions.Edit,
|
||||
titlePrefix = "Edit:",
|
||||
titleMain = fileName,
|
||||
tooltip = "Edit file: ${editArgs?.filePath ?: ""}",
|
||||
secondaryBadges = badges,
|
||||
fileLink = FileLink(
|
||||
path = editArgs?.filePath ?: "",
|
||||
displayName = fileName,
|
||||
enabled = result != null
|
||||
),
|
||||
actions = actions,
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun replaceFirstNOccurrences(
|
||||
input: String,
|
||||
target: String,
|
||||
replacement: String,
|
||||
n: Int
|
||||
): String {
|
||||
if (n <= 0 || target.isEmpty()) return input
|
||||
var remaining = n
|
||||
var idx: Int
|
||||
val sb = StringBuilder()
|
||||
var cursor = 0
|
||||
while (remaining > 0) {
|
||||
idx = input.indexOf(target, cursor)
|
||||
if (idx < 0) break
|
||||
sb.append(input, cursor, idx)
|
||||
sb.append(replacement)
|
||||
cursor = idx + target.length
|
||||
remaining--
|
||||
}
|
||||
sb.append(input.substring(cursor))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun createBashDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val command = when (args) {
|
||||
is BashTool.Args -> args.command
|
||||
is BashOutputTool.Args -> args.bashId
|
||||
is KillShellTool.Args -> "kill_shell"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
val truncatedCommand = truncateCommand(command)
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.BASH,
|
||||
icon = AllIcons.Nodes.Console,
|
||||
titlePrefix = "Bash:",
|
||||
titleMain = truncatedCommand,
|
||||
tooltip = "Command: $command",
|
||||
supportsStreaming = true,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSearchDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val searchArgs = args as? IntelliJSearchTool.Args
|
||||
val pattern = searchArgs?.pattern ?: ""
|
||||
val scopeOrPath = searchArgs?.path?.substringAfterLast('/') ?: (searchArgs?.scope ?: "")
|
||||
val titleMain = buildSearchDisplay(truncatePattern(pattern), scopeOrPath)
|
||||
|
||||
val badges = mutableListOf<Badge>()
|
||||
val actions = mutableListOf<ToolAction>()
|
||||
|
||||
when (result) {
|
||||
is IntelliJSearchTool.Result -> {
|
||||
badges.add(Badge("${result.totalMatches} matches", JBColor.BLUE))
|
||||
if (result.output.isNotBlank()) {
|
||||
actions.add(
|
||||
ToolAction("View Results", AllIcons.Actions.Find) { _ ->
|
||||
showTextDialog(result.output, "Search Results")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is String -> {
|
||||
actions.add(
|
||||
ToolAction("View Results", AllIcons.Actions.Find) { _ ->
|
||||
showTextDialog(result, "Search Results")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.SEARCH,
|
||||
icon = AllIcons.Actions.Search,
|
||||
titlePrefix = "Search:",
|
||||
titleMain = titleMain,
|
||||
tooltip = buildTooltipString("Search", pattern, scopeOrPath.ifBlank { null }),
|
||||
secondaryBadges = badges,
|
||||
actions = actions,
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createWebDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val query = when {
|
||||
args is WebSearchTool.Args -> args.query
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
val truncatedQuery = truncateQuery(query)
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.WEB,
|
||||
icon = AllIcons.General.Web,
|
||||
titlePrefix = "Web:",
|
||||
titleMain = truncatedQuery,
|
||||
tooltip = "Web search: $query",
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTaskDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val description = when (args) {
|
||||
is TaskTool.Args -> args.description
|
||||
is TodoWriteTool.Args -> todoWriteLabel(args)
|
||||
is JsonObject -> extractTodoWriteLabel(args) ?: "Unknown"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
val titlePrefix: String
|
||||
val prefixColor: JBColor?
|
||||
|
||||
if (args is TaskTool.Args) {
|
||||
val subagentType = args.subagentType
|
||||
titlePrefix = "[$subagentType]"
|
||||
prefixColor = getSubagentColor(subagentType)
|
||||
} else {
|
||||
titlePrefix = "Task:"
|
||||
prefixColor = null
|
||||
}
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.TASK,
|
||||
icon = AllIcons.Actions.RunAnything,
|
||||
titlePrefix = titlePrefix,
|
||||
titleMain = description,
|
||||
tooltip = "Task: $description",
|
||||
secondaryBadges = emptyList(),
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId,
|
||||
prefixColor = prefixColor
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSubagentColor(subagentType: String): JBColor {
|
||||
val hue = subagentType.hashCode().absoluteValue % 360
|
||||
val hueNormalized = hue.toFloat() / 360f
|
||||
|
||||
val lightRgb = hslToRgb(hueNormalized, 0.75f, 0.45f)
|
||||
val lightColor = Color(lightRgb[0], lightRgb[1], lightRgb[2])
|
||||
|
||||
val darkRgb = hslToRgb(hueNormalized, 0.70f, 0.70f)
|
||||
val darkColor = Color(darkRgb[0], darkRgb[1], darkRgb[2])
|
||||
|
||||
return JBColor(lightColor, darkColor)
|
||||
}
|
||||
|
||||
private fun hslToRgb(h: Float, s: Float, l: Float): IntArray {
|
||||
val r: Float
|
||||
val g: Float
|
||||
val b: Float
|
||||
|
||||
if (s == 0f) {
|
||||
r = l
|
||||
g = l
|
||||
b = l
|
||||
} else {
|
||||
val q = if (l < 0.5f) l * (1 + s) else l + s - l * s
|
||||
val p = 2 * l - q
|
||||
r = hueToRgb(p, q, h + 1f / 3f)
|
||||
g = hueToRgb(p, q, h)
|
||||
b = hueToRgb(p, q, h - 1f / 3f)
|
||||
}
|
||||
|
||||
return intArrayOf((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt())
|
||||
}
|
||||
|
||||
private fun hueToRgb(p: Float, q: Float, t: Float): Float {
|
||||
var t = t
|
||||
if (t < 0f) t += 1f
|
||||
if (t > 1f) t -= 1f
|
||||
if (t < 1f / 6f) return p + (q - p) * 6 * t
|
||||
if (t < 1f / 2f) return q
|
||||
if (t < 2f / 3f) return p + (q - p) * (2f / 3f - t) * 6f
|
||||
return p
|
||||
}
|
||||
|
||||
private fun todoWriteLabel(args: TodoWriteTool.Args): String {
|
||||
val inProgress =
|
||||
args.todos.firstOrNull { it.status == TodoWriteTool.TodoStatus.IN_PROGRESS }
|
||||
if (inProgress != null && inProgress.activeForm.isNotBlank()) {
|
||||
return inProgress.activeForm
|
||||
}
|
||||
val completed = args.todos.firstOrNull { it.status == TodoWriteTool.TodoStatus.COMPLETED }
|
||||
if (completed != null && completed.content.isNotBlank()) {
|
||||
return "Marking task done: ${completed.content}"
|
||||
}
|
||||
if (args.title.isNotBlank()) return "Updating todo list: ${args.title}"
|
||||
return "Updating todo list"
|
||||
}
|
||||
|
||||
private fun extractTodoWriteLabel(args: JsonObject): String? {
|
||||
val todos = args["todos"] as? JsonArray ?: return null
|
||||
val inProgress =
|
||||
todos.firstOrNull { it.stringValue("status")?.equals("in_progress", true) == true }
|
||||
val inProgressLabel = inProgress?.stringValue("activeForm")?.trim().orEmpty()
|
||||
if (inProgressLabel.isNotBlank()) return inProgressLabel
|
||||
val completed =
|
||||
todos.firstOrNull { it.stringValue("status")?.equals("completed", true) == true }
|
||||
val completedLabel = completed?.stringValue("content")?.trim().orEmpty()
|
||||
if (completedLabel.isNotBlank()) return "Marking task done: $completedLabel"
|
||||
val title = args.stringValue("title")?.trim().orEmpty()
|
||||
if (title.isNotBlank()) return "Updating todo list: $title"
|
||||
return "Updating todo list"
|
||||
}
|
||||
|
||||
private fun JsonObject.stringValue(key: String): String? =
|
||||
(this[key] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun JsonArray.firstOrNull(predicate: (JsonObject) -> Boolean): JsonObject? {
|
||||
for (element in this) {
|
||||
val obj = element as? JsonObject ?: continue
|
||||
if (predicate(obj)) return obj
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createLibraryResolveDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val libraryName = when {
|
||||
args is ResolveLibraryIdTool.Args -> args.libraryName
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
val badges = mutableListOf<Badge>()
|
||||
|
||||
if (result is ResolveLibraryIdTool.Result.Success) {
|
||||
badges.add(Badge("${result.libraries.size} found", JBColor.BLUE))
|
||||
}
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.LIBRARY_RESOLVE,
|
||||
icon = AllIcons.General.Web,
|
||||
titlePrefix = "Library:",
|
||||
titleMain = libraryName,
|
||||
tooltip = "Resolve library: $libraryName",
|
||||
secondaryBadges = badges,
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createLibraryDocsDescriptor(
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
val libraryId = when {
|
||||
args is GetLibraryDocsTool.Args -> args.context7CompatibleLibraryID
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.LIBRARY_DOCS,
|
||||
icon = AllIcons.General.Web,
|
||||
titlePrefix = "Docs:",
|
||||
titleMain = libraryId,
|
||||
tooltip = "Get library docs: $libraryId",
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun createOtherDescriptor(
|
||||
toolName: String,
|
||||
args: Any,
|
||||
result: Any?,
|
||||
projectId: String?
|
||||
): ToolCallDescriptor {
|
||||
return ToolCallDescriptor(
|
||||
kind = ToolKind.OTHER,
|
||||
icon = AllIcons.Actions.Help,
|
||||
titlePrefix = "Tool:",
|
||||
titleMain = toolName,
|
||||
tooltip = "Tool: $toolName",
|
||||
supportsStreaming = false,
|
||||
args = args,
|
||||
result = result,
|
||||
projectId = projectId
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractBaseName(filePath: String): String {
|
||||
return filePath.substringAfterLast('/')
|
||||
}
|
||||
|
||||
private fun truncatePattern(pattern: String): String {
|
||||
return if (pattern.length > AgentUiConfig.GREP_PATTERN_MAX) {
|
||||
pattern.take(AgentUiConfig.GREP_PATTERN_MAX) + "..."
|
||||
} else {
|
||||
pattern
|
||||
}
|
||||
}
|
||||
|
||||
private fun truncateQuery(query: String): String {
|
||||
return if (query.length > AgentUiConfig.WEB_QUERY_MAX) {
|
||||
query.take(AgentUiConfig.WEB_QUERY_MAX) + "..."
|
||||
} else {
|
||||
query
|
||||
}
|
||||
}
|
||||
|
||||
private fun truncateCommand(command: String): String {
|
||||
return if (command.length > AgentUiConfig.BASH_CMD_MAX) {
|
||||
command.take(AgentUiConfig.BASH_CMD_MAX) + "..."
|
||||
} else {
|
||||
command
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSearchDisplay(pattern: String, scope: String?): String {
|
||||
return if (scope.isNullOrBlank()) {
|
||||
"\"$pattern\""
|
||||
} else {
|
||||
"\"$pattern\" in $scope"
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTooltipString(operation: String, pattern: String, scope: String?): String {
|
||||
return if (scope.isNullOrBlank()) {
|
||||
"$operation: \"$pattern\""
|
||||
} else {
|
||||
"$operation: \"$pattern\" in $scope"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.agent.ui.descriptor
|
||||
|
||||
import com.intellij.ide.actions.OpenFileAction
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBPanel
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.agent.tools.IntelliJSearchTool
|
||||
import ee.carlrobert.codegpt.agent.tools.AskUserQuestionTool
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.FlowLayout
|
||||
import java.awt.Font
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import javax.swing.*
|
||||
|
||||
class ToolCallView(
|
||||
private var descriptor: ToolCallDescriptor
|
||||
) : JBPanel<ToolCallView>() {
|
||||
|
||||
private var headerPanel = ToolCallHeaderPanel(descriptor)
|
||||
private val streamingPanel = ToolCallStreamingPanel()
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
|
||||
border = JBUI.Borders.empty()
|
||||
|
||||
add(headerPanel, BorderLayout.NORTH)
|
||||
add(streamingPanel, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
fun complete(success: Boolean, result: Any?) {
|
||||
headerPanel.updateCompletionStatus(result)
|
||||
when (result) {
|
||||
is AskUserQuestionTool.Result.Success -> {
|
||||
val compactLines = result.answers.entries.map { (k, v) -> "$k: $v" }
|
||||
streamingPanel.showCompactInfo(compactLines)
|
||||
}
|
||||
else -> streamingPanel.onCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
fun appendStreamingLine(text: String, isError: Boolean) {
|
||||
if (descriptor.supportsStreaming) {
|
||||
streamingPanel.appendLine(text, isError)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDescriptor(): ToolCallDescriptor = descriptor
|
||||
|
||||
fun refreshDescriptor(newDescriptor: ToolCallDescriptor) {
|
||||
this.descriptor = newDescriptor
|
||||
remove(headerPanel)
|
||||
headerPanel = ToolCallHeaderPanel(descriptor)
|
||||
add(headerPanel, BorderLayout.NORTH)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
|
||||
private class ToolCallHeaderPanel(
|
||||
private val descriptor: ToolCallDescriptor
|
||||
) : JBPanel<ToolCallHeaderPanel>() {
|
||||
|
||||
private val leftRow = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false }
|
||||
private var fileLink: ActionLink? = null
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
|
||||
buildHeaderContent()
|
||||
add(leftRow, BorderLayout.WEST)
|
||||
}
|
||||
|
||||
private fun buildHeaderContent() {
|
||||
val prefixLabel = JBLabel(
|
||||
if (descriptor.titlePrefix.isNotEmpty()) "${descriptor.titlePrefix} " else "",
|
||||
descriptor.icon,
|
||||
SwingConstants.LEFT
|
||||
).withFont(JBFont.label()).apply {
|
||||
descriptor.prefixColor?.let { color ->
|
||||
foreground = color
|
||||
}
|
||||
}
|
||||
leftRow.add(prefixLabel)
|
||||
|
||||
when {
|
||||
descriptor.fileLink != null -> addFileLink()
|
||||
else -> addRegularContent()
|
||||
}
|
||||
|
||||
addSecondaryBadges()
|
||||
|
||||
if (descriptor.actions.isNotEmpty()) {
|
||||
addActionLinks()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addFileLink() {
|
||||
val fileLink = descriptor.fileLink!!
|
||||
val link = ActionLink(fileLink.displayName) {
|
||||
val project = getProject()
|
||||
if (project != null) {
|
||||
val vf = LocalFileSystem.getInstance().findFileByPath(fileLink.path)
|
||||
if (vf != null) OpenFileAction.openFile(vf, project)
|
||||
}
|
||||
}.apply {
|
||||
toolTipText = fileLink.path
|
||||
setExternalLinkIcon()
|
||||
isEnabled = fileLink.enabled
|
||||
}
|
||||
|
||||
this.fileLink = link
|
||||
leftRow.add(link)
|
||||
}
|
||||
|
||||
private fun addRegularContent() {
|
||||
val content = JBLabel(descriptor.titleMain)
|
||||
if (!descriptor.tooltip.isNullOrBlank()) {
|
||||
content.toolTipText = descriptor.tooltip
|
||||
}
|
||||
leftRow.add(content)
|
||||
|
||||
// Show IntelliJSearch parameter chips for compact rows for parity with main cards
|
||||
addSearchParametersIfAny()
|
||||
}
|
||||
|
||||
private fun addSecondaryBadges() {
|
||||
descriptor.secondaryBadges.forEach { badge ->
|
||||
val isMatchesBadge = badge.text.contains("matches", ignoreCase = true)
|
||||
if (isMatchesBadge && descriptor.result != null) {
|
||||
val link = ActionLink(badge.text) {
|
||||
val content = when (val res = descriptor.result) {
|
||||
is IntelliJSearchTool.Result -> res.output
|
||||
is String -> res
|
||||
else -> null
|
||||
}
|
||||
content?.let { showSearchResultsDialog(it) }
|
||||
}.apply {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}
|
||||
leftRow.add(link.apply { border = JBUI.Borders.emptyLeft(4) })
|
||||
} else {
|
||||
leftRow.add(JBLabel(badge.text).withFont(JBFont.small()).apply {
|
||||
foreground = badge.color
|
||||
if (badge.tooltip != null) {
|
||||
toolTipText = badge.tooltip
|
||||
}
|
||||
border = JBUI.Borders.emptyLeft(4)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSearchResultsDialog(content: String) {
|
||||
val dialog = JDialog().apply {
|
||||
title = "Search Results"
|
||||
isModal = true
|
||||
}
|
||||
|
||||
val textArea = JTextArea(content).apply {
|
||||
isEditable = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}
|
||||
|
||||
val scrollPane = JScrollPane(textArea).apply {
|
||||
preferredSize = JBUI.size(700, 400)
|
||||
border =
|
||||
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
|
||||
}
|
||||
|
||||
val footerPanel = JPanel(FlowLayout(FlowLayout.RIGHT)).apply {
|
||||
isOpaque = false
|
||||
add(JButton("Copy").apply {
|
||||
addActionListener {
|
||||
val selection = StringSelection(content)
|
||||
Toolkit.getDefaultToolkit().systemClipboard.setContents(
|
||||
selection,
|
||||
null
|
||||
)
|
||||
}
|
||||
})
|
||||
add(JButton("Close").apply {
|
||||
addActionListener { dialog.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
dialog.contentPane = BorderLayoutPanel().apply {
|
||||
add(scrollPane, BorderLayout.CENTER)
|
||||
add(footerPanel, BorderLayout.SOUTH)
|
||||
}
|
||||
dialog.pack()
|
||||
dialog.setLocationRelativeTo(null)
|
||||
dialog.isVisible = true
|
||||
}
|
||||
|
||||
fun updateCompletionStatus(result: Any?) {
|
||||
descriptor.fileLink?.let { fileLink ->
|
||||
if (!fileLink.enabled && result != null) {
|
||||
this.fileLink?.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun addActionLinks() {
|
||||
descriptor.actions.forEach { action ->
|
||||
val link = ActionLink("[${action.name}]") { action.action(this@ToolCallHeaderPanel) }.apply {
|
||||
font = JBFont.small()
|
||||
}
|
||||
leftRow.add(JBLabel(" "))
|
||||
leftRow.add(link)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSearchParametersIfAny() {
|
||||
val args = descriptor.args
|
||||
if (args is IntelliJSearchTool.Args) {
|
||||
val params = buildList {
|
||||
if (args.caseSensitive == true) add("case")
|
||||
if (args.regex == true) add("regex")
|
||||
if (args.wholeWords == true) add("words")
|
||||
args.context?.let { c ->
|
||||
if (c.isNotBlank() && !c.equals(
|
||||
"ANY",
|
||||
true
|
||||
)
|
||||
) add("context: $c")
|
||||
}
|
||||
args.fileType?.let { ft -> if (ft.isNotBlank()) add("type: $ft") }
|
||||
args.outputMode?.let { om ->
|
||||
if (om.isNotBlank() && !om.equals(
|
||||
"content",
|
||||
true
|
||||
)
|
||||
) add("out: $om")
|
||||
}
|
||||
args.limit?.let { lim -> add("limit: $lim") }
|
||||
}.joinToString(" · ")
|
||||
|
||||
if (params.isNotBlank()) {
|
||||
leftRow.add(JBLabel(" ($params)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getProject(): Project? {
|
||||
return descriptor.projectId?.let { projectId ->
|
||||
ProjectManager.getInstance().openProjects.find { it.locationHash == projectId }
|
||||
} ?: ProjectManager.getInstance().openProjects.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles streaming output display.
|
||||
*/
|
||||
private class ToolCallStreamingPanel : JBPanel<ToolCallStreamingPanel>() {
|
||||
|
||||
private val contentPanel = JBPanel<JBPanel<*>>().apply {
|
||||
isOpaque = false
|
||||
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
|
||||
border = JBUI.Borders.empty(2, 20, 0, 0)
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private val streamingLabel = JBLabel("")
|
||||
private val actionsPanel = JPanel(FlowLayout(FlowLayout.LEFT, 6, 0)).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.emptyLeft(12)
|
||||
add(ActionLink("Show details") { showDetailsDialog() }.apply {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
})
|
||||
add(ActionLink("Copy") { copyToClipboard() }.apply {
|
||||
font = JBUI.Fonts.smallFont()
|
||||
})
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
private val streamingTail: ArrayDeque<Pair<String, Boolean>> = ArrayDeque()
|
||||
private val streamingAllLines: ArrayDeque<Pair<String, Boolean>> = ArrayDeque()
|
||||
|
||||
companion object {
|
||||
private const val MAX_TAIL_LINES = 3
|
||||
private const val MAX_DETAIL_LINES = 500
|
||||
}
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
|
||||
add(contentPanel, BorderLayout.CENTER)
|
||||
add(actionsPanel, BorderLayout.SOUTH)
|
||||
}
|
||||
|
||||
fun showCompactInfo(lines: List<String>) {
|
||||
if (lines.isEmpty()) {
|
||||
contentPanel.isVisible = false
|
||||
actionsPanel.isVisible = false
|
||||
return
|
||||
}
|
||||
val html = buildString {
|
||||
append("<html><body style='color:#808080'>")
|
||||
lines.forEachIndexed { i, line ->
|
||||
val safe = sanitizeHtml(line.take(300))
|
||||
append(safe)
|
||||
if (i < lines.size - 1) append("<br>")
|
||||
}
|
||||
append("</body></html>")
|
||||
}
|
||||
streamingLabel.font = JBUI.Fonts.smallFont()
|
||||
streamingLabel.text = html
|
||||
contentPanel.removeAll()
|
||||
contentPanel.add(streamingLabel)
|
||||
contentPanel.isVisible = true
|
||||
actionsPanel.isVisible = false
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun appendLine(text: String, isError: Boolean) {
|
||||
if (text.isBlank()) return
|
||||
|
||||
streamingTail.addLast(text to isError)
|
||||
while (streamingTail.size > MAX_TAIL_LINES) {
|
||||
streamingTail.removeFirst()
|
||||
}
|
||||
|
||||
streamingAllLines.addLast(text to isError)
|
||||
while (streamingAllLines.size > MAX_DETAIL_LINES) {
|
||||
streamingAllLines.removeFirst()
|
||||
}
|
||||
|
||||
updateDisplay()
|
||||
}
|
||||
|
||||
fun onCompletion() {
|
||||
contentPanel.isVisible = streamingTail.isNotEmpty()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun updateDisplay() {
|
||||
val html = buildTailHtml()
|
||||
val font = JBFont.create(Font(Font.MONOSPACED, Font.PLAIN, JBUI.Fonts.smallFont().size))
|
||||
|
||||
streamingLabel.font = font
|
||||
streamingLabel.text = html
|
||||
|
||||
contentPanel.removeAll()
|
||||
contentPanel.add(streamingLabel)
|
||||
contentPanel.isVisible = true
|
||||
actionsPanel.isVisible = true
|
||||
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun buildTailHtml(): String {
|
||||
return buildString {
|
||||
append("<html><body style='width: 400px'>")
|
||||
streamingTail.forEachIndexed { idx, (line, isError) ->
|
||||
val safeText = sanitizeHtml(line.take(200))
|
||||
val color = if (isError) "#ff5555" else "#808080"
|
||||
append("<span style='color:$color'>$safeText</span>")
|
||||
if (idx < streamingTail.size - 1) append("<br>")
|
||||
}
|
||||
append("</body></html>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeHtml(text: String): String {
|
||||
return text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
private fun copyToClipboard() {
|
||||
val content = buildString {
|
||||
streamingAllLines.forEachIndexed { i, (line, _) ->
|
||||
append(line)
|
||||
if (i < streamingAllLines.size - 1) append('\n')
|
||||
}
|
||||
}
|
||||
val selection = StringSelection(content)
|
||||
Toolkit.getDefaultToolkit().systemClipboard.setContents(selection, null)
|
||||
}
|
||||
|
||||
private fun showDetailsDialog() {
|
||||
val dialog = JDialog().apply {
|
||||
title = "Streaming Output"
|
||||
isModal = true
|
||||
}
|
||||
|
||||
val content = buildString {
|
||||
streamingAllLines.forEachIndexed { i, (line, _) ->
|
||||
append(line)
|
||||
if (i < streamingAllLines.size - 1) append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
val textArea = JTextArea(content).apply {
|
||||
isEditable = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}
|
||||
|
||||
val scrollPane = JScrollPane(textArea).apply {
|
||||
preferredSize = JBUI.size(700, 400)
|
||||
border =
|
||||
JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())
|
||||
}
|
||||
|
||||
val footerPanel = JPanel(FlowLayout(FlowLayout.RIGHT)).apply {
|
||||
isOpaque = false
|
||||
add(JButton("Copy").apply {
|
||||
addActionListener { copyToClipboard() }
|
||||
})
|
||||
add(JButton("Close").apply {
|
||||
addActionListener { dialog.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
dialog.contentPane = BorderLayoutPanel().apply {
|
||||
add(scrollPane, BorderLayout.CENTER)
|
||||
add(footerPanel, BorderLayout.SOUTH)
|
||||
}
|
||||
dialog.pack()
|
||||
dialog.setLocationRelativeTo(null)
|
||||
dialog.isVisible = true
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue