From 8cf5720db9d0e60c29fb6600ce5eb17adae9966d Mon Sep 17 00:00:00 2001 From: Carl-Robert Date: Tue, 2 Apr 2024 02:50:41 +0300 Subject: [PATCH] feat: OpenAI and Claude vision support (#430) * feat: add OpenAI and Claude vision support * refactor: replace awaitility with PlatformTestUtil.waitWithEventsDispatching * feat: display error when image not found * chore: bump llm-client * feat: configurable file watcher and minor code cleanup * fix: ensure image notifications are triggered only for image file types * docs: update changelog * fix: user textarea icon button behaviour * refactor: minor cleanup --- CHANGELOG.md | 4 + build.gradle.kts | 2 - .../codegpt.java-conventions.gradle.kts | 2 +- .../ee/carlrobert/codegpt/CodeGPTKeys.java | 2 + .../carlrobert/codegpt/EncodingManager.java | 14 +- .../java/ee/carlrobert/codegpt/Icons.java | 1 + .../ProjectCompilationStatusListener.java | 2 +- .../codegpt/completions/CallParameters.java | 19 ++ .../CompletionRequestProvider.java | 92 +++++++-- .../completions/CompletionRequestService.java | 11 +- .../conversations/message/Message.java | 10 + .../configuration/ConfigurationComponent.java | 7 + .../configuration/ConfigurationState.java | 9 + .../chat/ChatToolWindowTabPanel.java | 72 ++++--- .../standard/StandardChatToolWindowPanel.java | 76 +++++-- .../StandardChatToolWindowTabPanel.java | 10 +- .../toolwindow/chat/ui/ImageAccordion.java | 86 ++++++++ .../chat/ui/SelectedFilesNotification.java | 86 -------- .../chat/ui/ToolWindowFooterNotification.java | 50 +++++ .../toolwindow/chat/ui/UserMessagePanel.java | 16 ++ .../chat/ui/textarea/AttachImageNotifier.java | 11 + .../chat/ui/textarea/ModelComboBoxAction.java | 27 +-- .../chat/ui/textarea/UserPromptTextArea.java | 65 +++--- .../ui/textarea/UserPromptTextAreaHeader.java | 31 +-- .../codegpt/util/file/FileUtil.java | 9 + .../codegpt/CodeGPTProjectActivity.kt | 46 ++++- .../codegpt/CodeGPTUpdateActivity.kt | 6 +- .../ee/carlrobert/codegpt/FileWatcher.kt | 29 +++ .../codegpt/actions/AttachImageAction.kt | 40 ++++ src/main/resources/icons/send.svg | 2 +- src/main/resources/icons/send_dark.svg | 2 +- src/main/resources/icons/upload.svg | 7 + src/main/resources/icons/upload_dark.svg | 7 + .../resources/messages/codegpt.properties | 11 +- .../CodeCompletionServiceTest.java | 5 +- .../DefaultCompletionRequestHandlerTest.java | 10 +- .../StandardChatToolWindowTabPanelTest.java | 194 +++++++++++++----- .../java/testsupport/IntegrationTest.java | 14 +- .../testsupport/mixin/ShortcutsTestMixin.java | 15 +- src/test/resources/images/test-image.png | Bin 0 -> 3940 bytes 40 files changed, 793 insertions(+), 309 deletions(-) create mode 100644 src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ImageAccordion.java delete mode 100644 src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/SelectedFilesNotification.java create mode 100644 src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ToolWindowFooterNotification.java create mode 100644 src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/AttachImageNotifier.java create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/FileWatcher.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/actions/AttachImageAction.kt create mode 100644 src/main/resources/icons/upload.svg create mode 100644 src/main/resources/icons/upload_dark.svg create mode 100644 src/test/resources/images/test-image.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae98a6d..dab7732f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Vision support (image understanding) for OpenAI GPT-4 and Anthropic Claude models + ### Removed - Azure custom configuration (use OpenAI-compatible service to override the default configuration) diff --git a/build.gradle.kts b/build.gradle.kts index d942d5a6..f6c9f1d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,8 +59,6 @@ dependencies { implementation("org.jsoup:jsoup:1.17.2") implementation("org.apache.commons:commons-text:1.11.0") implementation("com.knuddels:jtokkit:1.0.0") - - testImplementation("org.awaitility:awaitility:4.2.0") } tasks.register("updateSubmodules") { diff --git a/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts index 4a2878aa..767f856a 100644 --- a/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts @@ -26,7 +26,7 @@ checkstyle { } dependencies { - implementation("ee.carlrobert:llm-client:0.6.2") + implementation("ee.carlrobert:llm-client:0.7.0") testImplementation("org.assertj:assertj-core:3.25.3") testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.2") diff --git a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java index 3cf177f2..a96ade5b 100644 --- a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java +++ b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java @@ -9,4 +9,6 @@ public class CodeGPTKeys { Key.create("codegpt.editor.inlay.prev-value"); public static final Key> SELECTED_FILES = Key.create("codegpt.selectedFiles"); + public static final Key IMAGE_ATTACHMENT_FILE_PATH = + Key.create("codegpt.imageAttachmentFilePath"); } diff --git a/src/main/java/ee/carlrobert/codegpt/EncodingManager.java b/src/main/java/ee/carlrobert/codegpt/EncodingManager.java index 8e3962d5..46022bfa 100644 --- a/src/main/java/ee/carlrobert/codegpt/EncodingManager.java +++ b/src/main/java/ee/carlrobert/codegpt/EncodingManager.java @@ -9,7 +9,10 @@ import com.knuddels.jtokkit.api.EncodingRegistry; import com.knuddels.jtokkit.api.EncodingType; import com.knuddels.jtokkit.api.IntArrayList; import ee.carlrobert.codegpt.conversations.Conversation; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionDetailedMessage; import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIMessageTextContent; import java.util.List; @Service @@ -38,7 +41,16 @@ public final class EncodingManager { } public int countMessageTokens(OpenAIChatCompletionMessage message) { - return countMessageTokens(message.getRole(), message.getContent()); + if (message instanceof OpenAIChatCompletionStandardMessage standardMessage) { + return countMessageTokens(standardMessage.getRole(), standardMessage.getContent()); + } + + return ((OpenAIChatCompletionDetailedMessage) message).getContent().stream() + .filter(it -> it instanceof OpenAIMessageTextContent) + .map(it -> countMessageTokens( + ((OpenAIChatCompletionDetailedMessage) message).getRole(), + ((OpenAIMessageTextContent) it).getText())) + .reduce(0, Integer::sum); } public int countMessageTokens(String role, String content) { diff --git a/src/main/java/ee/carlrobert/codegpt/Icons.java b/src/main/java/ee/carlrobert/codegpt/Icons.java index d7aae08b..6dbeaf12 100644 --- a/src/main/java/ee/carlrobert/codegpt/Icons.java +++ b/src/main/java/ee/carlrobert/codegpt/Icons.java @@ -17,4 +17,5 @@ public final class Icons { public static final Icon You = IconLoader.getIcon("/icons/you.svg", Icons.class); public static final Icon YouSmall = IconLoader.getIcon("/icons/you_small.png", Icons.class); public static final Icon User = IconLoader.getIcon("/icons/user.svg", Icons.class); + public static final Icon Upload = IconLoader.getIcon("/icons/upload.svg", Icons.class); } diff --git a/src/main/java/ee/carlrobert/codegpt/ProjectCompilationStatusListener.java b/src/main/java/ee/carlrobert/codegpt/ProjectCompilationStatusListener.java index 73d292c2..b1b86157 100644 --- a/src/main/java/ee/carlrobert/codegpt/ProjectCompilationStatusListener.java +++ b/src/main/java/ee/carlrobert/codegpt/ProjectCompilationStatusListener.java @@ -53,7 +53,7 @@ public class ProjectCompilationStatusListener implements CompilationStatusListen () -> project.getService(StandardChatToolWindowContentManager.class) .sendMessage(getMultiFileMessage(compileContext), FIX_COMPILE_ERRORS))) .addAction(NotificationAction.createSimpleExpiring( - CodeGPTBundle.get("checkForUpdatesTask.notification.hideButton"), + CodeGPTBundle.get("shared.notification.doNotShowAgain"), () -> ConfigurationSettings.getCurrentState().setCaptureCompileErrors(false))) .notify(project); } diff --git a/src/main/java/ee/carlrobert/codegpt/completions/CallParameters.java b/src/main/java/ee/carlrobert/codegpt/completions/CallParameters.java index 3123da64..68d8134d 100644 --- a/src/main/java/ee/carlrobert/codegpt/completions/CallParameters.java +++ b/src/main/java/ee/carlrobert/codegpt/completions/CallParameters.java @@ -2,6 +2,7 @@ package ee.carlrobert.codegpt.completions; import ee.carlrobert.codegpt.conversations.Conversation; import ee.carlrobert.codegpt.conversations.message.Message; +import org.jetbrains.annotations.Nullable; public class CallParameters { @@ -9,6 +10,8 @@ public class CallParameters { private final ConversationType conversationType; private final Message message; private final boolean retry; + private @Nullable String imageMediaType; + private byte[] imageData; public CallParameters(Conversation conversation, Message message) { this(conversation, ConversationType.DEFAULT, message, false); @@ -40,4 +43,20 @@ public class CallParameters { public boolean isRetry() { return retry; } + + public String getImageMediaType() { + return imageMediaType; + } + + public void setImageMediaType(String imageMediaType) { + this.imageMediaType = imageMediaType; + } + + public byte[] getImageData() { + return imageData; + } + + public void setImageData(byte[] imageData) { + this.imageData = imageData; + } } diff --git a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java index d43109fb..328cd04b 100644 --- a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java +++ b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java @@ -30,15 +30,29 @@ import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings; import ee.carlrobert.codegpt.settings.service.you.YouSettings; import ee.carlrobert.codegpt.telemetry.core.configuration.TelemetryConfiguration; import ee.carlrobert.codegpt.telemetry.core.service.UserId; +import ee.carlrobert.codegpt.util.file.FileUtil; +import ee.carlrobert.llm.client.anthropic.completion.ClaudeBase64Source; +import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionDetailedMessage; +import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionMessage; import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionRequest; -import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionRequestMessage; +import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionStandardMessage; +import ee.carlrobert.llm.client.anthropic.completion.ClaudeMessageImageContent; +import ee.carlrobert.llm.client.anthropic.completion.ClaudeMessageTextContent; import ee.carlrobert.llm.client.llama.completion.LlamaCompletionRequest; import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionDetailedMessage; import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage; import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionRequest; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIImageUrl; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIMessageImageURLContent; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIMessageTextContent; import ee.carlrobert.llm.client.you.completion.YouCompletionRequest; import ee.carlrobert.llm.client.you.completion.YouCompletionRequestMessage; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -91,9 +105,10 @@ public class CompletionRequestProvider { public static OpenAIChatCompletionRequest buildOpenAILookupCompletionRequest(String context) { return new OpenAIChatCompletionRequest.Builder( List.of( - new OpenAIChatCompletionMessage("system", + new OpenAIChatCompletionStandardMessage( + "system", getResourceContent("/prompts/method-name-generator.txt")), - new OpenAIChatCompletionMessage("user", context))) + new OpenAIChatCompletionStandardMessage("user", context))) .setModel(OpenAISettings.getCurrentState().getModel()) .setStream(false) .build(); @@ -103,8 +118,8 @@ public class CompletionRequestProvider { return buildCustomOpenAIChatCompletionRequest( CustomServiceSettings.getCurrentState(), List.of( - new OpenAIChatCompletionMessage("system", system), - new OpenAIChatCompletionMessage("user", context)), + new OpenAIChatCompletionStandardMessage("system", system), + new OpenAIChatCompletionStandardMessage("user", context)), true); } @@ -112,10 +127,10 @@ public class CompletionRequestProvider { return buildCustomOpenAIChatCompletionRequest( CustomServiceSettings.getCurrentState(), List.of( - new OpenAIChatCompletionMessage( + new OpenAIChatCompletionStandardMessage( "system", getResourceContent("/prompts/method-name-generator.txt")), - new OpenAIChatCompletionMessage("user", context)), + new OpenAIChatCompletionStandardMessage("user", context)), false); } @@ -246,15 +261,25 @@ public class CompletionRequestProvider { request.setMaxTokens(configuration.getMaxTokens()); request.setStream(true); request.setSystem(COMPLETION_SYSTEM_PROMPT); - var messages = conversation.getMessages().stream() + List messages = conversation.getMessages().stream() .filter(prevMessage -> prevMessage.getResponse() != null && !prevMessage.getResponse().isEmpty()) .flatMap(prevMessage -> Stream.of( - new ClaudeCompletionRequestMessage("user", prevMessage.getPrompt()), - new ClaudeCompletionRequestMessage("assistant", prevMessage.getResponse()))) + new ClaudeCompletionStandardMessage("user", prevMessage.getPrompt()), + new ClaudeCompletionStandardMessage("assistant", prevMessage.getResponse()))) .collect(toList()); - messages.add( - new ClaudeCompletionRequestMessage("user", callParameters.getMessage().getPrompt())); + + if (callParameters.getImageMediaType() != null && callParameters.getImageData().length > 0) { + messages.add(new ClaudeCompletionDetailedMessage("user", + List.of( + new ClaudeMessageImageContent(new ClaudeBase64Source( + callParameters.getImageMediaType(), + callParameters.getImageData())), + new ClaudeMessageTextContent(callParameters.getMessage().getPrompt())))); + } else { + messages.add( + new ClaudeCompletionStandardMessage("user", callParameters.getMessage().getPrompt())); + } request.setMessages(messages); return request; } @@ -263,22 +288,48 @@ public class CompletionRequestProvider { var message = callParameters.getMessage(); var messages = new ArrayList(); if (callParameters.getConversationType() == ConversationType.DEFAULT) { - messages.add(new OpenAIChatCompletionMessage( + messages.add(new OpenAIChatCompletionStandardMessage( "system", ConfigurationSettings.getCurrentState().getSystemPrompt())); } if (callParameters.getConversationType() == ConversationType.FIX_COMPILE_ERRORS) { - messages.add(new OpenAIChatCompletionMessage("system", FIX_COMPILE_ERRORS_SYSTEM_PROMPT)); + messages.add( + new OpenAIChatCompletionStandardMessage("system", FIX_COMPILE_ERRORS_SYSTEM_PROMPT)); } for (var prevMessage : conversation.getMessages()) { if (callParameters.isRetry() && prevMessage.getId().equals(message.getId())) { break; } - messages.add(new OpenAIChatCompletionMessage("user", prevMessage.getPrompt())); - messages.add(new OpenAIChatCompletionMessage("assistant", prevMessage.getResponse())); + var prevMessageImageFilePath = prevMessage.getImageFilePath(); + if (prevMessageImageFilePath != null && !prevMessageImageFilePath.isEmpty()) { + try { + var imageFilePath = Path.of(prevMessageImageFilePath); + var imageData = Files.readAllBytes(imageFilePath); + var imageMediaType = FileUtil.getImageMediaType(imageFilePath.getFileName().toString()); + messages.add(new OpenAIChatCompletionDetailedMessage("user", + List.of( + new OpenAIMessageImageURLContent(new OpenAIImageUrl(imageMediaType, imageData)), + new OpenAIMessageTextContent(prevMessage.getPrompt())))); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else { + messages.add(new OpenAIChatCompletionStandardMessage("user", prevMessage.getPrompt())); + } + messages.add(new OpenAIChatCompletionStandardMessage("assistant", prevMessage.getResponse())); + } + + if (callParameters.getImageMediaType() != null && callParameters.getImageData().length > 0) { + messages.add(new OpenAIChatCompletionDetailedMessage("user", + List.of( + new OpenAIMessageImageURLContent( + new OpenAIImageUrl(callParameters.getImageMediaType(), + callParameters.getImageData())), + new OpenAIMessageTextContent(message.getPrompt())))); + } else { + messages.add(new OpenAIChatCompletionStandardMessage("user", message.getPrompt())); } - messages.add(new OpenAIChatCompletionMessage("user", message.getPrompt())); return messages; } @@ -324,8 +375,11 @@ public class CompletionRequestProvider { break; } - totalUsage -= encodingManager.countMessageTokens(messages.get(i)); - messages.set(i, null); + var message = messages.get(i); + if (message instanceof OpenAIChatCompletionStandardMessage) { + totalUsage -= encodingManager.countMessageTokens(message); + messages.set(i, null); + } } return messages.stream().filter(Objects::nonNull).collect(toList()); diff --git a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java index 5805921b..ba4608ee 100644 --- a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java +++ b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java @@ -26,11 +26,11 @@ import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings; import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings; import ee.carlrobert.llm.client.DeserializationUtil; import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionRequest; -import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionRequestMessage; +import ee.carlrobert.llm.client.anthropic.completion.ClaudeCompletionStandardMessage; import ee.carlrobert.llm.client.llama.completion.LlamaCompletionRequest; import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionEventSourceListener; -import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage; import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionRequest; +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage; import ee.carlrobert.llm.client.openai.completion.response.OpenAIChatCompletionResponse; import ee.carlrobert.llm.completion.CompletionEventListener; import java.io.IOException; @@ -117,8 +117,8 @@ public final class CompletionRequestService { var configuration = ConfigurationSettings.getCurrentState(); var commitMessagePrompt = configuration.getCommitMessagePrompt(); var openaiRequest = new OpenAIChatCompletionRequest.Builder(List.of( - new OpenAIChatCompletionMessage("system", commitMessagePrompt), - new OpenAIChatCompletionMessage("user", prompt))) + new OpenAIChatCompletionStandardMessage("system", commitMessagePrompt), + new OpenAIChatCompletionStandardMessage("user", prompt))) .setModel(OpenAISettings.getCurrentState().getModel()) .build(); var selectedService = GeneralSettings.getCurrentState().getSelectedService(); @@ -142,8 +142,7 @@ public final class CompletionRequestService { claudeRequest.setStream(true); claudeRequest.setMaxTokens(configuration.getMaxTokens()); claudeRequest.setModel(anthropicSettings.getModel()); - claudeRequest.setMessages( - List.of(new ClaudeCompletionRequestMessage("user", prompt))); + claudeRequest.setMessages(List.of(new ClaudeCompletionStandardMessage("user", prompt))); CompletionClientProvider.getClaudeClient() .getCompletionAsync(claudeRequest, eventListener); break; diff --git a/src/main/java/ee/carlrobert/codegpt/conversations/message/Message.java b/src/main/java/ee/carlrobert/codegpt/conversations/message/Message.java index 5ff03f71..9b1c05b5 100644 --- a/src/main/java/ee/carlrobert/codegpt/conversations/message/Message.java +++ b/src/main/java/ee/carlrobert/codegpt/conversations/message/Message.java @@ -6,6 +6,7 @@ import ee.carlrobert.llm.client.you.completion.YouSerpResult; import java.util.List; import java.util.Objects; import java.util.UUID; +import org.jetbrains.annotations.Nullable; public class Message { @@ -15,6 +16,7 @@ public class Message { private String userMessage; private List serpResults; private List referencedFilePaths; + private @Nullable String imageFilePath; public Message(String prompt, String response) { this(prompt); @@ -71,6 +73,14 @@ public class Message { this.referencedFilePaths = referencedFilePaths; } + public @Nullable String getImageFilePath() { + return imageFilePath; + } + + public void setImageFilePath(@Nullable String imageFilePath) { + this.imageFilePath = imageFilePath; + } + @Override public boolean equals(Object obj) { if (obj == this) { diff --git a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java index 54aa2713..cbcf7fa9 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java @@ -44,6 +44,7 @@ public class ConfigurationComponent { private final JPanel mainPanel; private final JBTable table; private final JBCheckBox checkForPluginUpdatesCheckBox; + private final JBCheckBox checkForNewScreenshotsCheckBox; private final JBCheckBox openNewTabCheckBox; private final JBCheckBox methodNameGenerationCheckBox; private final JBCheckBox autoFormattingCheckBox; @@ -111,6 +112,9 @@ public class ConfigurationComponent { checkForPluginUpdatesCheckBox = new JBCheckBox( CodeGPTBundle.get("configurationConfigurable.checkForPluginUpdates.label"), + configuration.isCheckForNewScreenshots()); + checkForNewScreenshotsCheckBox = new JBCheckBox( + CodeGPTBundle.get("configurationConfigurable.checkForNewScreenshots.label"), configuration.isCheckForPluginUpdates()); openNewTabCheckBox = new JBCheckBox( CodeGPTBundle.get("configurationConfigurable.openNewTabCheckBox.label"), @@ -126,6 +130,7 @@ public class ConfigurationComponent { .addComponent(tablePanel) .addVerticalGap(4) .addComponent(checkForPluginUpdatesCheckBox) + .addComponent(checkForNewScreenshotsCheckBox) .addComponent(openNewTabCheckBox) .addComponent(methodNameGenerationCheckBox) .addComponent(autoFormattingCheckBox) @@ -152,6 +157,7 @@ public class ConfigurationComponent { state.setSystemPrompt(systemPromptTextArea.getText()); state.setCommitMessagePrompt(commitMessagePromptTextArea.getText()); state.setCheckForPluginUpdates(checkForPluginUpdatesCheckBox.isSelected()); + state.setCheckForNewScreenshots(checkForNewScreenshotsCheckBox.isSelected()); state.setCreateNewChatOnEachAction(openNewTabCheckBox.isSelected()); state.setMethodNameGenerationEnabled(methodNameGenerationCheckBox.isSelected()); state.setAutoFormattingEnabled(autoFormattingCheckBox.isSelected()); @@ -168,6 +174,7 @@ public class ConfigurationComponent { systemPromptTextArea.setText(configuration.getSystemPrompt()); commitMessagePromptTextArea.setText(configuration.getCommitMessagePrompt()); checkForPluginUpdatesCheckBox.setSelected(configuration.isCheckForPluginUpdates()); + checkForNewScreenshotsCheckBox.setSelected(configuration.isCheckForNewScreenshots()); openNewTabCheckBox.setSelected(configuration.isCreateNewChatOnEachAction()); methodNameGenerationCheckBox.setSelected(configuration.isMethodNameGenerationEnabled()); autoFormattingCheckBox.setSelected(configuration.isAutoFormattingEnabled()); diff --git a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationState.java b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationState.java index 7ae70912..f11663d3 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationState.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationState.java @@ -14,6 +14,7 @@ public class ConfigurationState { private int maxTokens = 1000; private double temperature = 0.1; private boolean checkForPluginUpdates = true; + private boolean checkForNewScreenshots = true; private boolean createNewChatOnEachAction; private boolean ignoreGitCommitTokenLimit; private boolean methodNameGenerationEnabled = true; @@ -62,6 +63,14 @@ public class ConfigurationState { this.createNewChatOnEachAction = createNewChatOnEachAction; } + public boolean isCheckForNewScreenshots() { + return checkForNewScreenshots; + } + + public void setCheckForNewScreenshots(boolean checkForNewScreenshots) { + this.checkForNewScreenshots = checkForNewScreenshots; + } + public Map getTableData() { return tableData; } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java index b59867ed..a3a13784 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -6,7 +6,6 @@ import static java.lang.String.format; import static java.util.stream.Collectors.toList; import com.intellij.openapi.Disposable; -import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.impl.EditorImpl; import com.intellij.openapi.project.Project; @@ -27,7 +26,6 @@ import ee.carlrobert.codegpt.settings.GeneralSettings; import ee.carlrobert.codegpt.settings.service.ServiceType; import ee.carlrobert.codegpt.telemetry.TelemetryAction; import ee.carlrobert.codegpt.toolwindow.chat.standard.StandardChatToolWindowContentManager; -import ee.carlrobert.codegpt.toolwindow.chat.standard.StandardChatToolWindowPanel; import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody; import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel; import ee.carlrobert.codegpt.toolwindow.chat.ui.ResponsePanel; @@ -41,11 +39,15 @@ import ee.carlrobert.codegpt.util.file.FileUtil; import java.awt.BorderLayout; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.UUID; import javax.swing.JComponent; import javax.swing.JPanel; import javax.swing.SwingUtilities; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public abstract class ChatToolWindowTabPanel implements Disposable { @@ -62,10 +64,7 @@ public abstract class ChatToolWindowTabPanel implements Disposable { protected abstract JComponent getLandingView(); - public ChatToolWindowTabPanel( - @NotNull Project project, - @NotNull Conversation conversation, - boolean useContextualSearch) { + public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) { this.project = project; this.conversation = conversation; conversationService = ConversationService.getInstance(); @@ -98,8 +97,10 @@ public abstract class ChatToolWindowTabPanel implements Disposable { } public void sendMessage(Message message, ConversationType conversationType) { - Runnable runnable = () -> { + SwingUtilities.invokeLater(() -> { var referencedFiles = project.getUserData(CodeGPTKeys.SELECTED_FILES); + var chatToolWindowPanel = project.getService(StandardChatToolWindowContentManager.class) + .tryFindChatToolWindowPanel(); if (referencedFiles != null && !referencedFiles.isEmpty()) { var referencedFilePaths = referencedFiles.stream() .map(ReferencedFile::getFilePath) @@ -110,26 +111,42 @@ public abstract class ChatToolWindowTabPanel implements Disposable { totalTokensPanel.updateReferencedFilesTokens(referencedFiles); - project.getService(StandardChatToolWindowContentManager.class) - .tryFindChatToolWindowPanel() - .ifPresent(StandardChatToolWindowPanel::clearSelectedFilesNotification); + chatToolWindowPanel.ifPresent(panel -> panel.clearNotifications(project)); + } + + var userMessagePanel = new UserMessagePanel(project, message, this); + var attachedFilePath = CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH.get(project); + var callParameters = getCallParameters(conversationType, message, attachedFilePath); + if (callParameters.getImageData() != null) { + message.setImageFilePath(attachedFilePath); + chatToolWindowPanel.ifPresent(panel -> panel.clearNotifications(project)); + userMessagePanel.displayImage(attachedFilePath); } var messagePanel = toolWindowScrollablePanel.addMessage(message.getId()); - messagePanel.add(new UserMessagePanel(project, message, this)); + messagePanel.add(userMessagePanel); + var responsePanel = createResponsePanel(message, conversationType); messagePanel.add(responsePanel); - updateTotalTokens(message); + call(callParameters, responsePanel); + }); + } - call(message, conversationType, responsePanel, false); - }; - // TODO - if (ApplicationManager.getApplication().isUnitTestMode()) { - runnable.run(); - } else { - SwingUtilities.invokeLater(runnable); + private CallParameters getCallParameters( + ConversationType conversationType, + Message message, + @Nullable String attachedFilePath) { + var callParameters = new CallParameters(conversation, conversationType, message, false); + if (attachedFilePath != null && !attachedFilePath.isEmpty()) { + try { + callParameters.setImageData(Files.readAllBytes(Path.of(attachedFilePath))); + callParameters.setImageMediaType(FileUtil.getImageMediaType(attachedFilePath)); + } catch (IOException e) { + throw new RuntimeException(e); + } } + return callParameters; } private void updateTotalTokens(Message message) { @@ -175,7 +192,7 @@ public abstract class ChatToolWindowTabPanel implements Disposable { if (responsePanel != null) { message.setResponse(""); conversationService.saveMessage(conversation, message); - call(message, conversationType, responsePanel, true); + call(new CallParameters(conversation, conversationType, message, true), responsePanel); } totalTokensPanel.updateConversationTokens(conversation); @@ -202,11 +219,7 @@ public abstract class ChatToolWindowTabPanel implements Disposable { totalTokensPanel.updateConversationTokens(conversation); } - private void call( - Message message, - ConversationType conversationType, - ResponsePanel responsePanel, - boolean retry) { + private void call(CallParameters callParameters, ResponsePanel responsePanel) { var responseContainer = (ChatMessageResponseBody) responsePanel.getContent(); if (!CompletionRequestService.getInstance().isRequestAllowed()) { @@ -222,13 +235,13 @@ public abstract class ChatToolWindowTabPanel implements Disposable { userPromptTextArea) { @Override public void handleTokensExceededPolicyAccepted() { - call(message, conversationType, responsePanel, true); + call(callParameters, responsePanel); } }); userPromptTextArea.setRequestHandler(requestHandler); userPromptTextArea.setSubmitEnabled(false); - requestHandler.call(new CallParameters(conversation, conversationType, message, retry)); + requestHandler.call(callParameters); } private void handleSubmit(String text) { @@ -257,7 +270,10 @@ public abstract class ChatToolWindowTabPanel implements Disposable { panel.add(JBUI.Panels.simplePanel(new UserPromptTextAreaHeader( selectedService, totalTokensPanel, - contentManager::createNewTabPanel)), BorderLayout.NORTH); + () -> { + ConversationService.getInstance().startConversation(); + contentManager.createNewTabPanel(); + })), BorderLayout.NORTH); panel.add(JBUI.Panels.simplePanel(userPromptTextArea), BorderLayout.CENTER); return panel; } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowPanel.java index 18e1d43b..178c024e 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowPanel.java @@ -1,5 +1,8 @@ package ee.carlrobert.codegpt.toolwindow.chat.standard; +import static java.lang.String.format; +import static java.util.Collections.emptyList; + import com.intellij.openapi.Disposable; import com.intellij.openapi.actionSystem.ActionManager; import com.intellij.openapi.actionSystem.ActionToolbar; @@ -8,6 +11,7 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.SimpleToolWindowPanel; import com.intellij.openapi.util.Disposer; import com.intellij.util.ui.JBUI; +import ee.carlrobert.codegpt.CodeGPTKeys; import ee.carlrobert.codegpt.ReferencedFile; import ee.carlrobert.codegpt.actions.IncludeFilesInContextNotifier; import ee.carlrobert.codegpt.actions.toolwindow.ClearChatWindowAction; @@ -15,42 +19,78 @@ import ee.carlrobert.codegpt.actions.toolwindow.CreateNewConversationAction; import ee.carlrobert.codegpt.actions.toolwindow.OpenInEditorAction; import ee.carlrobert.codegpt.conversations.ConversationService; import ee.carlrobert.codegpt.conversations.ConversationsState; -import ee.carlrobert.codegpt.toolwindow.chat.ui.SelectedFilesNotification; +import ee.carlrobert.codegpt.toolwindow.chat.ui.ToolWindowFooterNotification; +import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier; import java.awt.BorderLayout; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; +import java.util.stream.Collectors; +import javax.swing.BoxLayout; import javax.swing.JPanel; import org.jetbrains.annotations.NotNull; public class StandardChatToolWindowPanel extends SimpleToolWindowPanel { - private final SelectedFilesNotification selectedFilesNotification; + private final ToolWindowFooterNotification selectedFilesNotification; + private final ToolWindowFooterNotification imageFileAttachmentNotification; private StandardChatToolWindowTabbedPane tabbedPane; public StandardChatToolWindowPanel( @NotNull Project project, @NotNull Disposable parentDisposable) { super(true); - selectedFilesNotification = new SelectedFilesNotification(project); - init(project, selectedFilesNotification, parentDisposable); + selectedFilesNotification = new ToolWindowFooterNotification( + () -> clearSelectedFilesNotification(project)); + imageFileAttachmentNotification = new ToolWindowFooterNotification(() -> + project.putUserData(CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH, "")); + init(project, parentDisposable); project.getMessageBus() .connect() .subscribe(IncludeFilesInContextNotifier.FILES_INCLUDED_IN_CONTEXT_TOPIC, (IncludeFilesInContextNotifier) this::displaySelectedFilesNotification); + project.getMessageBus() + .connect() + .subscribe(AttachImageNotifier.IMAGE_ATTACHMENT_FILE_PATH_TOPIC, + (AttachImageNotifier) filePath -> imageFileAttachmentNotification.show( + Path.of(filePath).getFileName().toString(), + "File path: " + filePath)); + } + + public StandardChatToolWindowTabbedPane getChatTabbedPane() { + return tabbedPane; } public void displaySelectedFilesNotification(List referencedFiles) { - selectedFilesNotification.displaySelectedFilesNotification(referencedFiles); + if (referencedFiles.isEmpty()) { + return; + } + + var referencedFilePaths = referencedFiles.stream() + .map(ReferencedFile::getFilePath) + .collect(Collectors.toList()); + selectedFilesNotification.show( + referencedFiles.size() + " files selected", + selectedFilesNotificationDescription(referencedFilePaths)); } - public void clearSelectedFilesNotification() { - selectedFilesNotification.clearSelectedFilesNotification(); + private String selectedFilesNotificationDescription(List referencedFilePaths) { + var html = referencedFilePaths.stream() + .map(filePath -> format("
  • %s
  • ", Paths.get(filePath).getFileName().toString())) + .collect(Collectors.joining()); + return format("
      %s
    ", html); } - private void init( - Project project, - SelectedFilesNotification selectedFilesNotification, - Disposable parentDisposable) { + public void clearNotifications(Project project) { + selectedFilesNotification.hideNotification(); + imageFileAttachmentNotification.hideNotification(); + + project.putUserData(CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH, ""); + project.putUserData(CodeGPTKeys.SELECTED_FILES, emptyList()); + } + + private void init(Project project, Disposable parentDisposable) { var conversation = ConversationsState.getCurrentConversation(); if (conversation == null) { conversation = ConversationService.getInstance().startConversation(); @@ -71,8 +111,11 @@ public class StandardChatToolWindowPanel extends SimpleToolWindowPanel { BorderLayout.LINE_START); setToolbar(actionToolbarPanel); - setContent( - JBUI.Panels.simplePanel(tabbedPane).addToBottom(selectedFilesNotification)); + var notificationContainer = new JPanel(new BorderLayout()); + notificationContainer.setLayout(new BoxLayout(notificationContainer, BoxLayout.PAGE_AXIS)); + notificationContainer.add(selectedFilesNotification); + notificationContainer.add(imageFileAttachmentNotification); + setContent(JBUI.Panels.simplePanel(tabbedPane).addToBottom(notificationContainer)); Disposer.register(parentDisposable, tabPanel); } @@ -102,7 +145,10 @@ public class StandardChatToolWindowPanel extends SimpleToolWindowPanel { return tabbedPane; } - public StandardChatToolWindowTabbedPane getChatTabbedPane() { - return tabbedPane; + public void clearSelectedFilesNotification(Project project) { + project.putUserData(CodeGPTKeys.SELECTED_FILES, emptyList()); + project.getMessageBus() + .syncPublisher(IncludeFilesInContextNotifier.FILES_INCLUDED_IN_CONTEXT_TOPIC) + .filesIncluded(emptyList()); } } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowTabPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowTabPanel.java index ef340acf..5b8645cf 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/standard/StandardChatToolWindowTabPanel.java @@ -23,7 +23,7 @@ public class StandardChatToolWindowTabPanel extends ChatToolWindowTabPanel { public StandardChatToolWindowTabPanel( @NotNull Project project, @NotNull Conversation conversation) { - super(project, conversation, false); + super(project, conversation); if (conversation.getMessages().isEmpty()) { displayLandingView(); } else { @@ -67,8 +67,14 @@ public class StandardChatToolWindowTabPanel extends ChatToolWindowTabPanel { } messageResponseBody.hideCaret(); + var userMessagePanel = new UserMessagePanel(project, message, this); + var imageFilePath = message.getImageFilePath(); + if (imageFilePath != null && !imageFilePath.isEmpty()) { + userMessagePanel.displayImage(imageFilePath); + } + var messagePanel = toolWindowScrollablePanel.addMessage(message.getId()); - messagePanel.add(new UserMessagePanel(project, message, this)); + messagePanel.add(userMessagePanel); messagePanel.add(new ResponsePanel() .withReloadAction(() -> reloadMessage(message, conversation, ConversationType.DEFAULT)) .withDeleteAction(() -> removeMessage(message.getId(), conversation)) diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ImageAccordion.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ImageAccordion.java new file mode 100644 index 00000000..250be68e --- /dev/null +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ImageAccordion.java @@ -0,0 +1,86 @@ +package ee.carlrobert.codegpt.toolwindow.chat.ui; + +import static com.intellij.util.ui.JBUI.Panels.simplePanel; + +import com.intellij.icons.AllIcons.General; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.JBUI; +import ee.carlrobert.codegpt.CodeGPTBundle; +import java.awt.BorderLayout; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.event.ItemEvent; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JPanel; +import javax.swing.JToggleButton; +import javax.swing.SwingConstants; + +public class ImageAccordion extends JPanel { + + public ImageAccordion(String fileName, byte[] imageData) { + super(new BorderLayout()); + setOpaque(false); + + var contentPanel = createContentPanel(fileName, imageData); + add(createToggleButton(contentPanel), BorderLayout.NORTH); + add(contentPanel, BorderLayout.CENTER); + } + + private JPanel createContentPanel(String fileName, byte[] imageData) { + var panel = new JPanel(); + panel.setOpaque(false); + panel.setVisible(true); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(JBUI.Borders.empty(4, 0)); + try { + ByteArrayInputStream inputStream = new ByteArrayInputStream(imageData); + BufferedImage originalImage = ImageIO.read(inputStream); + int maxHeight = 80; + BufferedImage resizedImage = resizeImage(originalImage, maxHeight); + panel.add(simplePanel() + .andTransparent() + .addToTop( + new JBLabel("%s".formatted(fileName)) + .withBorder(JBUI.Borders.emptyBottom(4))) + .addToLeft(new JBLabel(new ImageIcon(resizedImage))), BorderLayout.LINE_START); + } catch (IOException e) { + panel.add(new JBLabel("ERROR: Something went wrong while reading the image")); + throw new RuntimeException(e); + } + return panel; + } + + private JToggleButton createToggleButton(JPanel contentPane) { + var accordionToggle = new JToggleButton( + CodeGPTBundle.get("imageAccordion.title"), + General.ArrowDown); + accordionToggle.setFocusPainted(false); + accordionToggle.setContentAreaFilled(false); + accordionToggle.setBackground(getBackground()); + accordionToggle.setSelectedIcon(General.ArrowUp); + accordionToggle.setBorder(null); + accordionToggle.setSelected(true); + accordionToggle.setHorizontalAlignment(SwingConstants.LEADING); + accordionToggle.setHorizontalTextPosition(SwingConstants.LEADING); + accordionToggle.addItemListener(e -> + contentPane.setVisible(e.getStateChange() == ItemEvent.SELECTED)); + return accordionToggle; + } + + private BufferedImage resizeImage(BufferedImage originalImage, int maxHeight) { + double aspectRatio = (double) originalImage.getWidth() / originalImage.getHeight(); + int newWidth = (int) (maxHeight * aspectRatio); + Image resizedImage = originalImage.getScaledInstance(newWidth, maxHeight, Image.SCALE_SMOOTH); + BufferedImage bufferedResizedImage = new BufferedImage(newWidth, maxHeight, + BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = bufferedResizedImage.createGraphics(); + g2d.drawImage(resizedImage, 0, 0, null); + g2d.dispose(); + return bufferedResizedImage; + } +} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/SelectedFilesNotification.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/SelectedFilesNotification.java deleted file mode 100644 index 0b0072b2..00000000 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/SelectedFilesNotification.java +++ /dev/null @@ -1,86 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat.ui; - -import static java.lang.String.format; -import static java.util.Collections.emptyList; - -import com.intellij.icons.AllIcons.General; -import com.intellij.openapi.project.Project; -import com.intellij.ui.JBColor; -import com.intellij.ui.components.ActionLink; -import com.intellij.ui.components.JBLabel; -import com.intellij.util.ui.JBUI; -import com.intellij.util.ui.JBUI.CurrentTheme.NotificationInfo; -import ee.carlrobert.codegpt.CodeGPTKeys; -import ee.carlrobert.codegpt.ReferencedFile; -import ee.carlrobert.codegpt.actions.IncludeFilesInContextNotifier; -import java.awt.BorderLayout; -import java.nio.file.Paths; -import java.util.List; -import java.util.stream.Collectors; -import javax.swing.JPanel; -import javax.swing.SwingConstants; -import org.jetbrains.annotations.NotNull; - -public class SelectedFilesNotification extends JPanel { - - private final Project project; - private final JBLabel label; - - public SelectedFilesNotification(@NotNull Project project) { - super(new BorderLayout()); - this.project = project; - this.label = new JBLabel( - getSelectedFilesLabel(), - General.BalloonInformation, - SwingConstants.LEADING); - - setVisible(false); - setBorder(JBUI.Borders.compound( - JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0), - JBUI.Borders.empty(8, 12))); - - setBackground(NotificationInfo.backgroundColor()); - setForeground(NotificationInfo.foregroundColor()); - add(label, BorderLayout.LINE_START); - add(new ActionLink("Remove", (event) -> { - clearSelectedFilesNotification(); - }), BorderLayout.LINE_END); - } - - public void displaySelectedFilesNotification(@NotNull List referencedFiles) { - if (referencedFiles.isEmpty()) { - return; - } - - label.setText(referencedFiles.size() + " files selected"); - var referencedFilePaths = referencedFiles.stream() - .map(ReferencedFile::getFilePath) - .collect(Collectors.toList()); - label.setToolTipText(getHtml(referencedFilePaths)); - setVisible(true); - } - - public void clearSelectedFilesNotification() { - project.putUserData(CodeGPTKeys.SELECTED_FILES, emptyList()); - project.getMessageBus() - .syncPublisher(IncludeFilesInContextNotifier.FILES_INCLUDED_IN_CONTEXT_TOPIC) - .filesIncluded(emptyList()); - - label.setText("0 files selected"); - label.setToolTipText(null); - setVisible(false); - } - - private String getHtml(List referencedFilePaths) { - var html = referencedFilePaths.stream() - .map(filePath -> format("
  • %s
  • ", Paths.get(filePath).getFileName().toString())) - .collect(Collectors.joining()); - return format("
      %s
    ", html); - } - - private String getSelectedFilesLabel() { - var selectedFiles = project.getUserData(CodeGPTKeys.SELECTED_FILES); - var fileCount = selectedFiles == null ? 0 : selectedFiles.size(); - return fileCount + " files selected"; - } -} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ToolWindowFooterNotification.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ToolWindowFooterNotification.java new file mode 100644 index 00000000..52bcc66f --- /dev/null +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ToolWindowFooterNotification.java @@ -0,0 +1,50 @@ +package ee.carlrobert.codegpt.toolwindow.chat.ui; + +import com.intellij.icons.AllIcons.General; +import com.intellij.ui.JBColor; +import com.intellij.ui.components.ActionLink; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.JBUI; +import com.intellij.util.ui.JBUI.CurrentTheme.NotificationInfo; +import java.awt.BorderLayout; +import javax.swing.JPanel; +import javax.swing.SwingConstants; + +public class ToolWindowFooterNotification extends JPanel { + + private final JBLabel label; + + public ToolWindowFooterNotification(Runnable onRemove) { + this("", onRemove); + } + + public ToolWindowFooterNotification(String text, Runnable onRemove) { + super(new BorderLayout()); + this.label = new JBLabel(text, General.BalloonInformation, SwingConstants.LEADING); + + setVisible(false); + setBorder(JBUI.Borders.compound( + JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0), + JBUI.Borders.empty(8, 12))); + + setBackground(NotificationInfo.backgroundColor()); + setForeground(NotificationInfo.foregroundColor()); + add(label, BorderLayout.LINE_START); + add(new ActionLink("Remove", (event) -> { + hideNotification(); + onRemove.run(); + }), BorderLayout.LINE_END); + } + + public void show(String text, String toolTipText) { + label.setText(text); + label.setToolTipText(toolTipText); + setVisible(true); + } + + public void hideNotification() { + label.setText(""); + label.setToolTipText(null); + setVisible(false); + } +} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/UserMessagePanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/UserMessagePanel.java index 5e723ed4..5db6de56 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/UserMessagePanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/UserMessagePanel.java @@ -1,5 +1,6 @@ package ee.carlrobert.codegpt.toolwindow.chat.ui; +import com.intellij.icons.AllIcons.General; import com.intellij.openapi.Disposable; import com.intellij.openapi.project.Project; import com.intellij.ui.ColorUtil; @@ -11,6 +12,9 @@ import ee.carlrobert.codegpt.Icons; import ee.carlrobert.codegpt.conversations.message.Message; import ee.carlrobert.codegpt.settings.GeneralSettings; import java.awt.BorderLayout; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; import javax.swing.JPanel; import javax.swing.SwingConstants; @@ -39,6 +43,18 @@ public class UserMessagePanel extends JPanel { } } + public void displayImage(String imageFilePath) { + try { + var path = Paths.get(imageFilePath); + add(new ImageAccordion(path.getFileName().toString(), Files.readAllBytes(path))); + } catch (IOException e) { + add(new JBLabel( + "Unable to load image %s".formatted(imageFilePath), + General.Error, + SwingConstants.LEFT)); + } + } + private ChatMessageResponseBody createResponseBody( Project project, String prompt, diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/AttachImageNotifier.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/AttachImageNotifier.java new file mode 100644 index 00000000..77acddf2 --- /dev/null +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/AttachImageNotifier.java @@ -0,0 +1,11 @@ +package ee.carlrobert.codegpt.toolwindow.chat.ui.textarea; + +import com.intellij.util.messages.Topic; + +public interface AttachImageNotifier { + + Topic IMAGE_ATTACHMENT_FILE_PATH_TOPIC = + Topic.create("imageAttachmentFilePath", AttachImageNotifier.class); + + void imageAttached(String filePath); +} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java index d7f11dc6..1d1b14b5 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java @@ -18,8 +18,6 @@ import ee.carlrobert.codegpt.Icons; import ee.carlrobert.codegpt.completions.llama.LlamaModel; import ee.carlrobert.codegpt.completions.you.YouUserManager; import ee.carlrobert.codegpt.completions.you.auth.SignedOutNotifier; -import ee.carlrobert.codegpt.conversations.ConversationService; -import ee.carlrobert.codegpt.conversations.ConversationsState; import ee.carlrobert.codegpt.settings.GeneralSettings; import ee.carlrobert.codegpt.settings.GeneralSettingsState; import ee.carlrobert.codegpt.settings.service.ServiceType; @@ -39,13 +37,13 @@ import org.jetbrains.annotations.NotNull; public class ModelComboBoxAction extends ComboBoxAction { - private final Runnable onAddNewTab; + private final Runnable onModelChange; private final GeneralSettingsState settings; private final OpenAISettingsState openAISettings; private final YouSettingsState youSettings; - public ModelComboBoxAction(Runnable onAddNewTab, ServiceType selectedService) { - this.onAddNewTab = onAddNewTab; + public ModelComboBoxAction(Runnable onModelChange, ServiceType selectedService) { + this.onModelChange = onModelChange; settings = GeneralSettings.getCurrentState(); openAISettings = OpenAISettings.getCurrentState(); youSettings = YouSettings.getCurrentState(); @@ -74,6 +72,7 @@ public class ModelComboBoxAction extends ComboBoxAction { var actionGroup = new DefaultActionGroup(); actionGroup.addSeparator("OpenAI"); List.of( + OpenAIChatCompletionModel.GPT_4_VISION_PREVIEW, OpenAIChatCompletionModel.GPT_4_0125_128k, OpenAIChatCompletionModel.GPT_3_5_0125_16k, OpenAIChatCompletionModel.GPT_4_32k, @@ -210,7 +209,7 @@ public class ModelComboBoxAction extends ComboBoxAction { @Override public void actionPerformed(@NotNull AnActionEvent e) { - handleProviderChange(serviceType, label, icon, comboBoxPresentation); + handleModelChange(serviceType, label, icon, comboBoxPresentation); } @Override @@ -220,7 +219,7 @@ public class ModelComboBoxAction extends ComboBoxAction { }; } - private void handleProviderChange( + private void handleModelChange( ServiceType serviceType, String label, Icon icon, @@ -228,13 +227,7 @@ public class ModelComboBoxAction extends ComboBoxAction { settings.setSelectedService(serviceType); comboBoxPresentation.setIcon(icon); comboBoxPresentation.setText(label); - - var currentConversation = ConversationsState.getCurrentConversation(); - if (currentConversation != null && !currentConversation.getMessages().isEmpty()) { - onAddNewTab.run(); - } else { - ConversationService.getInstance().startConversation(); - } + onModelChange.run(); } private AnAction createOpenAIModelAction( @@ -252,7 +245,7 @@ public class ModelComboBoxAction extends ComboBoxAction { @Override public void actionPerformed(@NotNull AnActionEvent e) { openAISettings.setModel(model.getCode()); - handleProviderChange( + handleModelChange( OPENAI, model.getDescription(), Icons.OpenAI, @@ -281,7 +274,7 @@ public class ModelComboBoxAction extends ComboBoxAction { @Override public void actionPerformed(@NotNull AnActionEvent e) { youSettings.setChatMode(mode); - handleProviderChange( + handleModelChange( YOU, mode.getDescription(), Icons.YouSmall, @@ -311,7 +304,7 @@ public class ModelComboBoxAction extends ComboBoxAction { public void actionPerformed(@NotNull AnActionEvent e) { youSettings.setCustomModel(model); youSettings.setChatMode(YouCompletionMode.CUSTOM); - handleProviderChange( + handleModelChange( YOU, model.getDescription(), Icons.YouSmall, diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextArea.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextArea.java index aca839b6..38a62f4a 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextArea.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextArea.java @@ -1,6 +1,12 @@ package ee.carlrobert.codegpt.toolwindow.chat.ui.textarea; +import static ee.carlrobert.codegpt.settings.service.ServiceType.ANTHROPIC; +import static ee.carlrobert.codegpt.settings.service.ServiceType.OPENAI; +import static ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.GPT_4_VISION_PREVIEW; + import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.ex.util.EditorUtil; import com.intellij.openapi.util.registry.Registry; @@ -10,11 +16,14 @@ import com.intellij.ui.components.JBTextArea; import com.intellij.util.ui.JBUI; import ee.carlrobert.codegpt.CodeGPTBundle; import ee.carlrobert.codegpt.Icons; +import ee.carlrobert.codegpt.actions.AttachImageAction; import ee.carlrobert.codegpt.completions.CompletionRequestHandler; +import ee.carlrobert.codegpt.settings.GeneralSettings; +import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings; +import ee.carlrobert.codegpt.ui.IconActionButton; import ee.carlrobert.codegpt.ui.UIUtil; import java.awt.BasicStroke; import java.awt.BorderLayout; -import java.awt.Cursor; import java.awt.FlowLayout; import java.awt.Graphics; import java.awt.Graphics2D; @@ -23,16 +32,14 @@ import java.awt.RenderingHints; import java.awt.event.ActionEvent; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import javax.swing.AbstractAction; -import javax.swing.Icon; -import javax.swing.JButton; import javax.swing.JPanel; import javax.swing.UIManager; import javax.swing.event.DocumentEvent; import javax.swing.text.BadLocationException; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; public class UserPromptTextArea extends JPanel { @@ -41,11 +48,12 @@ public class UserPromptTextArea extends JPanel { private static final JBColor BACKGROUND_COLOR = JBColor.namedColor( "Editor.SearchField.background", com.intellij.util.ui.UIUtil.getTextFieldBackground()); + private final AtomicReference requestHandlerRef = + new AtomicReference<>(); private final JBTextArea textArea; - private final int textAreaRadius = 16; private final Consumer onSubmit; - private JButton stopButton; + private IconActionButton stopButton; private JPanel iconsPanel; private boolean submitEnabled = true; @@ -150,6 +158,10 @@ public class UserPromptTextArea extends JPanel { stopButton.setEnabled(!submitEnabled); } + public void setRequestHandler(@NotNull CompletionRequestHandler handler) { + requestHandlerRef.set(handler); + } + private void handleSubmit() { if (submitEnabled && !textArea.getText().isEmpty()) { // Replacing each newline with two newlines to ensure proper Markdown formatting @@ -163,12 +175,34 @@ public class UserPromptTextArea extends JPanel { setOpaque(false); add(textArea, BorderLayout.CENTER); - stopButton = createIconButton(AllIcons.Actions.Suspend, null); + stopButton = new IconActionButton( + new AnAction("Stop", "Stop current inference", AllIcons.Actions.Suspend) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + var handler = requestHandlerRef.get(); + if (handler != null) { + handler.cancel(); + } + } + }); + stopButton.setEnabled(false); var flowLayout = new FlowLayout(FlowLayout.RIGHT); flowLayout.setHgap(8); iconsPanel = new JPanel(flowLayout); - iconsPanel.add(createIconButton(Icons.Send, this::handleSubmit)); + iconsPanel.add(new IconActionButton( + new AnAction("Send Message", "Send message", Icons.Send) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + handleSubmit(); + } + })); + var selectedService = GeneralSettings.getCurrentState().getSelectedService(); + if (selectedService == ANTHROPIC + || (selectedService == OPENAI + && GPT_4_VISION_PREVIEW.getCode().equals(OpenAISettings.getCurrentState().getModel()))) { + iconsPanel.add(new IconActionButton(new AttachImageAction())); + } iconsPanel.add(stopButton); add(iconsPanel, BorderLayout.EAST); } @@ -180,19 +214,4 @@ public class UserPromptTextArea extends JPanel { textArea.setFont(UIManager.getFont("TextField.font")); } } - - // TODO: IconActionButton? - private JButton createIconButton(Icon icon, @Nullable Runnable submitListener) { - var button = UIUtil.createIconButton(icon); - if (submitListener != null) { - button.addActionListener((e) -> handleSubmit()); - } - button.setCursor(new Cursor(Cursor.HAND_CURSOR)); - button.setEnabled(false); - return button; - } - - public void setRequestHandler(@NotNull CompletionRequestHandler requestService) { - stopButton.addActionListener(e -> requestService.cancel()); - } } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextAreaHeader.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextAreaHeader.java index d350d857..0c5374f2 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextAreaHeader.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/UserPromptTextAreaHeader.java @@ -1,12 +1,7 @@ package ee.carlrobert.codegpt.toolwindow.chat.ui.textarea; import com.intellij.openapi.actionSystem.ActionPlaces; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.ui.components.JBCheckBox; -import com.intellij.util.messages.MessageBusConnection; import com.intellij.util.ui.JBUI; -import ee.carlrobert.codegpt.completions.you.YouSubscriptionNotifier; -import ee.carlrobert.codegpt.completions.you.auth.SignedOutNotifier; import ee.carlrobert.codegpt.settings.service.ServiceType; import java.awt.BorderLayout; import javax.swing.JPanel; @@ -16,7 +11,7 @@ public class UserPromptTextAreaHeader extends JPanel { public UserPromptTextAreaHeader( ServiceType selectedService, TotalTokensPanel totalTokensPanel, - Runnable onAddNewTab) { + Runnable onModelChange) { super(new BorderLayout()); setOpaque(false); setBorder(JBUI.Borders.emptyBottom(8)); @@ -29,29 +24,7 @@ public class UserPromptTextAreaHeader extends JPanel { break; default: } - add(new ModelComboBoxAction(onAddNewTab, selectedService) + add(new ModelComboBoxAction(onModelChange, selectedService) .createCustomComponent(ActionPlaces.UNKNOWN), BorderLayout.LINE_END); } - - private void subscribeToYouTopics(JBCheckBox gpt4CheckBox) { - var messageBusConnection = ApplicationManager.getApplication().getMessageBus().connect(); - subscribeToYouSubscriptionTopic(messageBusConnection, gpt4CheckBox); - subscribeToSignedOutTopic(messageBusConnection, gpt4CheckBox); - } - - private void subscribeToSignedOutTopic( - MessageBusConnection messageBusConnection, - JBCheckBox gpt4CheckBox) { - messageBusConnection.subscribe( - SignedOutNotifier.SIGNED_OUT_TOPIC, - (SignedOutNotifier) () -> gpt4CheckBox.setEnabled(false)); - } - - private void subscribeToYouSubscriptionTopic( - MessageBusConnection messageBusConnection, - JBCheckBox gpt4CheckBox) { - messageBusConnection.subscribe( - YouSubscriptionNotifier.SUBSCRIPTION_TOPIC, - (YouSubscriptionNotifier) () -> gpt4CheckBox.setEnabled(true)); - } } diff --git a/src/main/java/ee/carlrobert/codegpt/util/file/FileUtil.java b/src/main/java/ee/carlrobert/codegpt/util/file/FileUtil.java index 1ec5746d..9a898f08 100644 --- a/src/main/java/ee/carlrobert/codegpt/util/file/FileUtil.java +++ b/src/main/java/ee/carlrobert/codegpt/util/file/FileUtil.java @@ -141,6 +141,15 @@ public class FileUtil { } } + public static String getImageMediaType(String fileName) { + var fileExtension = getFileExtension(fileName); + return switch (fileExtension) { + case "png" -> "image/png"; + case "jpg", "jpeg" -> "image/jpeg"; + default -> throw new IllegalArgumentException("Unsupported image type: " + fileExtension); + }; + } + public static String getResourceContent(String name) { try (var stream = Objects.requireNonNull(FileUtil.class.getResourceAsStream(name))) { return new String(stream.readAllBytes(), StandardCharsets.UTF_8); diff --git a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt index f8cfc21e..13317571 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt @@ -1,9 +1,11 @@ package ee.carlrobert.codegpt +import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationType import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.util.Disposer import ee.carlrobert.codegpt.actions.editor.EditorActionsUtil import ee.carlrobert.codegpt.completions.you.YouUserManager import ee.carlrobert.codegpt.completions.you.auth.AuthenticationHandler @@ -13,8 +15,11 @@ import ee.carlrobert.codegpt.completions.you.auth.response.YouAuthenticationResp import ee.carlrobert.codegpt.credentials.CredentialsStore import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.service.you.YouSettings +import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier import ee.carlrobert.codegpt.ui.OverlayUtil +import java.nio.file.Paths class CodeGPTProjectActivity : ProjectActivity { @@ -23,12 +28,24 @@ class CodeGPTProjectActivity : ProjectActivity { CredentialsStore.loadAll() if (YouUserManager.getInstance().authenticationResponse == null) { - ApplicationManager.getApplication() - .executeOnPooledThread { this.handleYouServiceAuthentication() } + handleYouServiceAuthenticationAsync() + } + + if (!ApplicationManager.getApplication().isUnitTestMode + && ConfigurationSettings.getCurrentState().isCheckForNewScreenshots + ) { + val pathToWatch = Paths.get(System.getProperty("user.home"), "Desktop") + val fileWatcher = FileWatcher(pathToWatch) + fileWatcher.watch { + if (listOf("jpg", "jpeg", "png").contains(it.extension)) { + showImageAttachmentNotification(project, it.absolutePath) + } + } + Disposer.register(project, fileWatcher) } } - private fun handleYouServiceAuthentication() { + private fun handleYouServiceAuthenticationAsync() { val settings = YouSettings.getCurrentState() val password = getCredential(CredentialKey.YOU_ACCOUNT_PASSWORD) if (settings.email.isNotEmpty() && !password.isNullOrEmpty()) { @@ -57,4 +74,27 @@ class CodeGPTProjectActivity : ProjectActivity { }) } } + + private fun showImageAttachmentNotification(project: Project, filePath: String) { + OverlayUtil.getDefaultNotification( + CodeGPTBundle.get("imageAttachmentNotification.content"), + NotificationType.INFORMATION + ) + .addAction(NotificationAction.createSimpleExpiring( + CodeGPTBundle.get("imageAttachmentNotification.action") + ) { + CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH.set(project, filePath) + project.messageBus + .syncPublisher( + AttachImageNotifier.IMAGE_ATTACHMENT_FILE_PATH_TOPIC + ) + .imageAttached(filePath) + }) + .addAction(NotificationAction.createSimpleExpiring( + CodeGPTBundle.get("shared.notification.doNotShowAgain") + ) { + ConfigurationSettings.getCurrentState().isCheckForNewScreenshots = false + }) + .notify(project) + } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTUpdateActivity.kt b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTUpdateActivity.kt index af31c937..505c5bea 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTUpdateActivity.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTUpdateActivity.kt @@ -1,4 +1,4 @@ -package ee.carlrobert.codegpt; +package ee.carlrobert.codegpt import com.intellij.ide.plugins.InstalledPluginsState import com.intellij.notification.NotificationAction @@ -39,7 +39,7 @@ class CodeGPTUpdateActivity : ProjectActivity { Task.Backgroundable(project, CodeGPTBundle.get("checkForUpdatesTask.title"), true) { override fun run(indicator: ProgressIndicator) { val isLatestVersion = - !InstalledPluginsState.getInstance().hasNewerVersion(CodeGPTPlugin.CODEGPT_ID); + !InstalledPluginsState.getInstance().hasNewerVersion(CodeGPTPlugin.CODEGPT_ID) if (project.isDisposed || isLatestVersion) { return } @@ -55,7 +55,7 @@ class CodeGPTUpdateActivity : ProjectActivity { .executeOnPooledThread { installCodeGPTUpdate(project) } }) .addAction(NotificationAction.createSimpleExpiring( - CodeGPTBundle.get("checkForUpdatesTask.notification.hideButton") + CodeGPTBundle.get("shared.notification.doNotShowAgain") ) { ConfigurationSettings.getCurrentState().isCheckForPluginUpdates = false }) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/FileWatcher.kt b/src/main/kotlin/ee/carlrobert/codegpt/FileWatcher.kt new file mode 100644 index 00000000..d1c20669 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/FileWatcher.kt @@ -0,0 +1,29 @@ +package ee.carlrobert.codegpt + +import com.intellij.openapi.Disposable +import org.apache.commons.io.monitor.FileAlterationListenerAdaptor +import org.apache.commons.io.monitor.FileAlterationMonitor +import org.apache.commons.io.monitor.FileAlterationObserver +import java.io.File +import java.nio.file.Path + +class FileWatcher(private val pathToWatch: Path) : Disposable { + + private val fileMonitor = + FileAlterationMonitor(500, FileAlterationObserver(pathToWatch.toFile())) + + fun watch(onFileCreated: (File) -> Unit) { + val observer = FileAlterationObserver(pathToWatch.toFile()) + observer.addListener(object : FileAlterationListenerAdaptor() { + override fun onFileCreate(file: File) { + onFileCreated(file) + } + }) + fileMonitor.addObserver(observer) + fileMonitor.start() + } + + override fun dispose() { + fileMonitor.stop() + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/AttachImageAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/AttachImageAction.kt new file mode 100644 index 00000000..509ed8c2 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/AttachImageAction.kt @@ -0,0 +1,40 @@ +package ee.carlrobert.codegpt.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.Icons +import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier + +class AttachImageAction : AnAction( + CodeGPTBundle.get("action.attachImage"), + CodeGPTBundle.get("action.attachImageDescription"), + Icons.Upload +) { + + override fun actionPerformed(e: AnActionEvent) { + FileChooser.chooseFiles(createSingleImageFileDescriptor(), e.project, null).also { files -> + if (files.isNotEmpty()) { + check(files.size == 1) { "Expected exactly one file to be selected" } + e.project?.let { project -> + CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH[project] = files.first().path + project.messageBus + .syncPublisher(AttachImageNotifier.IMAGE_ATTACHMENT_FILE_PATH_TOPIC) + .imageAttached(files.first().path) + } + } + } + } + + private fun createSingleImageFileDescriptor() = FileChooserDescriptor( + true, false, false, false, false, false + ).apply { + withFileFilter { file -> + file.extension in listOf("jpg", "jpeg", "png") + } + withTitle(CodeGPTBundle.get("imageFileChooser.title")) + } +} \ No newline at end of file diff --git a/src/main/resources/icons/send.svg b/src/main/resources/icons/send.svg index 89cf3907..b6101d19 100644 --- a/src/main/resources/icons/send.svg +++ b/src/main/resources/icons/send.svg @@ -1,6 +1,6 @@ - + diff --git a/src/main/resources/icons/send_dark.svg b/src/main/resources/icons/send_dark.svg index 5dd4ba3b..d3d497c5 100644 --- a/src/main/resources/icons/send_dark.svg +++ b/src/main/resources/icons/send_dark.svg @@ -1,6 +1,6 @@ - + diff --git a/src/main/resources/icons/upload.svg b/src/main/resources/icons/upload.svg new file mode 100644 index 00000000..b26837f5 --- /dev/null +++ b/src/main/resources/icons/upload.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/icons/upload_dark.svg b/src/main/resources/icons/upload_dark.svg new file mode 100644 index 00000000..2241155e --- /dev/null +++ b/src/main/resources/icons/upload_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index dc6c02ca..b5858202 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -87,6 +87,7 @@ configurationConfigurable.table.header.promptColumnLabel=Prompt configurationConfigurable.table.action.revertToDefaults.text=Revert to Defaults configurationConfigurable.table.action.addKeymap.text=Add Shortcut configurationConfigurable.checkForPluginUpdates.label=Check for plugin updates automatically +configurationConfigurable.checkForNewScreenshots.label=Check for new screenshots automatically configurationConfigurable.openNewTabCheckBox.label=Open a new chat on each action configurationConfigurable.enableMethodNameGeneration.label=Enable method name lookup suggestions configurationConfigurable.autoFormatting.label=Enable automatic code formatting @@ -178,7 +179,6 @@ validation.error.mustBeGreaterThanZero=Value must be greater than 0 checkForUpdatesTask.title=Checking for CodeGPT update... checkForUpdatesTask.notification.message=An update for CodeGPT is available. checkForUpdatesTask.notification.installButton=Install update -checkForUpdatesTask.notification.hideButton=Do not show again llamaServerAgent.buildingProject.description=Building llama.cpp... llamaServerAgent.serverBootup.description=Booting up server... notification.compilationError.description=CodeGPT has detected a compilation error. Would you like assistance in resolving it? @@ -190,4 +190,11 @@ shared.infillPromptTemplate=Infill template: shared.apiVersion=API version: shared.configuration=Configuration shared.port=Port: -codeCompletion.progress.title=Code completion in progress \ No newline at end of file +shared.notification.doNotShowAgain=Do not show again +codeCompletion.progress.title=Code completion in progress +imageAttachmentNotification.content=New image detected on desktop. Would you like to attach it to your current conversation? +imageAttachmentNotification.action=Attach image +action.attachImage=Attach Image +action.attachImageDescription=Attach an image +imageFileChooser.title=Select Image +imageAccordion.title=Attached image diff --git a/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java b/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java index c09a7262..0b039e06 100644 --- a/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java +++ b/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java @@ -39,9 +39,6 @@ public class CodeCompletionServiceTest extends IntegrationTest { myFixture.type('c'); - PlatformTestUtil.waitWithEventsDispatching( - "Editor inlay assertions failed", - () -> "TEST_OUTPUT".equals(PREVIOUS_INLAY_TEXT.get(myFixture.getEditor())), - 5); + waitExpecting(() -> "TEST_OUTPUT".equals(PREVIOUS_INLAY_TEXT.get(myFixture.getEditor()))); } } diff --git a/src/test/java/ee/carlrobert/codegpt/completions/DefaultCompletionRequestHandlerTest.java b/src/test/java/ee/carlrobert/codegpt/completions/DefaultCompletionRequestHandlerTest.java index 5e1900d8..18fa3020 100644 --- a/src/test/java/ee/carlrobert/codegpt/completions/DefaultCompletionRequestHandlerTest.java +++ b/src/test/java/ee/carlrobert/codegpt/completions/DefaultCompletionRequestHandlerTest.java @@ -6,10 +6,8 @@ import static ee.carlrobert.llm.client.util.JSONUtil.e; import static ee.carlrobert.llm.client.util.JSONUtil.jsonArray; import static ee.carlrobert.llm.client.util.JSONUtil.jsonMap; import static ee.carlrobert.llm.client.util.JSONUtil.jsonMapResponse; -import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; import ee.carlrobert.codegpt.CodeGPTPlugin; import ee.carlrobert.codegpt.conversations.ConversationService; @@ -50,7 +48,7 @@ public class DefaultCompletionRequestHandlerTest extends IntegrationTest { requestHandler.call(new CallParameters(conversation, ConversationType.DEFAULT, message, false)); - await().atMost(5, SECONDS).until(() -> "Hello!".equals(message.getResponse())); + waitExpecting(() -> "Hello!".equals(message.getResponse())); } public void testAzureChatCompletionCall() { @@ -86,7 +84,7 @@ public class DefaultCompletionRequestHandlerTest extends IntegrationTest { requestHandler.call(new CallParameters(conversation, ConversationType.DEFAULT, message, false)); - await().atMost(5, SECONDS).until(() -> "Hello!".equals(message.getResponse())); + waitExpecting(() -> "Hello!".equals(message.getResponse())); } public void testYouChatCompletionCall() { @@ -137,7 +135,7 @@ public class DefaultCompletionRequestHandlerTest extends IntegrationTest { requestHandler.call(new CallParameters(conversation, ConversationType.DEFAULT, message, false)); - await().atMost(5, SECONDS).until(() -> "Hello!".equals(message.getResponse())); + waitExpecting(() -> "Hello!".equals(message.getResponse())); } public void testLlamaChatCompletionCall() { @@ -171,7 +169,7 @@ public class DefaultCompletionRequestHandlerTest extends IntegrationTest { requestHandler.call(new CallParameters(conversation, ConversationType.DEFAULT, message, false)); - await().atMost(5, SECONDS).until(() -> "Hello!".equals(message.getResponse())); + waitExpecting(() -> "Hello!".equals(message.getResponse())); } private CompletionResponseEventListener getRequestEventListener(Message message) { diff --git a/src/test/java/ee/carlrobert/codegpt/toolwindow/chat/StandardChatToolWindowTabPanelTest.java b/src/test/java/ee/carlrobert/codegpt/toolwindow/chat/StandardChatToolWindowTabPanelTest.java index a7ec0ed0..3aba409d 100644 --- a/src/test/java/ee/carlrobert/codegpt/toolwindow/chat/StandardChatToolWindowTabPanelTest.java +++ b/src/test/java/ee/carlrobert/codegpt/toolwindow/chat/StandardChatToolWindowTabPanelTest.java @@ -7,10 +7,9 @@ import static ee.carlrobert.llm.client.util.JSONUtil.e; import static ee.carlrobert.llm.client.util.JSONUtil.jsonArray; import static ee.carlrobert.llm.client.util.JSONUtil.jsonMap; import static ee.carlrobert.llm.client.util.JSONUtil.jsonMapResponse; -import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.Objects.requireNonNull; import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; import ee.carlrobert.codegpt.CodeGPTKeys; import ee.carlrobert.codegpt.EncodingManager; @@ -23,6 +22,10 @@ import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings; import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings; import ee.carlrobert.codegpt.toolwindow.chat.standard.StandardChatToolWindowTabPanel; import ee.carlrobert.llm.client.http.exchange.StreamHttpExchange; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; import java.util.List; import java.util.Map; import testsupport.IntegrationTest; @@ -57,11 +60,10 @@ public class StandardChatToolWindowTabPanelTest extends IntegrationTest { panel.sendMessage(message); - await().atMost(5, SECONDS) - .until(() -> { - var messages = conversation.getMessages(); - return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); - }); + waitExpecting(() -> { + var messages = conversation.getMessages(); + return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); + }); var encodingManager = EncodingManager.getInstance(); assertThat(panel.getTokenDetails()).extracting( "systemPromptTokens", @@ -114,23 +116,28 @@ public class StandardChatToolWindowTabPanelTest extends IntegrationTest { List.of( Map.of("role", "system", "content", COMPLETION_SYSTEM_PROMPT), Map.of("role", "user", "content", - "Use the following context to answer question at the end:\n\n" - + "File Path: TEST_FILE_PATH_1\n" - + "File Content:\n" - + "```TEST_FILE_NAME_1\n" - + "TEST_FILE_CONTENT_1\n" - + "```\n\n" - + "File Path: TEST_FILE_PATH_2\n" - + "File Content:\n" - + "```TEST_FILE_NAME_2\n" - + "TEST_FILE_CONTENT_2\n" - + "```\n\n" - + "File Path: TEST_FILE_PATH_3\n" - + "File Content:\n" - + "```TEST_FILE_NAME_3\n" - + "TEST_FILE_CONTENT_3\n" - + "```\n\n" - + "Question: TEST_MESSAGE"))); + """ + Use the following context to answer question at the end: + + File Path: TEST_FILE_PATH_1 + File Content: + ```TEST_FILE_NAME_1 + TEST_FILE_CONTENT_1 + ``` + + File Path: TEST_FILE_PATH_2 + File Content: + ```TEST_FILE_NAME_2 + TEST_FILE_CONTENT_2 + ``` + + File Path: TEST_FILE_PATH_3 + File Content: + ```TEST_FILE_NAME_3 + TEST_FILE_CONTENT_3 + ``` + + Question: TEST_MESSAGE"""))); return List.of( jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("role", "assistant")))), jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("content", "Hel")))), @@ -140,11 +147,10 @@ public class StandardChatToolWindowTabPanelTest extends IntegrationTest { panel.sendMessage(message); - await().atMost(5, SECONDS) - .until(() -> { - var messages = conversation.getMessages(); - return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); - }); + waitExpecting(() -> { + var messages = conversation.getMessages(); + return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); + }); var encodingManager = EncodingManager.getInstance(); assertThat(panel.getTokenDetails()).extracting( "systemPromptTokens", @@ -175,6 +181,79 @@ public class StandardChatToolWindowTabPanelTest extends IntegrationTest { List.of("TEST_FILE_PATH_1", "TEST_FILE_PATH_2", "TEST_FILE_PATH_3")); } + public void testSendingOpenAIMessageWithImage() { + var testImagePath = requireNonNull(getClass().getResource("/images/test-image.png")).getPath(); + getProject().putUserData(CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH, testImagePath); + useOpenAIService("gpt-4-vision-preview"); + ConfigurationSettings.getCurrentState().setSystemPrompt(COMPLETION_SYSTEM_PROMPT); + var message = new Message("TEST_MESSAGE"); + var conversation = ConversationService.getInstance().startConversation(); + var panel = new StandardChatToolWindowTabPanel(getProject(), conversation); + expectOpenAI((StreamHttpExchange) request -> { + assertThat(request.getUri().getPath()).isEqualTo("/v1/chat/completions"); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getHeaders().get(AUTHORIZATION).get(0)).isEqualTo("Bearer TEST_API_KEY"); + try { + var testImageUrl = "data:image/png;base64," + + Base64.getEncoder().encodeToString(Files.readAllBytes(Path.of(testImagePath))); + assertThat(request.getBody()) + .extracting("model", "messages") + .containsExactly( + "gpt-4-vision-preview", + List.of( + Map.of("role", "system", "content", COMPLETION_SYSTEM_PROMPT), + Map.of("role", "user", "content", List.of( + Map.of( + "type", "image_url", + "image_url", Map.of("url", testImageUrl)), + Map.of("type", "text", "text", "TEST_MESSAGE") + )))); + } catch (IOException e) { + throw new RuntimeException(e); + } + return List.of( + jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("role", "assistant")))), + jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("content", "Hel")))), + jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("content", "lo")))), + jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("content", "!"))))); + }); + + panel.sendMessage(message); + + waitExpecting(() -> { + var messages = conversation.getMessages(); + return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); + }); + var encodingManager = EncodingManager.getInstance(); + assertThat(panel.getTokenDetails()).extracting( + "systemPromptTokens", + "conversationTokens", + "userPromptTokens", + "highlightedTokens") + .containsExactly( + encodingManager.countTokens(COMPLETION_SYSTEM_PROMPT), + encodingManager.countTokens(message.getPrompt()), + 0, + 0); + assertThat(panel.getConversation()) + .isNotNull() + .extracting("id", "model", "clientCode", "discardTokenLimit") + .containsExactly( + conversation.getId(), + conversation.getModel(), + conversation.getClientCode(), + false); + var messages = panel.getConversation().getMessages(); + assertThat(messages.size()).isOne(); + assertThat(messages.get(0)) + .extracting("id", "prompt", "response", "imageFilePath") + .containsExactly( + message.getId(), + message.getPrompt(), + message.getResponse(), + message.getImageFilePath()); + } + public void testFixCompileErrorsWithOpenAIService() { getProject().putUserData(CodeGPTKeys.SELECTED_FILES, List.of( new ReferencedFile("TEST_FILE_NAME_1", "TEST_FILE_PATH_1", "TEST_FILE_CONTENT_1"), @@ -201,23 +280,28 @@ public class StandardChatToolWindowTabPanelTest extends IntegrationTest { List.of( Map.of("role", "system", "content", FIX_COMPILE_ERRORS_SYSTEM_PROMPT), Map.of("role", "user", "content", - "Use the following context to answer question at the end:\n\n" - + "File Path: TEST_FILE_PATH_1\n" - + "File Content:\n" - + "```TEST_FILE_NAME_1\n" - + "TEST_FILE_CONTENT_1\n" - + "```\n\n" - + "File Path: TEST_FILE_PATH_2\n" - + "File Content:\n" - + "```TEST_FILE_NAME_2\n" - + "TEST_FILE_CONTENT_2\n" - + "```\n\n" - + "File Path: TEST_FILE_PATH_3\n" - + "File Content:\n" - + "```TEST_FILE_NAME_3\n" - + "TEST_FILE_CONTENT_3\n" - + "```\n\n" - + "Question: TEST_MESSAGE"))); + """ + Use the following context to answer question at the end: + + File Path: TEST_FILE_PATH_1 + File Content: + ```TEST_FILE_NAME_1 + TEST_FILE_CONTENT_1 + ``` + + File Path: TEST_FILE_PATH_2 + File Content: + ```TEST_FILE_NAME_2 + TEST_FILE_CONTENT_2 + ``` + + File Path: TEST_FILE_PATH_3 + File Content: + ```TEST_FILE_NAME_3 + TEST_FILE_CONTENT_3 + ``` + + Question: TEST_MESSAGE"""))); return List.of( jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("role", "assistant")))), jsonMapResponse("choices", jsonArray(jsonMap("delta", jsonMap("content", "Hel")))), @@ -227,11 +311,10 @@ public class StandardChatToolWindowTabPanelTest extends IntegrationTest { panel.sendMessage(message, ConversationType.FIX_COMPILE_ERRORS); - await().atMost(5, SECONDS) - .until(() -> { - var messages = conversation.getMessages(); - return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); - }); + waitExpecting(() -> { + var messages = conversation.getMessages(); + return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); + }); var encodingManager = EncodingManager.getInstance(); assertThat(panel.getTokenDetails()).extracting( "systemPromptTokens", @@ -312,11 +395,10 @@ public class StandardChatToolWindowTabPanelTest extends IntegrationTest { panel.sendMessage(message, ConversationType.DEFAULT); - await().atMost(5, SECONDS) - .until(() -> { - var messages = conversation.getMessages(); - return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); - }); + waitExpecting(() -> { + var messages = conversation.getMessages(); + return !messages.isEmpty() && "Hello!".equals(messages.get(0).getResponse()); + }); assertThat(panel.getConversation()) .isNotNull() .extracting("id", "model", "clientCode", "discardTokenLimit") diff --git a/src/test/java/testsupport/IntegrationTest.java b/src/test/java/testsupport/IntegrationTest.java index 8470f8d1..f92be8e2 100644 --- a/src/test/java/testsupport/IntegrationTest.java +++ b/src/test/java/testsupport/IntegrationTest.java @@ -1,5 +1,6 @@ package testsupport; +import com.intellij.openapi.util.Key; import com.intellij.testFramework.fixtures.BasePlatformTestCase; import ee.carlrobert.codegpt.CodeGPTKeys; import ee.carlrobert.llm.client.mixin.ExternalServiceTestMixin; @@ -17,8 +18,17 @@ public class IntegrationTest extends BasePlatformTestCase implements @Override protected void tearDown() throws Exception { ExternalServiceTestMixin.clearAll(); - getProject().putUserData(CodeGPTKeys.SELECTED_FILES, Collections.emptyList()); - getProject().putUserData(CodeGPTKeys.PREVIOUS_INLAY_TEXT, ""); + clearKeys(); super.tearDown(); } + + private void clearKeys() { + putUserData(CodeGPTKeys.SELECTED_FILES, Collections.emptyList()); + putUserData(CodeGPTKeys.PREVIOUS_INLAY_TEXT, ""); + putUserData(CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH, ""); + } + + private void putUserData(Key key, T value) { + getProject().putUserData(key, value); + } } diff --git a/src/test/java/testsupport/mixin/ShortcutsTestMixin.java b/src/test/java/testsupport/mixin/ShortcutsTestMixin.java index c6f19be6..9c7d4aa2 100644 --- a/src/test/java/testsupport/mixin/ShortcutsTestMixin.java +++ b/src/test/java/testsupport/mixin/ShortcutsTestMixin.java @@ -3,19 +3,25 @@ package testsupport.mixin; import static ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey.AZURE_OPENAI_API_KEY; import static ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey.OPENAI_API_KEY; +import com.intellij.testFramework.PlatformTestUtil; import ee.carlrobert.codegpt.credentials.CredentialsStore; import ee.carlrobert.codegpt.settings.GeneralSettings; import ee.carlrobert.codegpt.settings.service.ServiceType; import ee.carlrobert.codegpt.settings.service.azure.AzureSettings; import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings; import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings; +import java.util.function.BooleanSupplier; public interface ShortcutsTestMixin { default void useOpenAIService() { + useOpenAIService("gpt-4"); + } + + default void useOpenAIService(String model) { GeneralSettings.getCurrentState().setSelectedService(ServiceType.OPENAI); CredentialsStore.INSTANCE.setCredential(OPENAI_API_KEY, "TEST_API_KEY"); - OpenAISettings.getCurrentState().setModel("gpt-4"); + OpenAISettings.getCurrentState().setModel(model); } default void useAzureService() { @@ -35,4 +41,11 @@ public interface ShortcutsTestMixin { GeneralSettings.getCurrentState().setSelectedService(ServiceType.LLAMA_CPP); LlamaSettings.getCurrentState().setServerPort(null); } + + default void waitExpecting(BooleanSupplier condition) { + PlatformTestUtil.waitWithEventsDispatching( + "Waiting for message response timed out or did not meet expected conditions", + condition, + 5); + } } diff --git a/src/test/resources/images/test-image.png b/src/test/resources/images/test-image.png new file mode 100644 index 0000000000000000000000000000000000000000..8ed28a16f602cae345abc793e28cd4efec329da7 GIT binary patch literal 3940 zcmZ`+c{tSF+aFmY6vf!WSjRGUF~iu`Y*~JmvdtKov1Vo%G4||~J-eb(XtR`^Od(r} z3|T{264|o*jh^0~uJ^s(b6ubBeV@<0eb0UFe@?uGnE^8+HzNQ5U^X(;wWLUfV=&NB zz9iP>L;&Cv9Ic~cVWguYVBzoUj`l(U0EY3Iw)A#ZT^xm%({5>cP|=h|Ptc@tYZ^01 zhi`z$3`XG=h*LCYVD)8Fqk1%6^N->X^rRHB{aKzZy)(2ZM=5gV1#t0F^IB87@ArfC z{+y$H)j%IEz@XVR-Me1aeBr#Vg({-tA~+nJW41`eu>X*n=+Zy^CE@m^OVfbScRMRv z-#IpJ1dKa9+U!3(!f>mFLO21!jGwKYAQ$LA>j0)~uB5840`6v9$SV{}Ly*I>fT9dP z5MZb6EwDRVmFT-;vt!!xO;J+=3>TGMxQk}Tzg1%LR54Tk4+M=DW6Ps7$!s9aC; zXz~j8q%hd7K14?fD3knd6w^kk1@MyUPs@DyO7})0L^<)n8*#*)O*DeTs?B*S4t4F` zv-8H!Kj$zW40DClYV%u3_rzU{zJ20ZP_uyG34~1?pMMpi{VW{Gq+oYO2aYgGQVhe! zA-7+-gw?^bibPowE^TMe&a(7!e##egF%VykXiR!}I*9m`17eV+kOhqq)@7`*M>CQ--<-}P;_SnANYdbYTR)}F8 zyioLQVnX`YcuseJ?ULD@DM?Kgmk59+Jsa7w&HC0!8gC*qakZ#XjKMD)SV|3lYAdTF z#D-GWCM40`m@<#s76>Hdw^U@at_dFDZ)fr^Yvop4a8?E}sohYakB;!#b_Vf?@TOz| z3N`gtZ_e|QU&>B;kxLJb3i%N&3MQS=lE5wt-TKj6Vsf{Dnx_Qm&UpI~$LN2)YM&lp zRTj&S2tL(2)1Wa8zEiMz+b{i{(MttA3*kd>_qAg7MS3tMbr&h^cd=|CVQ*%_f=^-w zsuJ>j8Y0WW{_{qvqhPn?taK&gf!K0O?^JC0omC-N+NCvy=lmnPUsE5|K6>0P@!sLc zjz8$}!}d*g=AZk#MVmUCL94u5RbsV3SX@De8ItoE(vG?qDfUdi;v5X}y1jfU-V4vH zmzX-2PK&2Gio1YA-OKF3SKzxL8SDETqO6JW{ z%pd^6Ntv~d;XIF#VCn(%L7((CHDDz@Dk{pL1$#$~ioC+&dn7ce>PZ)Z7pL}{X0QeI zWn*7%Xoj1gR3bcBpb~PStC$YS59e7u5h`#xI@0hoeOjyAZ6e*3uLxPbo)ha@a^kd- ztqvAc$`O?=Vq|KTa5}$J`E*}fQGVyfXnsyDF3`znY7pq*LJZp4Wem&%I!R}kNG#X1 zfpM&CanV9@hHPn(r9#@ekv-ZR0{1#4N>7YL4hrn03U#7qLGM$>w9X4Qb$ZUSC)|-J zO#5BZy>!{J0N(P#;!XKZf%+u|Lq;ydhwxh!xV( z4~~84A;U^X%b7A7>PWt}^j6U%u~PQ?>Her&jdqQEO%%p< zaCfk4F>6r@pR&ulX}l@4$hSz%t(Yvwoyx7seNia@ePwWdKp5?Sp~I{xa$a`|q9?a%w)#nkJaik-41jGO41=yzW> zSB6#};trB`uWs(?W0E(xyZG$o*IGLp=*l80wUXLQqAKYs>8NA$W7=Xs?VB-IPwge) zlJ=A2lRp9rWEYk0bL|0{l7@jrilJu1_Z1dKHvF$MHBFLQg-N=I=dVk9XR97&q8rhv z3r-*C-Onn-*yoOLSGiY(Vv+0DzEt&i_O3YGk_K{caV$yoU2c3){oJJbira|G2=boi z>spEGM>X}nrZrnNh3<~t#P%4E_NQIXM_jN&95wiw-lvt-O^Ddhx{;ILl*u(WK}l;s z5ndbhN9|6p3(-Zs&Ynk|S{;I8cRKI1P0x9(4|J8-roc6J^GeE*wKK{c!AQl9=oB#b z2gNTn>6Prm58p4uC=97~20g}>tV?pjIGelmvp+pn9KQBA@k*X!OMP8K{P^P7_3_?u ztFiJi7|~l<$JN%=SP6%7sFlpQn(p$e6+?E1WN&0cqiK`s z#N|d9aUKqa=MkYq(-76>q2`+hJ^O^vPX~o^-nVml#(T04Qw|YF?T0J&g%u+eR#bMH zews#_my6w9LR|Ew+66uoZxoLfAG!=r<+MG}W)O50l+=NeW4s^@G2{G;=b^A_5fc$9 zavmn(tO$c+xZ7_NQC?A z{G0b)o5hcx+k^CJ_ymnL5tax>A=pTVSET6N=%4Xa@#So=j+^H-g7@cy4}`TWk@8?w z!Jy!^*67xgo?O0$KYry$ndMgHSJh;KK*$<>TlpkRo zfU&`7WWRRoAhxZ~R8DWMHSLsd_rGua@Sl5|rNIxLn631#_KJ7Uvp=Od7ipU#n3E?z zc~#GSxs$uctY;#xBqKcyt6K){VXHM!%5{*fadZ=t;J}-$=hp@}w5XUgs&jS~WPG~) zfL3GnvdG6YXW^S`#5Kuv4HNU*p-(kSH0|C&+XLRDarHAsF^1*E_s{lu6{6%l4=W6+ za;jeRXniT|Ll=4~2TqTD4{BQv=zGx9pL-OXiX3=!_XQIhr=qmt6%}9CVVSm;L5^~c z#6%cJko0IZ&{@iEtd!n>k z?Mwn&hP;L+o$3#kSf*3@3glpyF=L*X0IqKiPKh6lF~Nc6V??}pYvCK{UcGcMekQ7u*za%A!iO{3q4lkIfF@w#(WF z_&Q8dY|anEoA+tvNUzwV#F)%k^4SU&VFjV}guz$uiRD7rNTAbrZ_D1!MB`rco(St8e}=ju!R{comp>Rb z4!x*>mA4tl>}?>x(;0xn9m-CD0hX zSIT~FZaU*(a@Ws%joE{BE<+o~*8ZJ=)q1XJ={x zMf!R}T-|&TCG-YjR2*n-YA@_0KwbK2MZ;rgMN8HDf%%j0}}Yx%HjrE+#ga3O~zl;8D{2iz!bA0N*y6{hnf3cJv!x`0N{@fRwv1|0H z6y*we(Yof=6iFGdU+Z|p#3?wI$1q?RsG^JmfZa$}%Ub*#A|u^)w?CX-_Px{b;2j;& id>&k(C})n%$iIy$W{f+QKu2DR5n!Zeru$giCGx+Hz33MJ literal 0 HcmV?d00001