diff --git a/.gitignore b/.gitignore index 9bfe0df..7322ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .metadata/ .sonarlint/ +External Plug-in Libraries/ com.chabicht.code-intelligence/bin/ +*.md diff --git a/TOOLS.md b/TOOLS.md new file mode 100644 index 0000000..9415fc2 --- /dev/null +++ b/TOOLS.md @@ -0,0 +1,105 @@ +# Function Calling / Tool Use + +## Overview + +Function calling (also referred to as tool use or function invocation) enables language models (LLMs) to execute predefined actions or functions outside of their internal capabilities. Different AI model vendors may refer to this feature using various terms such as **Function Calling**, **Tool Use**, **Plugins**, or **Function Invocation**. + +## Supported Tools + +The plugin provides access to a set of functions or tools that the LLM can invoke. Each function has clearly defined parameters and expected behavior, allowing the model to interact programmatically with your workspace. + +The following tools are currently supported: + +### 1. `apply_patch` +- **Description**: Applies a patch to a code file. +- **Parameters**: + - `file_name` (string, required): The name of the file that is to be changed. If the path is present, the file is identified uniquely. If only a file name is present, a best effort algorithm is applied to guess the correct file. If possible, provide the complete path. + - `patch_content` (string, required): A patch in unified diff format. Example: + ```diff + --- /path/to/File.java + +++ /path/to/File.java + @@ -137,5 +137,9 @@ + } + } + + private static class UpperCaseNode extends CustomNode { + + @Override + + public void test() { + + // Implementation not needed for this test + + } + } + + private static class UpperCaseNodeRendererFactory implements HtmlNodeRendererFactory { + ``` + +### 2. `read_file_content` +- **Description**: Reads the content of a specified file, or a specific line range within the file. Returns the content with line numbers prefixed. +- **Parameters**: + - `file_name` (string, required): The name or path of the file to read. If only a file name is present, a best effort algorithm is applied to guess the correct file. If possible, provide the complete path to the file. + - `start_line` (integer, optional): The 1-based starting line number of the range to read. If omitted or null, and `end_line` is also omitted or null, the entire file is read. If provided, `end_line` must also be provided or it defaults to `start_line`. + - `end_line` (integer, optional): The 1-based ending line number of the range to read. If omitted or null, and `start_line` is provided, it defaults to `start_line` (reads a single line). If both `start_line` and `end_line` are omitted or null, the entire file is read. + +### 3. `apply_change` +- **Description**: Applies a change to a code file. +The `apply_change` tool allows the model to propose and apply modifications to code files. It includes the following parameters: + +- **`file_name`** *(string, required)*: The name of the file to be changed. +- **`location_in_file`** *(string, required)*: The location of the part of the file the change refers to in the format `l[start line]:[end line]`. Line numbers are 1-based, Example: `l123:130` refers to line 123 to 130. Hint: If you're provided with code snippets, they usually come with a line range in this format. This information is evaluated by code, so a textual description or 80% adherence WILL NOT WORK! It is IMPORTANT to adhere to the EXACT FORMAT! +- **`original_text`** *(string, required)*: The original text to replace, including all whitespaces etc. It is IMPORTANT that the original text is IDENTICAL to the text in the code file. It is also important that enough context information is provided that the tool can locate the change within the file. +- **`replacement_text`** *(string, required)*: The new text to replace the original content. +- **Note**: This function queues changes. Applying them typically opens a preview dialog for review. + +### 4. `perform_text_search` +- **Description**: Performs a text search in the workspace and returns the matches. The search is performed synchronously and results are collected directly. +- **Parameters**: + - `search_text` (string, required): The text or pattern to search for. Note that this is NOT capable of regex patterns! Instead of `.*`, you must use `*`! Valid placeholders for a pattern are: + - `*`: Any character sequence + - `?`: Any character + - `\`: Escape for literals '\*, ?, or \'. + - `file_name_patterns` (array of strings, optional): List of file name patterns (e.g., "*.java", "data*.xml"). If omitted or empty, searches all files in the workspace. Make sure to use this parameter for your calls if at all possible since it can greatly reduce execution time of the tool. + - `is_case_sensitive` (boolean, optional): True for a case-sensitive search. Defaults to false. + - `is_whole_word` (boolean, optional): True to match whole words only. Defaults to false. + +### 5. `perform_regex_search` +- **Description**: Performs a RegEx search in the workspace and returns the matches. The search is performed synchronously and results are collected directly. +- **Parameters**: + - `search_pattern` (string, required): The pattern in `java.util.regex.Pattern` syntax to search for. + - `file_name_patterns` (array of strings, optional): List of file name patterns (e.g., "*.java", "data*.xml"). If omitted or empty, searches all files in the workspace. Make sure to use this parameter for your calls if at all possible since it can greatly reduce execution time of the tool. + - `is_case_sensitive` (boolean, optional): True for a case-sensitive search. Defaults to false. + +### 6. `create_file` +- **Description**: Creates a new file with the specified content. Fails if the file already exists. +- **Parameters**: + - `file_path` (string, required): The complete path, including the file name, where the new file should be created (e.g., '/project/src/com/example/NewFile.java'). This path should be relative to the workspace or project root. + - `content` (string, required): The content to be written into the new file. Can be an empty string if an empty file is desired. + +### 7. `find_files` +- **Description**: Finds files within the workspace by matching their full, workspace-relative path against a regular expression. This is useful for locating files when the exact name or path is unknown. +- **Parameters**: + - `file_path_pattern` (string, required): A regular expression (in `java.util.regex.Pattern` syntax) to match against the full workspace-relative path of each file (e.g., `\.xml$`, `[^/]*Service\.java`, `/MyProject/.*Service\.java`, `/MyProject/src/main/java/com/example/.*Service\.java`). + - `project_names` (array of strings, optional): A list of project names to search within. If omitted or empty, all projects in the workspace will be searched. + - `is_case_sensitive` (boolean, optional): True for a case-sensitive search. Defaults to false if not provided. + +### 8. `list_projects` +- **Description**: Lists all projects in the current workspace, showing their name and whether they are open or closed. +- **Parameters**: None. + +--- + +## Model Integration and Configuration + +The plugin supports tool use with various AI model providers, including Gemini, OpenAI, Anthropic, Ollama, and other compatible APIs. The plugin automatically formats the tool definitions for the selected AI model provider. + +### Enabling and Managing Tools + +To use these tools with your AI model: + +1. Navigate to **Preferences → Code Intelligence**. +2. **Global Toggle**: + * Ensure the "**Enable Tools globally in Chat**" option is checked to allow the use of any tools. +3. **Manage Specific Tools**: + * Click the "**Manage Specific Tools...**" button. + * In the dialog that appears, you can check or uncheck individual tools to enable or disable them. + * Click **OK** to save your tool preferences. + +Once enabled, the plugin will declare the selected tools to the AI model, allowing it to request their execution when appropriate. There is no need to manually add JSON snippets for these standard tools to your model's custom parameters, as the plugin handles their registration. diff --git a/com.chabicht.code-intelligence.tests/.classpath b/com.chabicht.code-intelligence.tests/.classpath new file mode 100644 index 0000000..675a5e2 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/.classpath @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/com.chabicht.code-intelligence.tests/.gitignore b/com.chabicht.code-intelligence.tests/.gitignore new file mode 100644 index 0000000..ae3c172 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/.gitignore @@ -0,0 +1 @@ +/bin/ diff --git a/com.chabicht.code-intelligence.tests/.project b/com.chabicht.code-intelligence.tests/.project new file mode 100644 index 0000000..47c6f9d --- /dev/null +++ b/com.chabicht.code-intelligence.tests/.project @@ -0,0 +1,28 @@ + + + com.chabicht.code-intelligence.tests + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + + org.eclipse.pde.PluginNature + org.eclipse.jdt.core.javanature + + diff --git a/com.chabicht.code-intelligence.tests/.settings/org.eclipse.core.resources.prefs b/com.chabicht.code-intelligence.tests/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/com.chabicht.code-intelligence.tests/.settings/org.eclipse.jdt.core.prefs b/com.chabicht.code-intelligence.tests/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..62ef348 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,9 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 +org.eclipse.jdt.core.compiler.compliance=17 +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning +org.eclipse.jdt.core.compiler.release=enabled +org.eclipse.jdt.core.compiler.source=17 diff --git a/com.chabicht.code-intelligence.tests/META-INF/MANIFEST.MF b/com.chabicht.code-intelligence.tests/META-INF/MANIFEST.MF new file mode 100644 index 0000000..f94ef47 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/META-INF/MANIFEST.MF @@ -0,0 +1,10 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: Tests +Bundle-SymbolicName: com.chabicht.code-intelligence.tests +Bundle-Version: 1.0.0.qualifier +Bundle-Vendor: chabicht.com +Fragment-Host: com.chabicht.code-intelligence +Require-Bundle: junit-jupiter-api;bundle-version="5.10.0" +Automatic-Module-Name: com.chabicht.code.intelligence.tests +Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.chabicht.code-intelligence.tests/build.properties b/com.chabicht.code-intelligence.tests/build.properties new file mode 100644 index 0000000..34d2e4d --- /dev/null +++ b/com.chabicht.code-intelligence.tests/build.properties @@ -0,0 +1,4 @@ +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + . diff --git a/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestDocument.java b/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestDocument.java new file mode 100644 index 0000000..da6e9e2 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestDocument.java @@ -0,0 +1,320 @@ +package com.chabicht.code_intelligence.chat.tools; + +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.BadPositionCategoryException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentListener; +import org.eclipse.jface.text.IDocumentPartitioner; +import org.eclipse.jface.text.IDocumentPartitioningListener; +import org.eclipse.jface.text.IPositionUpdater; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITypedRegion; +import org.eclipse.jface.text.Position; +import org.eclipse.jface.text.Region; + +public class TestDocument implements IDocument { + + private final String fileName; + private String content; + + public TestDocument(String fileName, String content) { + this.fileName = fileName; + this.content = content; + } + + @Override + public char getChar(int offset) throws BadLocationException { + return content.charAt(offset); + } + + @Override + public int getLength() { + return content.length(); + } + + @Override + public String get() { + return content; + } + + @Override + public String get(int offset, int length) throws BadLocationException { + return content.substring(offset, offset + length); + } + + @Override + public void set(String text) { + content = text; + } + + @Override + public void replace(int offset, int length, String text) throws BadLocationException { + content = content.substring(0, offset) + text + content.substring(offset + length); + } + + @Override + public void addDocumentListener(IDocumentListener listener) { + + } + + @Override + public void removeDocumentListener(IDocumentListener listener) { + + } + + @Override + public void addPrenotifiedDocumentListener(IDocumentListener documentAdapter) { + + } + + @Override + public void removePrenotifiedDocumentListener(IDocumentListener documentAdapter) { + + } + + @Override + public void addPositionCategory(String category) { + + } + + @Override + public void removePositionCategory(String category) throws BadPositionCategoryException { + + } + + @Override + public String[] getPositionCategories() { + return null; + } + + @Override + public boolean containsPositionCategory(String category) { + return false; + } + + @Override + public void addPosition(Position position) throws BadLocationException { + + } + + @Override + public void removePosition(Position position) { + + } + + @Override + public void addPosition(String category, Position position) + throws BadLocationException, BadPositionCategoryException { + + } + + @Override + public void removePosition(String category, Position position) throws BadPositionCategoryException { + + } + + @Override + public Position[] getPositions(String category) throws BadPositionCategoryException { + return null; + } + + @Override + public boolean containsPosition(String category, int offset, int length) { + return false; + } + + @Override + public int computeIndexInCategory(String category, int offset) + throws BadLocationException, BadPositionCategoryException { + return 0; + } + + @Override + public void addPositionUpdater(IPositionUpdater updater) { + + } + + @Override + public void removePositionUpdater(IPositionUpdater updater) { + + } + + @Override + public void insertPositionUpdater(IPositionUpdater updater, int index) { + + } + + @Override + public IPositionUpdater[] getPositionUpdaters() { + return null; + } + + @Override + public String[] getLegalContentTypes() { + return null; + } + + @Override + public String getContentType(int offset) throws BadLocationException { + return null; + } + + @Override + public ITypedRegion getPartition(int offset) throws BadLocationException { + return null; + } + + @Override + public ITypedRegion[] computePartitioning(int offset, int length) throws BadLocationException { + return null; + } + + @Override + public void addDocumentPartitioningListener(IDocumentPartitioningListener listener) { + + } + + @Override + public void removeDocumentPartitioningListener(IDocumentPartitioningListener listener) { + + } + + @Override + public void setDocumentPartitioner(IDocumentPartitioner partitioner) { + + } + + @Override + public IDocumentPartitioner getDocumentPartitioner() { + return null; + } + + @Override + public int getLineLength(int line) throws BadLocationException { + String[] lines = content.split("\\r?\\n"); + if (line < 0 || line >= lines.length) { + throw new BadLocationException("Invalid line number: " + line); + } + return lines[line].length(); + } + + @Override + public int getLineOfOffset(int offset) throws BadLocationException { + String[] lines = content.split("\\r?\\n"); + int currentOffset = 0; + for (int i = 0; i < lines.length; i++) { + if (offset >= currentOffset && offset < currentOffset + lines[i].length() + + (i < lines.length - 1 ? lines[i].endsWith("\r") ? 2 : 1 : 0)) { + return i; + } + currentOffset += lines[i].length() + (i < lines.length - 1 ? lines[i].endsWith("\r") ? 2 : 1 : 0); + } + throw new BadLocationException("Invalid offset: " + offset); + } + + @Override + public int getLineOffset(int line) throws BadLocationException { + String[] lines = content.split("\\r?\\n"); + if (line < 0 || line >= lines.length) { + throw new BadLocationException("Invalid line number: " + line); + } + int offset = 0; + for (int i = 0; i < line; i++) { + offset += lines[i].length() + (i < lines.length - 1 ? lines[i].endsWith("\r") ? 2 : 1 : 0); + } + return offset; + } + + @Override + public IRegion getLineInformation(int line) throws BadLocationException { + if (line < 0) { + throw new BadLocationException("Invalid line number: " + line); + } + + int currentOffset = 0; + int currentLine = 0; + int contentLen = content.length(); + + for (int i = 0; i < contentLen; i++) { + if (currentLine == line) { + int lineStartOffset = currentOffset; + int lineEndOffset = contentLen; // Assume end of content initially + + // Find the end of the current line (before the next delimiter) + for (int j = i; j < contentLen; j++) { + char c = content.charAt(j); + if (c == '\r') { + if (j + 1 < contentLen && content.charAt(j + 1) == '\n') { + lineEndOffset = j; // Found \r\n, end is before \r + break; + } else { + lineEndOffset = j; // Found \r, end is before \r + break; + } + } else if (c == '\n') { + lineEndOffset = j; // Found \n, end is before \n + break; + } + } + return new Region(lineStartOffset, lineEndOffset - lineStartOffset); + } + + char c = content.charAt(i); + if (c == '\r') { + if (i + 1 < contentLen && content.charAt(i + 1) == '\n') { + i++; // Consume the next character as part of \r\n + } + currentOffset = i + 1; // Offset is after the delimiter + currentLine++; + } else if (c == '\n') { + currentOffset = i + 1; // Offset is after the delimiter + currentLine++; + } + } + + // Check if the last line was requested and exists + if (currentLine == line && currentOffset <= contentLen) { + return new Region(currentOffset, contentLen - currentOffset); + } + + throw new BadLocationException("Invalid line number: " + line); + } + + @Override + public IRegion getLineInformationOfOffset(int offset) throws BadLocationException { + return null; + } + + @Override + public int getNumberOfLines() { + return content.split("\n").length; + } + + @Override + public int getNumberOfLines(int offset, int length) throws BadLocationException { + String subString = content.substring(offset, offset + length); + return subString.split("\n").length; + } + + @Override + public int computeNumberOfLines(String text) { + return 0; + } + + @Override + public String[] getLegalLineDelimiters() { + return null; + } + + @Override + public String getLineDelimiter(int line) throws BadLocationException { + return "\n"; + } + + @Override + public int search(int startOffset, String findString, boolean forwardSearch, boolean caseSensitive, + boolean wholeWord) throws BadLocationException { + return 0; + } + +} diff --git a/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestFile.java b/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestFile.java new file mode 100644 index 0000000..191c4d0 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestFile.java @@ -0,0 +1,560 @@ +package com.chabicht.code_intelligence.chat.tools; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.apache.commons.io.IOUtils; +import org.eclipse.core.resources.IContainer; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFileState; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IPathVariableManager; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IProjectDescription; +import org.eclipse.core.resources.IResourceProxy; +import org.eclipse.core.resources.IResourceProxyVisitor; +import org.eclipse.core.resources.IResourceVisitor; +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.ResourceAttributes; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.Path; +import org.eclipse.core.runtime.QualifiedName; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.content.IContentDescription; +import org.eclipse.core.runtime.jobs.ISchedulingRule; + +public class TestFile implements IFile { + + final String name; + final String content; + + public TestFile(String name, String content) { + this.name = name; + this.content = content; + } + + @Override + public void accept(IResourceProxyVisitor visitor, int memberFlags) throws CoreException { + } + + @Override + public void accept(IResourceProxyVisitor visitor, int depth, int memberFlags) throws CoreException { + } + + @Override + public void accept(IResourceVisitor visitor) throws CoreException { + } + + @Override + public void accept(IResourceVisitor visitor, int depth, boolean includePhantoms) throws CoreException { + + } + + @Override + public void accept(IResourceVisitor visitor, int depth, int memberFlags) throws CoreException { + + } + + @Override + public void clearHistory(IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void copy(IPath destination, boolean force, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void copy(IPath destination, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void copy(IProjectDescription description, boolean force, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void copy(IProjectDescription description, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public IMarker createMarker(String type) throws CoreException { + return null; + } + + @Override + public IResourceProxy createProxy() { + return null; + } + + @Override + public void delete(boolean force, IProgressMonitor monitor) throws CoreException { + } + + @Override + public void delete(int updateFlags, IProgressMonitor monitor) throws CoreException { + } + + @Override + public void deleteMarkers(String type, boolean includeSubtypes, int depth) throws CoreException { + } + + @Override + public boolean exists() { + return true; + } + + @Override + public IMarker findMarker(long id) throws CoreException { + return null; + } + + @Override + public IMarker[] findMarkers(String type, boolean includeSubtypes, int depth) throws CoreException { + return null; + } + + @Override + public int findMaxProblemSeverity(String type, boolean includeSubtypes, int depth) throws CoreException { + return 0; + } + + @Override + public String getFileExtension() { + return "java"; + } + + @Override + public long getLocalTimeStamp() { + + return 0; + } + + @Override + public IPath getLocation() { + return new Path("/"); + } + + @Override + public URI getLocationURI() { + return null; + } + + @Override + public IMarker getMarker(long id) { + return null; + } + + @Override + public long getModificationStamp() { + + return 0; + } + + @Override + public IPathVariableManager getPathVariableManager() { + + return null; + } + + @Override + public IContainer getParent() { + + return null; + } + + @Override + public Map getPersistentProperties() throws CoreException { + + return null; + } + + @Override + public String getPersistentProperty(QualifiedName key) throws CoreException { + + return null; + } + + @Override + public IProject getProject() { + + return null; + } + + @Override + public IPath getProjectRelativePath() { + + return null; + } + + @Override + public IPath getRawLocation() { + + return null; + } + + @Override + public URI getRawLocationURI() { + + return null; + } + + @Override + public ResourceAttributes getResourceAttributes() { + + return null; + } + + @Override + public Map getSessionProperties() throws CoreException { + + return null; + } + + @Override + public Object getSessionProperty(QualifiedName key) throws CoreException { + + return null; + } + + @Override + public int getType() { + + return 0; + } + + @Override + public IWorkspace getWorkspace() { + + return null; + } + + @Override + public boolean isAccessible() { + + return false; + } + + @Override + public boolean isDerived() { + + return false; + } + + @Override + public boolean isDerived(int options) { + + return false; + } + + @Override + public boolean isHidden() { + + return false; + } + + @Override + public boolean isHidden(int options) { + + return false; + } + + @Override + public boolean isLinked() { + + return false; + } + + @Override + public boolean isVirtual() { + + return true; + } + + @Override + public boolean isLinked(int options) { + + return false; + } + + @Override + public boolean isLocal(int depth) { + + return false; + } + + @Override + public boolean isPhantom() { + + return false; + } + + @Override + public boolean isSynchronized(int depth) { + + return false; + } + + @Override + public boolean isTeamPrivateMember() { + + return false; + } + + @Override + public boolean isTeamPrivateMember(int options) { + + return false; + } + + @Override + public void move(IPath destination, boolean force, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void move(IPath destination, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void move(IProjectDescription description, boolean force, boolean keepHistory, IProgressMonitor monitor) + throws CoreException { + + } + + @Override + public void move(IProjectDescription description, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void refreshLocal(int depth, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void revertModificationStamp(long value) throws CoreException { + + } + + @Override + public void setDerived(boolean isDerived) throws CoreException { + + } + + @Override + public void setDerived(boolean isDerived, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void setHidden(boolean isHidden) throws CoreException { + + } + + @Override + public void setLocal(boolean flag, int depth, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public long setLocalTimeStamp(long value) throws CoreException { + + return 0; + } + + @Override + public void setPersistentProperty(QualifiedName key, String value) throws CoreException { + + } + + @Override + public void setReadOnly(boolean readOnly) { + + } + + @Override + public void setResourceAttributes(ResourceAttributes attributes) throws CoreException { + + } + + @Override + public void setSessionProperty(QualifiedName key, Object value) throws CoreException { + + } + + @Override + public void setTeamPrivateMember(boolean isTeamPrivate) throws CoreException { + + } + + @Override + public void touch(IProgressMonitor monitor) throws CoreException { + + } + + @Override + public T getAdapter(Class adapter) { + return null; + } + + @Override + public boolean contains(ISchedulingRule rule) { + return false; + } + + @Override + public boolean isConflicting(ISchedulingRule rule) { + + return false; + } + + @Override + public void appendContents(InputStream source, boolean force, boolean keepHistory, IProgressMonitor monitor) + throws CoreException { + + } + + @Override + public void appendContents(InputStream source, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void create(InputStream source, boolean force, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void create(InputStream source, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void createLink(IPath localLocation, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void createLink(URI location, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void delete(boolean force, boolean keepHistory, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public String getCharset() throws CoreException { + + return null; + } + + @Override + public String getCharset(boolean checkImplicit) throws CoreException { + + return null; + } + + @Override + public String getCharsetFor(Reader reader) throws CoreException { + + return null; + } + + @Override + public IContentDescription getContentDescription() throws CoreException { + + return null; + } + + @Override + public InputStream getContents() throws CoreException { + return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public InputStream getContents(boolean force) throws CoreException { + return getContents(); + } + + @Override + public int getEncoding() throws CoreException { + return 0; + } + + @Override + public IPath getFullPath() { + return null; + } + + @Override + public IFileState[] getHistory(IProgressMonitor monitor) throws CoreException { + return null; + } + + @Override + public String getName() { + return name; + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public void move(IPath destination, boolean force, boolean keepHistory, IProgressMonitor monitor) + throws CoreException { + + } + + @Override + public void setCharset(String newCharset) throws CoreException { + + } + + @Override + public void setCharset(String newCharset, IProgressMonitor monitor) throws CoreException { + + } + + @Override + public void setContents(InputStream source, boolean force, boolean keepHistory, IProgressMonitor monitor) + throws CoreException { + + } + + @Override + public void setContents(IFileState source, boolean force, boolean keepHistory, IProgressMonitor monitor) + throws CoreException { + + } + + @Override + public void setContents(InputStream source, int updateFlags, IProgressMonitor monitor) throws CoreException { + try { + IOUtils.toString(source, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CoreException(Status.error(e.getMessage(), e)); + } + } + + @Override + public void setContents(IFileState source, int updateFlags, IProgressMonitor monitor) throws CoreException { + + } + +} diff --git a/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestResourceAccess.java b/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestResourceAccess.java new file mode 100644 index 0000000..8347a19 --- /dev/null +++ b/com.chabicht.code-intelligence.tests/src/com/chabicht/code_intelligence/chat/tools/TestResourceAccess.java @@ -0,0 +1,91 @@ +package com.chabicht.code_intelligence.chat.tools; + +import java.util.Map; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; + +public class TestResourceAccess implements IResourceAccess { + + private Map files; + + public TestResourceAccess(Map files) { + this.files = files; + } + + @Override + public IFile findFileByNameBestEffort(String fileName) { + if (files.containsKey(fileName)) { + return new TestFile(fileName, files.get(fileName)); + } + + return null; + } + + @Override + public IDocument getDocumentAndConnect(IFile file, Map documentMap) { + String fileName = file.getName(); + if (files.containsKey(fileName)) { + return new TestDocument(fileName, files.get(fileName)); + } + + return null; + } + + @Override + public void disconnectAllDocuments(Map documentMap) { + } + + @Override + public CreateFileResult createFileInWorkspace(String filePath, String content) { + if (files.containsKey(filePath)) { + return CreateFileResult.failure("File already exists: " + filePath, filePath); + } + // Simulate file creation for testing purposes + files.put(filePath, content); + return new CreateFileResult(true, "File created successfully (test): " + filePath, filePath); + } + + @Override + public IFileHandle findFileHandleByName(String fileName) { + // Check if file exists in our test data + if (files.containsKey(fileName)) { + // For testing, create a test file and wrap it in RealFileHandle + TestFile testFile = new TestFile(fileName, files.get(fileName)); + return new RealFileHandle(testFile); + } + return null; + } + + @Override + public IDocument getDocumentForHandle(IFileHandle handle, Map documentMap) { + if (handle == null) { + throw new IllegalArgumentException("File handle cannot be null"); + } + + if (handle.isVirtual()) { + // Handle virtual files - get content directly + VirtualFileHandle virtualHandle = (VirtualFileHandle) handle; + IDocument doc = new Document(virtualHandle.getContent()); + documentMap.put(handle, doc); + return doc; + } else { + // Handle real files - delegate to existing logic + String fileName = handle.getName(); + if (files.containsKey(fileName)) { + IDocument doc = new TestDocument(fileName, files.get(fileName)); + documentMap.put(handle, doc); + return doc; + } + return null; + } + } + + @Override + public IProject[] getProjects() { + // Return an empty array for tests, as it's the simplest valid implementation. + return new IProject[0]; + } +} diff --git a/com.chabicht.code-intelligence/.classpath b/com.chabicht.code-intelligence/.classpath index ab3076b..4787867 100644 --- a/com.chabicht.code-intelligence/.classpath +++ b/com.chabicht.code-intelligence/.classpath @@ -1,5 +1,7 @@ + + diff --git a/com.chabicht.code-intelligence/META-INF/MANIFEST.MF b/com.chabicht.code-intelligence/META-INF/MANIFEST.MF index 567215f..1b4b2f8 100644 --- a/com.chabicht.code-intelligence/META-INF/MANIFEST.MF +++ b/com.chabicht.code-intelligence/META-INF/MANIFEST.MF @@ -8,7 +8,9 @@ Bundle-ClassPath: lib/commonmark-0.24.0.jar, ., lib/jmustache-1.16.jar, lib/commonmark-ext-gfm-tables-0.24.0.jar, - lib/commonmark-ext-gfm-strikethrough-0.24.0.jar + lib/commonmark-ext-gfm-strikethrough-0.24.0.jar, + lib/commons-text-1.13.1.jar, + lib/java-diff-utils-4.16-SNAPSHOT.jar Bundle-Vendor: chabicht.com Require-Bundle: org.eclipse.ui, org.eclipse.core.runtime, @@ -30,7 +32,11 @@ Require-Bundle: org.eclipse.ui, org.apache.commons.commons-beanutils;bundle-version="1.9.4", org.apache.commons.commons-io;bundle-version="2.17.0", org.eclipse.core.expressions;bundle-version="3.9.100", - org.eclipse.ui.console;bundle-version="3.13.0" + org.eclipse.ui.console;bundle-version="3.13.0", + org.eclipse.compare;bundle-version="3.9.0", + org.eclipse.core.filebuffers;bundle-version="3.8.100", + org.eclipse.ltk.core.refactoring;bundle-version="3.14.100", + org.eclipse.ltk.ui.refactoring;bundle-version="3.13.100" Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.chabicht.code.intelligence Bundle-ActivationPolicy: lazy diff --git a/com.chabicht.code-intelligence/build.properties b/com.chabicht.code-intelligence/build.properties index 65541d5..b19f295 100644 --- a/com.chabicht.code-intelligence/build.properties +++ b/com.chabicht.code-intelligence/build.properties @@ -7,4 +7,15 @@ bin.includes = plugin.xml,\ lib/commonmark-0.24.0.jar,\ lib/jmustache-1.16.jar,\ lib/commonmark-ext-gfm-tables-0.24.0.jar,\ - lib/commonmark-ext-gfm-strikethrough-0.24.0.jar + lib/commonmark-ext-gfm-strikethrough-0.24.0.jar,\ + lib/commons-text-1.13.1.jar,\ + lib/java-diff-utils-4.16-SNAPSHOT.jar +src.includes = .classpath,\ + .project,\ + .settings/,\ + META-INF/,\ + build.properties,\ + icons/,\ + lib/,\ + plugin.xml,\ + src/ diff --git a/com.chabicht.code-intelligence/lib/commons-text-1.13.1.jar b/com.chabicht.code-intelligence/lib/commons-text-1.13.1.jar new file mode 100644 index 0000000..ddba18d Binary files /dev/null and b/com.chabicht.code-intelligence/lib/commons-text-1.13.1.jar differ diff --git a/com.chabicht.code-intelligence/lib/java-diff-utils-4.16-SNAPSHOT.jar b/com.chabicht.code-intelligence/lib/java-diff-utils-4.16-SNAPSHOT.jar new file mode 100644 index 0000000..91efe40 Binary files /dev/null and b/com.chabicht.code-intelligence/lib/java-diff-utils-4.16-SNAPSHOT.jar differ diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/Activator.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/Activator.java index bc7c570..2ae013e 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/Activator.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/Activator.java @@ -9,7 +9,6 @@ import java.io.FileWriter; import java.io.IOException; import java.lang.reflect.Type; -import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -30,9 +29,9 @@ import com.chabicht.code_intelligence.model.ChatHistoryEntry; import com.chabicht.code_intelligence.model.PromptTemplate; import com.chabicht.code_intelligence.model.ProviderDefaults; +import com.chabicht.code_intelligence.util.GsonUtil; import com.chabicht.codeintelligence.preferences.PreferenceConstants; import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.google.gson.JsonIOException; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; @@ -97,10 +96,24 @@ public static void logError(String message, Throwable exception) { /** * Log an error to the Eclipse Error Log. */ + public static void logError(String message) { + getDefault().getLog().log(new Status(IStatus.ERROR, PLUGIN_ID, message)); + } + + /** + * Log a message to the Eclipse Error Log. + */ public static void logInfo(String message) { getDefault().getLog().log(new Status(IStatus.INFO, PLUGIN_ID, message)); } + /** + * Log a warning to the Eclipse Error Log. + */ + public static void logWarn(String message) { + getDefault().getLog().log(new Status(IStatus.WARNING, PLUGIN_ID, message)); + } + @SuppressWarnings({ "unchecked", "rawtypes" }) public List loadApiConnections() { TypeToken typeToken = new TypeToken>() { @@ -217,7 +230,7 @@ private List readFile(String filename, TypeToken> token) { if (!file.exists()) { return Collections.emptyList(); } else { - try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + try (BufferedReader reader = new BufferedReader(new FileReader(file), 1 << 18)) { Type listType = token.getType(); List res = createGson().fromJson(reader, listType); @@ -236,7 +249,7 @@ private void writeFile(String filename, List items) { File parentDirectory = getConfigLocationAsFile(); File file = new File(parentDirectory, filename); - try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { + try (BufferedWriter writer = new BufferedWriter(new FileWriter(file), 1 << 18)) { createGson().toJson(items, writer); } } catch (IOException | JsonIOException e) { @@ -245,7 +258,7 @@ private void writeFile(String filename, List items) { } public Gson createGson() { - return new GsonBuilder().registerTypeAdapter(Instant.class, new InstantTypeAdapter()).create(); + return GsonUtil.createGson(); } private File getConfigLocationAsFile() throws IOException { diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/OptionalTypeAdapter.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/OptionalTypeAdapter.java new file mode 100644 index 0000000..233c3ef --- /dev/null +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/OptionalTypeAdapter.java @@ -0,0 +1,49 @@ +package com.chabicht.code_intelligence; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Optional; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +public class OptionalTypeAdapter implements JsonSerializer>, JsonDeserializer> { + + @Override + public JsonElement serialize(Optional src, Type typeOfSrc, JsonSerializationContext context) { + if (src.isPresent()) { + // Get the type of the value inside the Optional + Type valueType = ((ParameterizedType) typeOfSrc).getActualTypeArguments()[0]; + // Delegate serialization to Gson for the actual value + return context.serialize(src.get(), valueType); + } else { + // Serialize empty Optional as JSON null + return JsonNull.INSTANCE; + } + } + + @Override + public Optional deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + if (json.isJsonNull() || isEmptyObject(json)) { + // Deserialize JSON null as empty Optional + return Optional.empty(); + } else { + // Get the type of the value inside the Optional + Type valueType = ((ParameterizedType) typeOfT).getActualTypeArguments()[0]; + // Delegate deserialization to Gson for the actual value + Object value = context.deserialize(json, valueType); + // Wrap the deserialized value in an Optional + return Optional.ofNullable(value); + } + } + + private boolean isEmptyObject(JsonElement json) { + return json.isJsonObject() && json.getAsJsonObject().isEmpty(); + } +} diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AbstractApiClient.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AbstractApiClient.java index 53f3c11..5f1edab 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AbstractApiClient.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AbstractApiClient.java @@ -1,11 +1,13 @@ package com.chabicht.code_intelligence.apiclient; import java.util.Map; +import java.util.Map.Entry; import com.chabicht.code_intelligence.Activator; import com.chabicht.code_intelligence.CustomConfigurationParameters; import com.chabicht.code_intelligence.model.PromptType; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; public class AbstractApiClient { @@ -42,4 +44,14 @@ protected void setPropertyIfNotPresent(JsonObject object, String propertyName, N } } + protected void patchMissingProperties(JsonObject target, JsonObject patch) { + for (Entry e : patch.entrySet()) { + if (!target.has(e.getKey())) { + target.add(e.getKey(), e.getValue()); + } else if (target.get(e.getKey()).isJsonObject() && e.getValue().isJsonObject()) { + patchMissingProperties(target.get(e.getKey()).getAsJsonObject(), e.getValue().getAsJsonObject()); + } + } + } + } \ No newline at end of file diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AiApiConnection.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AiApiConnection.java index ba245e7..8bac466 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AiApiConnection.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AiApiConnection.java @@ -42,6 +42,7 @@ public String getApiKeyUri() { private String baseUri; private String apiKey; private boolean enabled; + private boolean legacyFormat; private transient IAiApiClient apiClient; @@ -89,6 +90,15 @@ public void setEnabled(boolean enabled) { propertyChangeSupport.firePropertyChange("enabled", this.enabled, this.enabled = enabled); } + public boolean isLegacyFormat() { + return legacyFormat; + } + + public void setLegacyFormat(boolean openAiLegacyApi) { + propertyChangeSupport.firePropertyChange("legacyFormat", this.legacyFormat, + this.legacyFormat = openAiLegacyApi); + } + public IAiApiClient getApiClient() { return getApiClient(false); } diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AnthropicApiClient.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AnthropicApiClient.java index 448f65e..559917c 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AnthropicApiClient.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/AnthropicApiClient.java @@ -2,6 +2,7 @@ import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.REASONING_BUDGET_TOKENS; import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.REASONING_ENABLED; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.TOOLS_ENABLED; import java.io.IOException; import java.net.URI; @@ -20,8 +21,12 @@ import org.apache.commons.lang3.StringUtils; import com.chabicht.code_intelligence.Activator; +import com.chabicht.code_intelligence.chat.tools.ToolDefinitions; import com.chabicht.code_intelligence.model.ChatConversation; import com.chabicht.code_intelligence.model.ChatConversation.ChatOption; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionCall; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionResult; +import com.chabicht.code_intelligence.model.ChatConversation.MessageContext; import com.chabicht.code_intelligence.model.ChatConversation.Role; import com.chabicht.code_intelligence.model.CompletionPrompt; import com.chabicht.code_intelligence.model.CompletionResult; @@ -106,6 +111,9 @@ public CompletionResult performCompletion(String modelName, CompletionPrompt com req.add("messages", messages); JsonObject res = performPost(JsonObject.class, "messages", req); + if (res.has("usage")) { + logApiUsage(modelName, res.getAsJsonObject("usage"), "completion"); + } return new CompletionResult( res.get("content").getAsJsonArray().get(0).getAsJsonObject().get("text").getAsString()); } @@ -116,31 +124,23 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse req.addProperty("model", modelName); req.addProperty("stream", true); - JsonArray messages = new JsonArray(); + Map options = chat.getOptions(); + if (options.containsKey(TOOLS_ENABLED) && Boolean.TRUE.equals(options.get(TOOLS_ENABLED))) { + patchMissingProperties(req, ToolDefinitions.getInstance().getToolDefinitionsAnthropic()); + } + + // Add system prompt if present for (ChatConversation.ChatMessage msg : chat.getMessages()) { if (Role.SYSTEM.equals(msg.getRole())) { req.add("system", new JsonPrimitive(msg.getContent())); - continue; - } - - JsonObject jsonMsg = new JsonObject(); - jsonMsg.addProperty("role", msg.getRole().toString().toLowerCase()); - - StringBuilder content = new StringBuilder(256); - if (!msg.getContext().isEmpty()) { - content.append("Context information:\n\n"); + break; } - for (ChatConversation.MessageContext ctx : msg.getContext()) { - content.append(ctx.compile()); - content.append("\n"); - } - content.append(msg.getContent()); - jsonMsg.addProperty("content", content.toString()); - messages.add(jsonMsg); } - req.add("messages", messages); - Map options = chat.getOptions(); + // Add messages array + req.add("messages", createMessagesArray(chat)); + + // Set max tokens if (options.containsKey(REASONING_ENABLED) && Boolean.TRUE.equals(options.get(REASONING_ENABLED))) { int reasoningBudgetTokens = (int) options.get(REASONING_BUDGET_TOKENS); req.addProperty("max_tokens", maxResponseTokens + reasoningBudgetTokens); @@ -152,10 +152,12 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse req.addProperty("max_tokens", maxResponseTokens); } + // Create assistant message that will be populated with the response ChatConversation.ChatMessage assistantMessage = new ChatConversation.ChatMessage( ChatConversation.Role.ASSISTANT, ""); chat.addMessage(assistantMessage, true); + // Build request and initiate streaming String requestBody = gson.toJson(req); HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(5)).followRedirects(Redirect.ALWAYS).build(); @@ -165,11 +167,9 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse .POST(HttpRequest.BodyPublishers.ofString(requestBody)).header("x-api-key", apiConnection.getApiKey()) .header("Content-Type", "application/json").header("anthropic-version", ANTHROPIC_VERSION); - if (StringUtils.isNotBlank(apiConnection.getApiKey())) { - requestBuilder.header("x-api-key", apiConnection.getApiKey()); - } - HttpRequest request = requestBuilder.build(); + AtomicBoolean responseFinished = new AtomicBoolean(false); + AtomicReference currentToolUse = new AtomicReference<>(null); asyncRequest = client.sendAsync(request, HttpResponse.BodyHandlers.ofLines()).thenAccept(response -> { try { @@ -190,69 +190,308 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse try { JsonObject jsonResponse = JsonParser.parseString(data).getAsJsonObject(); - switch (currentEvent.get()) { - case "content_block_delta": - JsonObject delta = jsonResponse.getAsJsonObject("delta"); - String deltaType = delta.get("type").getAsString(); + if (jsonResponse.has("usage")) { + logApiUsage(modelName, jsonResponse.getAsJsonObject("usage"), "chat"); + } else if (jsonResponse.has("message")) { + JsonObject message = jsonResponse.get("message").getAsJsonObject(); + if (message.has("usage")) { + logApiUsage(modelName, message.getAsJsonObject("usage"), "chat"); + } + } - if (deltaType.equals("text_delta")) { - if (thinkingStarted.get()) { - assistantMessage.setContent(assistantMessage.getContent() + "\n\n"); - thinkingStarted.set(false); + // Extract the event type from the JSON response + if (jsonResponse.has("type")) { + String eventType = jsonResponse.get("type").getAsString(); + currentEvent.set(eventType); + + switch (eventType) { + case "content_block_delta": + if (jsonResponse.has("delta")) { + JsonObject delta = jsonResponse.getAsJsonObject("delta"); + String deltaType = delta.get("type").getAsString(); + + if (deltaType.equals("text_delta")) { + if (thinkingStarted.get()) { + assistantMessage + .setContent(assistantMessage.getContent() + "\n\n"); + thinkingStarted.set(false); + } + String text = delta.get("text").getAsString(); + assistantMessage.setContent(assistantMessage.getContent() + text); + chat.notifyMessageUpdated(assistantMessage); + } else if (deltaType.equals("thinking_delta")) { + if (!thinkingStarted.get()) { + assistantMessage + .setContent(assistantMessage.getContent() + "\n\n"); + thinkingStarted.set(true); + } + // Handle thinking delta + String thinking = delta.get("thinking").getAsString(); + assistantMessage.setContent(assistantMessage.getContent() + thinking); + assistantMessage.setThinkingContent( + (assistantMessage.getThinkingContent() == null ? "" + : assistantMessage.getThinkingContent()) + thinking); + chat.notifyMessageUpdated(assistantMessage); + } else if (deltaType.equals("input_json_delta")) { + // Accumulate tool input JSON + if (jsonResponse.has("index")) { + int index = jsonResponse.get("index").getAsInt(); + ToolUseInfo toolUse = currentToolUse.get(); + if (toolUse != null) { + String partialJson = delta.get("partial_json").getAsString(); + toolUse.addInputJson(partialJson); + } + } + } else if (deltaType.equals("signature_delta")) { + assistantMessage.getThinkingMetadata().put("signature", + delta.get("signature").getAsString()); + } + } + break; + + case "content_block_start": + if (jsonResponse.has("content_block")) { + JsonObject contentBlock = jsonResponse.getAsJsonObject("content_block"); + String blockType = contentBlock.get("type").getAsString(); + + if (blockType.equals("tool_use")) { + // Store the tool use information for later when we receive + // input_json_delta events + String id = contentBlock.get("id").getAsString(); + String name = contentBlock.get("name").getAsString(); + currentToolUse.set(new ToolUseInfo(id, name)); + } else if (blockType.equals("thinking")) { + if (!thinkingStarted.get()) { + assistantMessage + .setContent(assistantMessage.getContent() + "\n\n"); + thinkingStarted.set(true); + } + } } - String text = delta.get("text").getAsString(); - assistantMessage.setContent(assistantMessage.getContent() + text); - chat.notifyMessageUpdated(assistantMessage); - } else if (deltaType.equals("thinking_delta")) { - if (!thinkingStarted.get()) { - assistantMessage.setContent(assistantMessage.getContent() + "\n\n"); - thinkingStarted.set(true); + break; + + case "content_block_stop": + if (jsonResponse.has("index")) { + int index = jsonResponse.get("index").getAsInt(); + + // If we have a completed tool use + ToolUseInfo toolUse = currentToolUse.get(); + if (toolUse != null && toolUse.isComplete()) { + JsonObject input = JsonParser.parseString(toolUse.getCompleteJson()) + .getAsJsonObject(); + String argsJson = gson.toJson(input); + + assistantMessage.setFunctionCall( + new FunctionCall(toolUse.getId(), toolUse.getName(), argsJson)); + chat.notifyFunctionCalled(assistantMessage); + currentToolUse.set(null); + } + } + break; + + case "message_delta": + if (jsonResponse.has("delta")) { + JsonObject delta = jsonResponse.getAsJsonObject("delta"); + if (delta.has("stop_reason") + && "tool_use".equals(delta.get("stop_reason").getAsString())) { + if (!responseFinished.get()) { + chat.notifyChatResponseFinished(assistantMessage); + responseFinished.set(true); + } + } + } + break; + + case "message_stop": + // Handle end of message + if (!responseFinished.get()) { + chat.notifyChatResponseFinished(assistantMessage); + responseFinished.set(true); } - // Handle thinking delta - String thinking = delta.get("thinking").getAsString(); - assistantMessage.setContent(assistantMessage.getContent() + thinking); - chat.notifyMessageUpdated(assistantMessage); - } else if (deltaType.equals("signature_delta")) { - // Handle signature delta for thinking content - // This signals the end of a thinking block - // No additional action required here + break; + + case "message_start": + case "ping": + // These events can be handled if needed + break; + + default: + Activator.logError("Unknown event type in stream: " + eventType, null); } - break; - - case "message_stop": - // Handle end of message - break; - - case "content_block_start": - case "content_block_stop": - case "message_start": - case "message_delta": - case "ping": - // These events can be handled if needed - break; - - default: - Activator.logError("Unknown event type in stream: " + currentEvent, null); } } catch (JsonSyntaxException e) { Activator.logError("Error parsing stream chunk: " + data, e); } + } }); } else { Activator.logError("Streaming chat failed with status: " + response.statusCode() + "\n" - + response.body().collect(Collectors.joining()), null); + + response.body().collect(Collectors.joining()) + "\n\nRequest JSON:\n" + requestBody, + null); } } finally { - chat.notifyChatResponseFinished(assistantMessage); + if (!responseFinished.get()) { + chat.notifyChatResponseFinished(assistantMessage); + } asyncRequest = null; } }).exceptionally(e -> { - Activator.logError("Exception during streaming chat", e); - return null; + Activator.logError("Exception during streaming chat", e); + if (!responseFinished.get()) { + chat.notifyChatResponseFinished(assistantMessage); + } + asyncRequest = null; + return null; }); } + /** + * @param chat + * @return + */ + private JsonArray createMessagesArray(ChatConversation chat) { + JsonArray messagesJson = new JsonArray(); + + for (ChatConversation.ChatMessage msg : chat.getMessages()) { + // Skip system messages, they're handled separately + if (Role.SYSTEM.equals(msg.getRole())) { + continue; + } + // Skip TOOL_SUMMARY messages, they are for internal use only + if (Role.TOOL_SUMMARY.equals(msg.getRole())) { + continue; + } + + // Build message text with context + StringBuilder contentBuilder = new StringBuilder(); + if (!msg.getContext().isEmpty()) { + contentBuilder.append("Context information:\n\n"); + for (MessageContext ctx : msg.getContext()) { + contentBuilder.append(ctx.compile(true)); + contentBuilder.append("\n"); + } + } + contentBuilder.append(msg.getContent()); + + String messageContent = contentBuilder.toString(); + + // Skip if both message content and thinking content are blank + if (StringUtils.isBlank(messageContent) && StringUtils.isBlank(msg.getThinkingContent())) { + continue; + } + + // Create the message JSON object + JsonObject jsonMsg = new JsonObject(); + jsonMsg.addProperty("role", msg.getRole().toString().toLowerCase()); + + JsonArray contentArray = new JsonArray(); + + // For assistant messages, add thinking content first if available + if (Role.ASSISTANT.equals(msg.getRole()) && StringUtils.isNotBlank(msg.getThinkingContent())) { + JsonObject thinkingContent = new JsonObject(); + thinkingContent.addProperty("type", "thinking"); + thinkingContent.addProperty("thinking", msg.getThinkingContent()); + + // Add signature field required by Anthropic API + // If the field is missing we assume the user switched models. In that case we + // can't send the thoughts "back" to the API. + Object signature = msg.getThinkingMetadata().get("signature"); + if (signature != null) { + thinkingContent.addProperty("signature", (String) signature); + + contentArray.add(thinkingContent); + } + } + + // Add text content block if message content is not blank + if (StringUtils.isNotBlank(messageContent)) { + JsonObject textContent = new JsonObject(); + textContent.addProperty("type", "text"); + textContent.addProperty("text", messageContent); + contentArray.add(textContent); + } + + // If this is an assistant message with a function call, add a tool_use content + // block + if (Role.ASSISTANT.equals(msg.getRole()) && msg.getFunctionCall().isPresent()) { + FunctionCall fc = msg.getFunctionCall().get(); + + JsonObject toolUseBlock = new JsonObject(); + toolUseBlock.addProperty("type", "tool_use"); + toolUseBlock.addProperty("id", fc.getId()); + toolUseBlock.addProperty("name", fc.getFunctionName()); + + // Parse the argsJson back to a JsonObject + JsonObject inputObject = JsonParser.parseString(fc.getArgsJson()).getAsJsonObject(); + toolUseBlock.add("input", inputObject); + + contentArray.add(toolUseBlock); + } + + jsonMsg.add("content", contentArray); + messagesJson.add(jsonMsg); + + // Then, if there's a function result, add it as a separate user message + if (msg.getFunctionResult().isPresent()) { + FunctionResult fr = msg.getFunctionResult().get(); + + JsonObject resultMsg = new JsonObject(); + resultMsg.addProperty("role", "user"); + + JsonArray resultContentArray = new JsonArray(); + + // Add the tool result block + JsonObject toolResult = new JsonObject(); + toolResult.addProperty("type", "tool_result"); + toolResult.addProperty("tool_use_id", fr.getId()); + toolResult.addProperty("content", fr.getResultJson()); + resultContentArray.add(toolResult); + + resultMsg.add("content", resultContentArray); + messagesJson.add(resultMsg); + } + } + + // Cache conversation with default TTL (5 minutes). + if (messagesJson.size() > 0) { + JsonObject lastMessage = messagesJson.get(messagesJson.size() - 1).getAsJsonObject(); + JsonArray contentArray = lastMessage.get("content").getAsJsonArray(); + JsonObject content = contentArray.get(0).getAsJsonObject(); + JsonObject cacheControlObj = new JsonObject(); + cacheControlObj.addProperty("type", "ephemeral"); + content.add("cache_control", cacheControlObj); + } + + return messagesJson; + } + + /** + * Helper method to add a property to a JsonObject based on its type. + * + * @param jsonObject The JsonObject to add the property to + * @param key The property key + * @param value The property value + */ + private void addJsonProperty(JsonObject jsonObject, String key, Object value) { + if (value == null) { + jsonObject.add(key, null); + } else if (value instanceof String) { + jsonObject.addProperty(key, (String) value); + } else if (value instanceof Number) { + jsonObject.addProperty(key, (Number) value); + } else if (value instanceof Boolean) { + jsonObject.addProperty(key, (Boolean) value); + } else if (value instanceof Character) { + jsonObject.addProperty(key, (Character) value); + } else if (value instanceof JsonElement) { + jsonObject.add(key, (JsonElement) value); + } else { + // For other types, convert to string + jsonObject.addProperty(key, value.toString()); + } + } + @SuppressWarnings("unchecked") T performPost(Class clazz, String relPath, U requestBody) { int statusCode = -1; @@ -314,6 +553,9 @@ public String caption(String modelName, String content) { req.add("messages", messages); JsonObject res = performPost(JsonObject.class, "messages", req); + if (res.has("usage")) { + logApiUsage(modelName, res.getAsJsonObject("usage"), "caption"); + } return res.get("content").getAsJsonArray().get(0).getAsJsonObject().get("text").getAsString(); } @@ -329,4 +571,59 @@ public synchronized void abortChat() { asyncRequest = null; } } + + private void logApiUsage(String modelName, JsonObject usage, String apiCallType) { +// int inputTokens = 0; +// if (usage.has("input_tokens") && !usage.get("input_tokens").isJsonNull()) { +// inputTokens = usage.get("input_tokens").getAsInt(); +// } +// int outputTokens = 0; +// if (usage.has("output_tokens") && !usage.get("output_tokens").isJsonNull()) { +// outputTokens = usage.get("output_tokens").getAsInt(); +// } +// int cacheReadTokens = 0; +// if (usage.has("cache_read_input_tokens") && !usage.get("cache_read_input_tokens").isJsonNull()) { +// cacheReadTokens = usage.get("cache_read_input_tokens").getAsInt(); +// } +// int cacheCreationTokens = 0; +// if (usage.has("cache_creation_input_tokens") && !usage.get("cache_creation_input_tokens").isJsonNull()) { +// cacheCreationTokens = usage.get("cache_creation_input_tokens").getAsInt(); +// } +// String logMessage = String.format( +// "Anthropic API usage for %s (model: %s): input_tokens=%d, output_tokens=%d, cache_read_tokens=%d, cache_creation_tokens=%d", +// apiCallType, modelName, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens); +// Log.logInfo(logMessage); + } + + private static class ToolUseInfo { + private final String id; + private final String name; + private final StringBuilder inputJson = new StringBuilder(); + + public ToolUseInfo(String id, String name) { + this.id = id; + this.name = name; + } + + public void addInputJson(String partialJson) { + inputJson.append(partialJson); + } + + public boolean isComplete() { + String json = inputJson.toString(); + return json.startsWith("{") && json.endsWith("}"); + } + + public String getCompleteJson() { + return inputJson.toString(); + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + } } \ No newline at end of file diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/GeminiApiClient.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/GeminiApiClient.java index aa48ff9..9f29f1e 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/GeminiApiClient.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/GeminiApiClient.java @@ -1,19 +1,30 @@ package com.chabicht.code_intelligence.apiclient; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.REASONING_BUDGET_TOKENS; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.REASONING_ENABLED; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.TOOLS_ENABLED; + import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import com.chabicht.code_intelligence.Activator; +import com.chabicht.code_intelligence.chat.tools.ToolDefinitions; import com.chabicht.code_intelligence.model.ChatConversation; import com.chabicht.code_intelligence.model.ChatConversation.ChatMessage; +import com.chabicht.code_intelligence.model.ChatConversation.ChatOption; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionCall; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionResult; import com.chabicht.code_intelligence.model.ChatConversation.MessageContext; import com.chabicht.code_intelligence.model.ChatConversation.Role; import com.chabicht.code_intelligence.model.CompletionPrompt; @@ -64,6 +75,12 @@ public CompletionResult performCompletion(String modelName, CompletionPrompt com @Override public void performChat(String modelName, ChatConversation chat, int maxResponseTokens) { JsonObject req = createFromPresets(PromptType.CHAT); + + Map options = chat.getOptions(); + if (options.containsKey(TOOLS_ENABLED) && Boolean.TRUE.equals(options.get(TOOLS_ENABLED))) { + patchMissingProperties(req, ToolDefinitions.getInstance().getToolDefinitionsGemini()); + } + String systemPrompt = getSystemPrompt(chat); if (StringUtils.isNoneBlank(systemPrompt)) { JsonObject systemInstruction = new JsonObject(); @@ -78,6 +95,20 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse setPropertyIfNotPresent(genConfig, "temperature", 0.1); genConfig.addProperty("maxOutputTokens", maxResponseTokens); + // Reasoning + JsonObject thinkingConfig = new JsonObject(); + if (options.containsKey(REASONING_ENABLED) && Boolean.TRUE.equals(options.get(REASONING_ENABLED))) { + int reasoningBudgetTokens = (int) options.get(REASONING_BUDGET_TOKENS); + + genConfig.addProperty("maxOutputTokens", maxResponseTokens + reasoningBudgetTokens); + + thinkingConfig.addProperty("includeThoughts", true); + thinkingConfig.addProperty("thinkingBudget", reasoningBudgetTokens); + } else { + thinkingConfig.addProperty("thinkingBudget", 0); + } + genConfig.add("thinkingConfig", thinkingConfig); + ChatConversation.ChatMessage assistantMessage = new ChatConversation.ChatMessage( ChatConversation.Role.ASSISTANT, ""); chat.addMessage(assistantMessage, true); @@ -85,41 +116,111 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse String requestBody = gson.toJson(req); HttpRequest request = buildHttpRequest(modelName + ":streamGenerateContent?alt=sse&", requestBody); + AtomicBoolean responseFinished = new AtomicBoolean(false); + AtomicBoolean thinkingStarted = new AtomicBoolean(false); + asyncRequest = HttpClient.newHttpClient().sendAsync(request, HttpResponse.BodyHandlers.ofLines()) .thenAccept(response -> { - response.body().forEach(line -> { - if (line.startsWith("data: ")) { - String data = line.substring(6).trim(); - try { - JsonObject jsonChunk = JsonParser.parseString(data).getAsJsonObject(); - JsonArray candidates = jsonChunk.getAsJsonArray("candidates"); - if (!candidates.isEmpty()) { - JsonObject candidate = candidates.get(0).getAsJsonObject(); - - // Process content (if present) to update the message - if (candidate.has("content")) { - JsonObject content = candidate.get("content").getAsJsonObject(); - String chunk = content.get("parts").getAsJsonArray().get(0).getAsJsonObject() - .get("text").getAsString(); - assistantMessage.setContent(assistantMessage.getContent() + chunk); - chat.notifyMessageUpdated(assistantMessage); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + response.body().forEach(line -> { + if (line.startsWith("data: ")) { + String data = line.substring(6).trim(); + try { + JsonObject jsonChunk = JsonParser.parseString(data).getAsJsonObject(); + JsonArray candidates = jsonChunk.getAsJsonArray("candidates"); + if (candidates != null && !candidates.isEmpty()) { + JsonObject candidate = candidates.get(0).getAsJsonObject(); + JsonObject content = candidate.getAsJsonObject("content"); + + if (content != null && content.has("parts")) { + JsonArray parts = content.getAsJsonArray("parts"); + if (parts != null && !parts.isEmpty()) { + JsonObject firstPart = parts.get(0).getAsJsonObject(); + + if (firstPart.has("text")) { + String chunk = firstPart.get("text").getAsString(); + if (firstPart.has("thought") + && firstPart.get("thought").getAsBoolean()) { + if (!thinkingStarted.get()) { + assistantMessage.setContent( + assistantMessage.getContent() + "\n\n"); + thinkingStarted.set(true); + } + assistantMessage.setThinkingContent( + (assistantMessage.getThinkingContent() == null ? "" + : assistantMessage.getThinkingContent()) + + chunk); + } else if (!firstPart.has("thought") + || !firstPart.get("thought").getAsBoolean()) { + if (thinkingStarted.get()) { + assistantMessage.setContent( + assistantMessage.getContent() + "\n\n"); + thinkingStarted.set(false); + } + } + + assistantMessage.setContent(assistantMessage.getContent() + chunk); + chat.notifyMessageUpdated(assistantMessage); + } + + if (firstPart.has("functionCall")) { + JsonObject functionCall = firstPart.getAsJsonObject("functionCall"); + String id = Optional.ofNullable(functionCall.get("id")) + .map(JsonElement::getAsString).orElse(null); + String functionName = functionCall.get("name").getAsString(); + JsonObject functionArgs = functionCall.getAsJsonObject("args"); + String argsJson = (functionArgs != null) ? gson.toJson(functionArgs) + : "{}"; + assistantMessage.setFunctionCall( + new FunctionCall(id, functionName, argsJson)); + chat.notifyFunctionCalled(assistantMessage); + } + } + } + + if (candidate.has("finishReason")) { + String reason = candidate.get("finishReason").getAsString(); + if ("MALFORMED_FUNCTION_CALL".equals(reason)) { + Activator.logError("Error " + reason + " in API response.\n"); + } + chat.notifyChatResponseFinished(assistantMessage); + responseFinished.set(true); + asyncRequest = null; + } } - - // Check for finishReason and if it is "STOP", mark as finished. - if (candidate.has("finishReason") - && "STOP".equals(candidate.get("finishReason").getAsString())) { - chat.notifyChatResponseFinished(assistantMessage); + } catch (Exception e) { + Activator.logError("Exception processing streaming chat chunk: " + data, e); + if (asyncRequest != null) { + if (!responseFinished.get()) { + chat.notifyChatResponseFinished(assistantMessage); + responseFinished.set(true); + } asyncRequest = null; } } - } catch (Exception e) { - Activator.logError("Exception during streaming chat", e); - asyncRequest = null; } + }); + } else { + String body = response.body().collect(Collectors.joining("\n")); + Activator.logError("Error " + response.statusCode() + " in API call:\n" + body + + "\n\nRequest JSON:\n" + requestBody); + if (asyncRequest != null) { + if (!responseFinished.get()) { + chat.notifyChatResponseFinished(assistantMessage); + responseFinished.set(true); + } + asyncRequest = null; } - }); + } }).exceptionally(e -> { - Activator.logError("Exception during streaming chat", e); + Activator.logError("Exception during streaming chat request", e); + if (asyncRequest != null) { + if (!responseFinished.get()) { + chat.notifyChatResponseFinished(assistantMessage); + responseFinished.set(true); + } + asyncRequest = null; + } return null; }); } @@ -182,40 +283,85 @@ private JsonArray createChatContentsArray(ChatConversation chat) { if (Role.SYSTEM.equals(msg.getRole())) { continue; } + // Skip TOOL_SUMMARY messages, they are for internal use only + if (Role.TOOL_SUMMARY.equals(msg.getRole())) { + continue; + } - JsonObject jsonMsg = new JsonObject(); + JsonObject jsonMsg = createMessage(msg.getRole()); + + if (msg.getFunctionCall().isPresent()) { + fillFunctionCall(jsonMsg, msg); + } else { + fillTextMessage(jsonMsg, msg); + } + messagesJson.add(jsonMsg); - // Convert role to lowercase. If your API expects "model" for assistant - // messages, you might need to map it. - String role = msg.getRole().toString().toLowerCase(); - if ("assistant".equals(role)) { - // Some APIs expect the role to be "model" for responses. - role = "model"; + if (msg.getFunctionResult().isPresent()) { + JsonObject resultUserMessage = createMessage(Role.USER); + fillFunctionResult(resultUserMessage, msg); + messagesJson.add(resultUserMessage); } - jsonMsg.addProperty("role", role); - - // Build the full text content including any context information. - StringBuilder contentBuilder = new StringBuilder(); - if (!msg.getContext().isEmpty()) { - contentBuilder.append("Context information:\n\n"); - for (MessageContext ctx : msg.getContext()) { - contentBuilder.append(ctx.compile()); - contentBuilder.append("\n"); - } + } + return messagesJson; + } + + private void fillFunctionCall(JsonObject jsonMsg, ChatMessage msg) { + FunctionCall fc = msg.getFunctionCall().get(); + JsonObject functionCallObj = new JsonObject(); + functionCallObj.addProperty("id", fc.getId()); + functionCallObj.addProperty("name", fc.getFunctionName()); + functionCallObj.add("args", gson.fromJson(fc.getArgsJson(), JsonObject.class)); + JsonObject partObj = new JsonObject(); + partObj.add("functionCall", functionCallObj); + jsonMsg.getAsJsonArray("parts").add(partObj); + } + + private void fillFunctionResult(JsonObject jsonMsg, ChatMessage msg) { + FunctionResult fr = msg.getFunctionResult().get(); + JsonObject functionCallObj = new JsonObject(); + functionCallObj.addProperty("id", fr.getId()); + functionCallObj.addProperty("name", fr.getFunctionName()); + functionCallObj.add("response", gson.fromJson(fr.getResultJson(), JsonObject.class)); + JsonObject partObj = new JsonObject(); + partObj.add("functionResponse", functionCallObj); + jsonMsg.getAsJsonArray("parts").add(partObj); + } + + private void fillTextMessage(JsonObject jsonMsg, ChatConversation.ChatMessage msg) { + // Build the full text content including any context information. + StringBuilder contentBuilder = new StringBuilder(); + if (!msg.getContext().isEmpty()) { + contentBuilder.append("Context information:\n\n"); + for (MessageContext ctx : msg.getContext()) { + contentBuilder.append(ctx.compile(true)); + contentBuilder.append("\n"); } - contentBuilder.append(msg.getContent()); + } + contentBuilder.append(msg.getContent()); - // Instead of "content", create a "parts" array with an object containing - // "text". - JsonArray partsArray = new JsonArray(); - JsonObject partObj = new JsonObject(); - partObj.addProperty("text", contentBuilder.toString()); - partsArray.add(partObj); - jsonMsg.add("parts", partsArray); + JsonArray partsArray = jsonMsg.getAsJsonArray("parts"); + JsonObject partObj = new JsonObject(); + partObj.addProperty("text", contentBuilder.toString()); + partsArray.add(partObj); + } - messagesJson.add(jsonMsg); + private JsonObject createMessage(Role role) { + JsonObject jsonMsg = new JsonObject(); + + // Convert role to lowercase. If your API expects "model" for assistant + // messages, you might need to map it. + String roleStr = role.toString().toLowerCase(); + if ("assistant".equals(roleStr)) { + // Some APIs expect the role to be "model" for responses. + roleStr = "model"; } - return messagesJson; + jsonMsg.addProperty("role", roleStr); + + JsonArray partsArray = new JsonArray(); + jsonMsg.add("parts", partsArray); + + return jsonMsg; } private HttpRequest buildHttpRequest(String relPath, String body) { diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OllamaApiClient.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OllamaApiClient.java index 63cc6bd..0f47e8f 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OllamaApiClient.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OllamaApiClient.java @@ -1,5 +1,7 @@ package com.chabicht.code_intelligence.apiclient; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.TOOLS_ENABLED; + import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -9,13 +11,18 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.UUID; // Added import for UUID import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import com.chabicht.code_intelligence.Activator; +import com.chabicht.code_intelligence.chat.tools.ToolDefinitions; import com.chabicht.code_intelligence.model.ChatConversation; +import com.chabicht.code_intelligence.model.ChatConversation.ChatOption; import com.chabicht.code_intelligence.model.ChatConversation.MessageContext; import com.chabicht.code_intelligence.model.CompletionPrompt; import com.chabicht.code_intelligence.model.CompletionResult; @@ -76,10 +83,10 @@ private T performGet(Class clazz, String relPath) { @SuppressWarnings("unchecked") private T performPost(Class clazz, String relPath, - U requestBody) { + U requestBodyJson) { int statusCode = -1; String responseBody = "(nothing)"; - String requestBodyString = gson.toJson(requestBody); + String requestBodyString = gson.toJson(requestBodyJson); try { HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(5)).followRedirects(Redirect.ALWAYS).build(); @@ -127,7 +134,6 @@ public CompletionResult performCompletion(String modelName, CompletionPrompt com JsonObject res = performPost(JsonObject.class, "api/generate", req); return new CompletionResult(res.get("response").getAsString()); } - } /** @@ -147,20 +153,22 @@ public CompletionResult performCompletion(String modelName, CompletionPrompt com * onChunk callback. * * - * @param modelName the model to use (for example, "llama3.2") - * @param chat the ChatConversation object containing the conversation so - * far + * @param modelName the model to use (for example, "llama3.2") + * @param chat the ChatConversation object containing the + * conversation so far + * @param maxResponseTokens the maximum number of tokens for the response */ @Override public void performChat(String modelName, ChatConversation chat, int maxResponseTokens) { - // Build the JSON array of messages from the conversation. - // We assume the conversation ends with a user message. List messagesToSend = new ArrayList<>(chat.getMessages()); JsonArray messagesJson = new JsonArray(); for (ChatConversation.ChatMessage msg : messagesToSend) { - JsonObject jsonMsg = new JsonObject(); + // Skip TOOL_SUMMARY messages, they are for internal use only + if (ChatConversation.Role.TOOL_SUMMARY.equals(msg.getRole())) { + continue; + } - // Convert the role to lowercase (e.g. "system", "user", "assistant"). + JsonObject jsonMsg = new JsonObject(); jsonMsg.addProperty("role", msg.getRole().toString().toLowerCase()); StringBuilder content = new StringBuilder(256); @@ -168,91 +176,136 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse content.append("Context information:\n\n"); } for (MessageContext ctx : msg.getContext()) { - content.append(ctx.compile()); + content.append(ctx.compile(true)); content.append("\n"); } content.append(msg.getContent()); jsonMsg.addProperty("content", content.toString()); - messagesJson.add(jsonMsg); + + // NEW: Add role: "tool" message if the current message was an ASSISTANT + // message that had a FunctionCall and now has a FunctionResult. + if (msg.getRole() == ChatConversation.Role.ASSISTANT && msg.getFunctionCall().isPresent() + && msg.getFunctionResult().isPresent()) { + + ChatConversation.FunctionCall originalFc = msg.getFunctionCall().get(); + ChatConversation.FunctionResult fr = msg.getFunctionResult().get(); + + if (!originalFc.getId().equals(fr.getId())) { + Activator.logError("Mismatch between FunctionCall ID (" + originalFc.getId() + + ") and FunctionResult ID (" + fr.getId() + ") for function " + fr.getFunctionName(), + null); + } + + JsonObject toolResultMsgJson = new JsonObject(); + toolResultMsgJson.addProperty("role", "tool"); + toolResultMsgJson.addProperty("content", fr.getResultJson()); + toolResultMsgJson.addProperty("name", fr.getFunctionName()); + messagesJson.add(toolResultMsgJson); + } } - // Create the JSON request object for Ollama. JsonObject req = createFromPresets(PromptType.CHAT); req.addProperty("model", modelName); req.addProperty("stream", true); req.add("messages", messagesJson); + Map chatOptions = chat.getOptions(); + if (chatOptions.containsKey(TOOLS_ENABLED) && Boolean.TRUE.equals(chatOptions.get(TOOLS_ENABLED))) { + patchMissingProperties(req, ToolDefinitions.getInstance().getToolDefinitionsOllama()); + } + JsonObject options = getOrAddJsonObject(req, "options"); setPropertyIfNotPresent(options, NUM_CTX, DEFAULT_CONTEXT_SIZE); setPropertyIfNotPresent(options, "num_predict", maxResponseTokens); - // Add a new (empty) assistant message to the conversation. ChatConversation.ChatMessage assistantMessage = new ChatConversation.ChatMessage( ChatConversation.Role.ASSISTANT, ""); chat.addMessage(assistantMessage, true); - // Prepare the HTTP request. String requestBody = gson.toJson(req); - // Use HTTP/1.1 client with a short connection timeout. HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.ALWAYS).build(); - // The Ollama streaming chat endpoint is at "/api/chat" URI endpoint = URI.create(apiConnection.getBaseUri()).resolve("/api/chat"); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(endpoint) .POST(HttpRequest.BodyPublishers.ofString(requestBody)).header("Content-Type", "application/json"); - // Optionally add an authorization header if your apiConnection includes an API - // key. + if (StringUtils.isNotBlank(apiConnection.getApiKey())) { requestBuilder.header("Authorization", "Bearer " + apiConnection.getApiKey()); } HttpRequest request = requestBuilder.build(); - // Send the request asynchronously and process the streamed response - // line-by-line. + final AtomicBoolean responseFinished = new AtomicBoolean(false); + asyncRequest = client.sendAsync(request, HttpResponse.BodyHandlers.ofLines()).thenAccept(response -> { try { if (response.statusCode() >= 200 && response.statusCode() < 300) { response.body().forEach(line -> { - // Each line is expected to be a JSON object. if (line != null && !line.trim().isEmpty()) { try { JsonObject jsonChunk = JsonParser.parseString(line).getAsJsonObject(); - // The Ollama chat endpoint returns a JSON object with a "message" field. - // That "message" object should contain a "content" field that holds the new - // text. if (jsonChunk.has("message")) { JsonObject messageObj = jsonChunk.getAsJsonObject("message"); + + // Handle tool_calls + if (messageObj.has("tool_calls")) { + JsonArray toolCallsArray = messageObj.getAsJsonArray("tool_calls"); + if (toolCallsArray != null && !toolCallsArray.isEmpty()) { + if (toolCallsArray.size() > 1) { + Activator.logError( + "Ollama API returned multiple tool_calls in a single message. Processing only the first.", + null); + } + JsonObject toolCallObj = toolCallsArray.get(0).getAsJsonObject(); + JsonObject functionDetails = toolCallObj.getAsJsonObject("function"); + String functionName = functionDetails.get("name").getAsString(); + JsonObject argumentsObj = functionDetails.getAsJsonObject("arguments"); + String argsJsonString = gson.toJson(argumentsObj); + String clientGeneratedCallId = UUID.randomUUID().toString(); + ChatConversation.FunctionCall functionCall = new ChatConversation.FunctionCall( + clientGeneratedCallId, functionName, argsJsonString); + assistantMessage.setFunctionCall(functionCall); + } + } + + // Handle content if (messageObj.has("content")) { String chunk = messageObj.get("content").getAsString(); - // Append the received chunk to the assistant message. assistantMessage.setContent(assistantMessage.getContent() + chunk); - // Notify the conversation listeners that the assistant message was updated. + chat.notifyMessageUpdated(assistantMessage); } } - // Optionally, if the response includes a "done" flag that is true, you can - // finish early. if (jsonChunk.has("done") && jsonChunk.get("done").getAsBoolean()) { - // End of stream. - return; + finalizeAssistantMessage(assistantMessage, chat, responseFinished); + return; // End of stream for this line processor } } catch (JsonSyntaxException e) { Activator.logError("Error parsing stream chunk: " + line, e); + finalizeAssistantMessage(assistantMessage, chat, responseFinished); + asyncRequest = null; } } }); } else { - Activator.logError("Streaming chat failed with status: " + response.statusCode(), null); + Activator.logError("Streaming chat failed with status: " + response.statusCode() + + "\nResponse body: " + response.body().collect(Collectors.joining("\n")), null); + finalizeAssistantMessage(assistantMessage, chat, responseFinished); + asyncRequest = null; } } finally { - chat.notifyChatResponseFinished(assistantMessage); + finalizeAssistantMessage(assistantMessage, chat, responseFinished); asyncRequest = null; } }).exceptionally(e -> { Activator.logError("Exception during streaming chat", e); + // Ensure assistant message is finalized in case of error before stream + // completion + finalizeAssistantMessage(assistantMessage, chat, responseFinished); + asyncRequest = null; return null; + }); } @@ -284,4 +337,16 @@ public synchronized void abortChat() { asyncRequest = null; } } + + private void finalizeAssistantMessage(ChatConversation.ChatMessage assistantMessage, ChatConversation chat, + AtomicBoolean responseFinished) { + if (assistantMessage != null && !responseFinished.get()) { + if (assistantMessage.getFunctionCall().isPresent()) { + chat.notifyFunctionCalled(assistantMessage); + } + + chat.notifyChatResponseFinished(assistantMessage); + responseFinished.set(true); + } + } } diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OpenAiApiClient.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OpenAiApiClient.java index 2bc1d8e..3328775 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OpenAiApiClient.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/OpenAiApiClient.java @@ -1,5 +1,7 @@ package com.chabicht.code_intelligence.apiclient; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.TOOLS_ENABLED; + import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -8,7 +10,9 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; @@ -16,8 +20,13 @@ import org.apache.commons.lang3.StringUtils; import com.chabicht.code_intelligence.Activator; +import com.chabicht.code_intelligence.chat.tools.ToolDefinitions; import com.chabicht.code_intelligence.model.ChatConversation; +import com.chabicht.code_intelligence.model.ChatConversation.ChatOption; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionCall; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionResult; import com.chabicht.code_intelligence.model.ChatConversation.MessageContext; +import com.chabicht.code_intelligence.model.ChatConversation.Role; import com.chabicht.code_intelligence.model.CompletionPrompt; import com.chabicht.code_intelligence.model.CompletionResult; import com.chabicht.code_intelligence.model.PromptType; @@ -178,22 +187,58 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse List messagesToSend = new ArrayList<>(chat.getMessages()); JsonArray messagesJson = new JsonArray(); for (ChatConversation.ChatMessage msg : messagesToSend) { - JsonObject jsonMsg = new JsonObject(); + // Skip TOOL_SUMMARY messages, they are for internal use only + if (Role.TOOL_SUMMARY.equals(msg.getRole())) { + continue; + } + JsonObject jsonMsg = new JsonObject(); // Convert the role enum to lowercase string (system, user, assistant). jsonMsg.addProperty("role", msg.getRole().toString().toLowerCase()); - StringBuilder content = new StringBuilder(256); + // For user, system messages, or assistant messages without function calls + StringBuilder contentBuilder = new StringBuilder(256); if (!msg.getContext().isEmpty()) { - content.append("Context information:\n\n"); + contentBuilder.append("Context information:\n\n"); + for (MessageContext ctx : msg.getContext()) { + contentBuilder.append(ctx.compile(true)); + contentBuilder.append("\n"); + } + } + String textContent = msg.getContent(); + if (textContent != null) { // Ensure content is not null before appending + contentBuilder.append(textContent); } - for (MessageContext ctx : msg.getContext()) { - content.append(ctx.compile()); - content.append("\n"); + jsonMsg.addProperty("content", contentBuilder.toString()); + + // Add tool call to assistant message if applicable. + if (Role.ASSISTANT.equals(msg.getRole()) && msg.getFunctionCall().isPresent()) { + FunctionCall fc = msg.getFunctionCall().get(); + JsonArray toolCallsArray = new JsonArray(); + JsonObject toolCallItem = new JsonObject(); + toolCallItem.addProperty("id", fc.getId()); + toolCallItem.addProperty("type", "function"); + + JsonObject functionDetails = new JsonObject(); + functionDetails.addProperty("name", fc.getFunctionName()); + // Arguments for OpenAI function calls should be a string containing JSON + functionDetails.addProperty("arguments", fc.getArgsJson()); + toolCallItem.add("function", functionDetails); + toolCallsArray.add(toolCallItem); + + jsonMsg.add("tool_calls", toolCallsArray); + } + + messagesJson.add(jsonMsg); // Add the constructed message (user, assistant, or system) + + if (msg.getFunctionResult().isPresent()) { + FunctionResult fr = msg.getFunctionResult().get(); + JsonObject toolMessage = new JsonObject(); + toolMessage.addProperty("role", "tool"); + toolMessage.addProperty("tool_call_id", fr.getId()); + toolMessage.addProperty("content", fr.getResultJson()); // Content is the JSON string of the result + messagesJson.add(toolMessage); } - content.append(msg.getContent()); - jsonMsg.addProperty("content", content.toString()); - messagesJson.add(jsonMsg); } // Create the JSON request object. @@ -201,6 +246,21 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse req.addProperty("model", modelName); req.addProperty("max_completion_tokens", maxResponseTokens); req.addProperty("stream", true); + + Map options = chat.getOptions(); + if (options.containsKey(TOOLS_ENABLED) && Boolean.TRUE.equals(options.get(TOOLS_ENABLED))) { + if (apiConnection.isLegacyFormat()) { + patchMissingProperties(req, ToolDefinitions.getInstance().getToolDefinitionsOpenAiLegacy()); + } else { + JsonObject toolDefinitionsOpenAi = ToolDefinitions.getInstance().getToolDefinitionsOpenAi(); + // Hack for Fireworks.AI: they don't support the strict flag in function + // definitions. + if (apiConnection.getBaseUri().contains("fireworks.ai")) { + removeStrictFlag(toolDefinitionsOpenAi); + } + patchMissingProperties(req, toolDefinitionsOpenAi); + } + } req.add("messages", messagesJson); // Add a new (empty) assistant message to the conversation. @@ -222,6 +282,9 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse } HttpRequest request = requestBuilder.build(); + // Map to keep track of tool calls by their index + Map activeToolCalls = new HashMap<>(); + // Send the request asynchronously and process the streamed response // line-by-line. asyncRequest = client.sendAsync(request, HttpResponse.BodyHandlers.ofLines()).thenAccept(response -> { @@ -236,6 +299,7 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse // End of stream. return; } + try { JsonObject jsonChunk = JsonParser.parseString(data).getAsJsonObject(); JsonArray choices = jsonChunk.getAsJsonArray("choices"); @@ -263,6 +327,20 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse chunk = ""; } + // Check for tool_calls in the delta + if (delta.has("tool_calls") && !delta.get("tool_calls").isJsonNull()) { + // Process tool calls + handleToolCallDelta(delta.getAsJsonArray("tool_calls"), activeToolCalls, + assistantMessage, chat); + } + + // Check for function_call in the delta (deprecated format) + if (delta.has("function_call") && !delta.get("function_call").isJsonNull()) { + // Process function call (deprecated format) + handleFunctionCallDelta(delta.getAsJsonObject("function_call"), activeToolCalls, + assistantMessage, chat); + } + if (StringUtils.isNotBlank(chunk)) { // Append the received chunk to the assistant message. assistantMessage.setContent(assistantMessage.getContent() + chunk); @@ -270,6 +348,15 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse chat.notifyMessageUpdated(assistantMessage); } } + + // Check for finish_reason to detect completed tool calls or function calls + if (choice.has("finish_reason") && !choice.get("finish_reason").isJsonNull()) { + String finishReason = choice.get("finish_reason").getAsString(); + if ("tool_calls".equals(finishReason) || "function_call".equals(finishReason)) { + // All tool calls or function calls are complete - finalize any pending calls + finalizeToolCalls(activeToolCalls, assistantMessage, chat); + } + } } } catch (JsonSyntaxException e) { Activator.logError("Error parsing stream chunk: " + data, e); @@ -281,15 +368,34 @@ public void performChat(String modelName, ChatConversation chat, int maxResponse + response.body().collect(Collectors.joining("\n")), null); } } finally { + // Check if any tool calls are still pending finalization + if (!activeToolCalls.isEmpty()) { + finalizeToolCalls(activeToolCalls, assistantMessage, chat); + } + chat.notifyChatResponseFinished(assistantMessage); asyncRequest = null; } }).exceptionally(e -> { Activator.logError("Exception during streaming chat", e); + + // Clean up any pending tool/function calls + if (!activeToolCalls.isEmpty()) { + finalizeToolCalls(activeToolCalls, assistantMessage, chat); + } + + chat.notifyChatResponseFinished(assistantMessage); + asyncRequest = null; return null; }); } + private void removeStrictFlag(JsonObject toolDefinitionsOpenAi) { + for (JsonElement el : toolDefinitionsOpenAi.get("tools").getAsJsonArray()) { + el.getAsJsonObject().get("function").getAsJsonObject().remove("strict"); + } + } + @Override public String caption(String modelName, String content) { JsonObject req = createFromPresets(PromptType.INSTRUCT); @@ -321,4 +427,196 @@ public synchronized void abortChat() { asyncRequest = null; } } + + /** + * Processes tool call deltas from the streaming API response. + * + * @param toolCallDeltas The tool call deltas from the current chunk + * @param activeToolCalls Map of active tool calls being tracked + * @param assistantMessage The assistant message to update + * @param chat The chat conversation + */ + private void handleToolCallDelta(JsonArray toolCallDeltas, Map activeToolCalls, + ChatConversation.ChatMessage assistantMessage, ChatConversation chat) { + for (JsonElement toolCallElement : toolCallDeltas) { + JsonObject toolCallDelta = toolCallElement.getAsJsonObject(); + + try { + // Get the index to identify which tool call this belongs to + int index = toolCallDelta.get("index").getAsInt(); + + // Check if this is a new tool call or an update to an existing one + if (!activeToolCalls.containsKey(index)) { + // This is a new tool call, extract ID and function name + String id = null; + String functionName = null; + + if (toolCallDelta.has("id") && !toolCallDelta.get("id").isJsonNull()) { + id = toolCallDelta.get("id").getAsString(); + } + + if (toolCallDelta.has("function")) { + JsonObject function = toolCallDelta.getAsJsonObject("function"); + if (function.has("name") && !function.get("name").isJsonNull()) { + functionName = function.get("name").getAsString(); + } + } + + // Only create a new tool call info if we have both id and name + if (id != null && functionName != null) { + activeToolCalls.put(index, new ToolCallInfo(index, id, functionName)); + } + } + + // Now update the existing tool call with any new argument chunks + if (activeToolCalls.containsKey(index) && toolCallDelta.has("function")) { + JsonObject function = toolCallDelta.getAsJsonObject("function"); + if (function.has("arguments") && !function.get("arguments").isJsonNull()) { + String argumentChunk = function.get("arguments").getAsString(); + activeToolCalls.get(index).appendArguments(argumentChunk); + } + } + } catch (Exception e) { + Activator.logError("Error processing tool call delta: " + toolCallDelta, e); + // We catch the exception but don't rethrow to allow processing to continue + } + } + } + + /** + * Finalizes any pending tool calls when streaming ends. + * + * @param activeToolCalls Map of active tool calls being tracked + * @param assistantMessage The assistant message to update + * @param chat The chat conversation + */ + private void finalizeToolCalls(Map activeToolCalls, + ChatConversation.ChatMessage assistantMessage, ChatConversation chat) { + // Find any remaining tool calls that haven't been finalized yet + for (ToolCallInfo toolCall : activeToolCalls.values()) { + if (!toolCall.isComplete()) { + toolCall.markComplete(); + + // Only notify for the first tool call if there are multiple + // (This is just one approach - you might want to handle multiple tools + // differently) + if (!assistantMessage.getFunctionCall().isPresent()) { + assistantMessage.setFunctionCall(toolCall.toFunctionCall()); + chat.notifyFunctionCalled(assistantMessage); + } + } + } + + // Clear the active tool calls map + activeToolCalls.clear(); + } + + /** + * Processes function call deltas from the streaming API response (deprecated format). + * + * @param functionCallDelta The function call delta from the current chunk + * @param activeToolCalls Map of active tool calls being tracked + * @param assistantMessage The assistant message to update + * @param chat The chat conversation + */ + private void handleFunctionCallDelta(JsonObject functionCallDelta, + Map activeToolCalls, + ChatConversation.ChatMessage assistantMessage, + ChatConversation chat) { + try { + // For the deprecated function_call format, we always use index 0 + // (there is only one function call in this format) + int index = 0; + + // Check if this is a new function call or an update to an existing one + if (!activeToolCalls.containsKey(index)) { + // This is a new function call, extract the name + String name = null; + + if (functionCallDelta.has("name") && !functionCallDelta.get("name").isJsonNull()) { + name = functionCallDelta.get("name").getAsString(); + + // Generate a unique ID for the function call + String id = "call_func_" + System.currentTimeMillis(); + activeToolCalls.put(index, new ToolCallInfo(index, id, name)); + } + } + + // Now update the existing function call with any new argument chunks + if (activeToolCalls.containsKey(index) && + functionCallDelta.has("arguments") && + !functionCallDelta.get("arguments").isJsonNull()) { + + String argumentChunk = functionCallDelta.get("arguments").getAsString(); + activeToolCalls.get(index).appendArguments(argumentChunk); + } + } catch (Exception e) { + Activator.logError("Error processing function call delta: " + functionCallDelta, e); + } + } + + /** + * Helper class to track and accumulate tool call information from streaming + * responses. + */ + private static class ToolCallInfo { + private final int index; // The position of this tool call in the array + private final String id; // The unique ID of the tool call + private final String name; // The function name + private final StringBuilder argumentsJson = new StringBuilder(); // Accumulating JSON arguments + private boolean isComplete = false; // Whether the tool call is complete + private String errorMessage = null; // Any error message if the tool call failed + + public ToolCallInfo(int index, String id, String name) { + this.index = index; + this.id = id; + this.name = name; + } + + public void appendArguments(String argumentChunk) { + argumentsJson.append(argumentChunk); + } + + public boolean isComplete() { + return isComplete; + } + + public void markComplete() { + isComplete = true; + } + + public void markFailure(String message) { + this.errorMessage = message; + this.isComplete = true; // Mark as complete to avoid further processing + } + + public FunctionCall toFunctionCall() { + return new FunctionCall(id, name, argumentsJson.toString()); + } + + // Getters + public int getIndex() { + return index; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getArgumentsJson() { + return argumentsJson.toString(); + } + + public boolean hasFailed() { + return errorMessage != null; + } + + public String getErrorMessage() { + return errorMessage; + } + } } diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/XAiApiClient.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/XAiApiClient.java index 5483dcc..58031f6 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/XAiApiClient.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/apiclient/XAiApiClient.java @@ -1,5 +1,7 @@ package com.chabicht.code_intelligence.apiclient; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.TOOLS_ENABLED; + import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -7,18 +9,25 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import com.chabicht.code_intelligence.Activator; +import com.chabicht.code_intelligence.chat.tools.ToolDefinitions; import com.chabicht.code_intelligence.model.ChatConversation; +import com.chabicht.code_intelligence.model.ChatConversation.ChatOption; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionCall; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionResult; import com.chabicht.code_intelligence.model.ChatConversation.MessageContext; +import com.chabicht.code_intelligence.model.ChatConversation.Role; import com.chabicht.code_intelligence.model.CompletionPrompt; import com.chabicht.code_intelligence.model.CompletionResult; +import com.chabicht.code_intelligence.model.PromptType; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -27,235 +36,327 @@ import com.google.gson.JsonSyntaxException; /** - * Implementation for X.ai API using the chat completion endpoint. - * Compatible with the OpenAI REST API structure but tailored for X.ai specifics. + * Implementation for X.ai API using the chat completion endpoint. Compatible + * with the OpenAI REST API structure but tailored for X.ai specifics. */ public class XAiApiClient extends AbstractApiClient implements IAiApiClient { private transient final Gson gson = Activator.getDefault().createGson(); - private CompletableFuture asyncRequest; - - /** - * Constructs an XAiApiClient with the provided API connection. - * The AiApiConnection should be configured with base URI "https://api.x.ai/v1" - * and the X.ai API key. - * - * @param apiConnection the connection details for the X.ai API - */ - public XAiApiClient(AiApiConnection apiConnection) { + private CompletableFuture asyncRequest; + + /** + * Constructs an XAiApiClient with the provided API connection. The + * AiApiConnection should be configured with base URI "https://api.x.ai/v1" and + * the X.ai API key. + * + * @param apiConnection the connection details for the X.ai API + */ + public XAiApiClient(AiApiConnection apiConnection) { super(apiConnection); - } - - @Override - public List getModels() { - JsonObject res = performGet(JsonObject.class, "models"); - return res.get("data").getAsJsonArray().asList().stream().map(e -> { - JsonObject o = e.getAsJsonObject(); - String id = o.get("id").getAsString(); - return new AiModel(apiConnection, id, id); - }).collect(Collectors.toList()); - } - - public AiApiConnection getApiConnection() { - return apiConnection; - } - - @SuppressWarnings("unchecked") - private T performGet(Class clazz, String relPath) { - int statusCode = -1; - String responseBody = "(nothing)"; - try { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(5)) - .followRedirects(Redirect.ALWAYS) - .build(); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(apiConnection.getBaseUri() + "/").resolve(relPath)) - .GET(); - if (StringUtils.isNotBlank(apiConnection.getApiKey())) { - requestBuilder.header("Authorization", "Bearer " + apiConnection.getApiKey()); - } - HttpRequest request = requestBuilder.build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - statusCode = response.statusCode(); - responseBody = response.body(); - return (T) JsonParser.parseString(responseBody); - } catch (JsonSyntaxException | IOException | InterruptedException e) { - Activator.logError(String.format(""" - Error during API request: - URI: %s - Method: GET - Status code: %d - Response: - %s - """, apiConnection.getBaseUri() + relPath, statusCode, responseBody), e); - throw new RuntimeException(e); - } - } - - @SuppressWarnings("unchecked") - private T performPost(Class clazz, String relPath, U requestBody) { - int statusCode = -1; - String responseBody = "(nothing)"; - String requestBodyString = "(nothing)"; - try { - requestBodyString = gson.toJson(requestBody); - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(5)) - .followRedirects(Redirect.ALWAYS) - .build(); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(apiConnection.getBaseUri() + "/").resolve(relPath)) - .POST(HttpRequest.BodyPublishers.ofString(requestBodyString)); - requestBuilder.header("Content-Type", "application/json"); - if (StringUtils.isNotBlank(apiConnection.getApiKey())) { - requestBuilder.header("Authorization", "Bearer " + apiConnection.getApiKey()); - } - HttpRequest request = requestBuilder.build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - statusCode = response.statusCode(); - responseBody = response.body(); - - if (statusCode < 200 || statusCode >= 300) { - throw new RuntimeException( - String.format("API request failed with code %s:\n%s", statusCode, responseBody)); - } - return (T) JsonParser.parseString(responseBody); - } catch (JsonSyntaxException | IOException | InterruptedException e) { - Activator.logError(String.format(""" - Error during API request: - URI: %s - Method: POST - Status code: %d - Request: - %s - Response: - %s - """, apiConnection.getBaseUri() + relPath, statusCode, requestBodyString, responseBody), e); - throw new RuntimeException(e); - } - } - - @Override - public CompletionResult performCompletion(String modelName, CompletionPrompt completionPrompt) { - JsonObject req = new JsonObject(); - req.addProperty("model", modelName); - req.addProperty("temperature", completionPrompt.getTemperature()); - req.addProperty("max_tokens", Activator.getDefault().getMaxCompletionTokens()); - - JsonArray messages = new JsonArray(); - JsonObject userMessage = new JsonObject(); - userMessage.addProperty("role", "user"); - userMessage.addProperty("content", completionPrompt.compile()); - messages.add(userMessage); - - req.add("messages", messages); - - JsonObject res = performPost(JsonObject.class, "chat/completions", req); - return new CompletionResult(res.get("choices").getAsJsonArray().get(0).getAsJsonObject() - .get("message").getAsJsonObject().get("content").getAsString()); - } - - @Override + } + + @Override + public List getModels() { + JsonObject res = performGet(JsonObject.class, "models"); + return res.get("data").getAsJsonArray().asList().stream().map(e -> { + JsonObject o = e.getAsJsonObject(); + String id = o.get("id").getAsString(); + return new AiModel(apiConnection, id, id); + }).collect(Collectors.toList()); + } + + public AiApiConnection getApiConnection() { + return apiConnection; + } + + @SuppressWarnings("unchecked") + private T performGet(Class clazz, String relPath) { + int statusCode = -1; + String responseBody = "(nothing)"; + try { + HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(5)).followRedirects(Redirect.ALWAYS).build(); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(apiConnection.getBaseUri() + "/").resolve(relPath)).GET(); + if (StringUtils.isNotBlank(apiConnection.getApiKey())) { + requestBuilder.header("Authorization", "Bearer " + apiConnection.getApiKey()); + } + HttpRequest request = requestBuilder.build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + statusCode = response.statusCode(); + responseBody = response.body(); + return (T) JsonParser.parseString(responseBody); + } catch (JsonSyntaxException | IOException | InterruptedException e) { + Activator.logError(String.format(""" + Error during API request: + URI: %s + Method: GET + Status code: %d + Response: + %s + """, apiConnection.getBaseUri() + relPath, statusCode, responseBody), e); + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + private T performPost(Class clazz, String relPath, + U requestBody) { + int statusCode = -1; + String responseBody = "(nothing)"; + String requestBodyString = "(nothing)"; + try { + requestBodyString = gson.toJson(requestBody); + HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(5)).followRedirects(Redirect.ALWAYS).build(); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(apiConnection.getBaseUri() + "/").resolve(relPath)) + .POST(HttpRequest.BodyPublishers.ofString(requestBodyString)); + requestBuilder.header("Content-Type", "application/json"); + if (StringUtils.isNotBlank(apiConnection.getApiKey())) { + requestBuilder.header("Authorization", "Bearer " + apiConnection.getApiKey()); + } + HttpRequest request = requestBuilder.build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + statusCode = response.statusCode(); + responseBody = response.body(); + + if (statusCode < 200 || statusCode >= 300) { + throw new RuntimeException( + String.format("API request failed with code %s:\n%s", statusCode, responseBody)); + } + return (T) JsonParser.parseString(responseBody); + } catch (JsonSyntaxException | IOException | InterruptedException e) { + Activator.logError(String.format(""" + Error during API request: + URI: %s + Method: POST + Status code: %d + Request: + %s + Response: + %s + """, apiConnection.getBaseUri() + relPath, statusCode, requestBodyString, responseBody), e); + throw new RuntimeException(e); + } + } + + @Override + public CompletionResult performCompletion(String modelName, CompletionPrompt completionPrompt) { + JsonObject req = new JsonObject(); + req.addProperty("model", modelName); + req.addProperty("temperature", completionPrompt.getTemperature()); + req.addProperty("max_completion_tokens", Activator.getDefault().getMaxCompletionTokens()); + + JsonArray messages = new JsonArray(); + JsonObject userMessage = new JsonObject(); + userMessage.addProperty("role", "user"); + userMessage.addProperty("content", completionPrompt.compile()); + messages.add(userMessage); + + req.add("messages", messages); + + JsonObject res = performPost(JsonObject.class, "chat/completions", req); + return new CompletionResult(res.get("choices").getAsJsonArray().get(0).getAsJsonObject().get("message") + .getAsJsonObject().get("content").getAsString()); + } + + @Override public void performChat(String modelName, ChatConversation chat, int maxResponseTokens) { - // Build the JSON array of messages from the conversation - List messagesToSend = new ArrayList<>(chat.getMessages()); - JsonArray messagesJson = new JsonArray(); - for (ChatConversation.ChatMessage msg : messagesToSend) { - JsonObject jsonMsg = new JsonObject(); - jsonMsg.addProperty("role", msg.getRole().toString().toLowerCase()); - - StringBuilder content = new StringBuilder(256); - if (!msg.getContext().isEmpty()) { - content.append("Context information:\n\n"); - } - for (MessageContext ctx : msg.getContext()) { - content.append(ctx.compile()); - content.append("\n"); - } - content.append(msg.getContent()); - jsonMsg.addProperty("content", content.toString()); - messagesJson.add(jsonMsg); - } - - // Create the JSON request object with streaming enabled - JsonObject req = new JsonObject(); - req.addProperty("model", modelName); - req.addProperty("stream", true); - req.addProperty("max_tokens", maxResponseTokens); - req.add("messages", messagesJson); - - // Add an empty assistant message to be updated as the stream progresses - ChatConversation.ChatMessage assistantMessage = new ChatConversation.ChatMessage( - ChatConversation.Role.ASSISTANT, ""); + // Build the JSON array of messages from the conversation + JsonArray messagesJson = new JsonArray(); + for (ChatConversation.ChatMessage msg : chat.getMessages()) { // Iterate directly + // Skip TOOL_SUMMARY messages, they are for internal use only + if (ChatConversation.Role.TOOL_SUMMARY.equals(msg.getRole())) { + continue; + } + JsonObject jsonMsg = new JsonObject(); + String roleName = msg.getRole().toString().toLowerCase(); + jsonMsg.addProperty("role", roleName); + + StringBuilder contentBuilder = new StringBuilder(256); + // Build content for every message type + if (!msg.getContext().isEmpty()) { + contentBuilder.append("Context information:\n\n"); + } + for (MessageContext ctx : msg.getContext()) { + contentBuilder.append(ctx.compile()); + contentBuilder.append("\n"); + } + contentBuilder.append(msg.getContent()); + jsonMsg.addProperty("content", contentBuilder.toString()); + + // Add function calls for assistant messages + if (msg.getRole() == Role.ASSISTANT && msg.getFunctionCall().isPresent()) { + JsonArray toolCallsArray = new JsonArray(); + JsonObject toolCallJson = new JsonObject(); + FunctionCall fc = msg.getFunctionCall().get(); + toolCallJson.addProperty("id", fc.getId()); + toolCallJson.addProperty("type", "function"); + JsonObject functionJson = new JsonObject(); + functionJson.addProperty("name", fc.getFunctionName()); + functionJson.addProperty("arguments", fc.getArgsJson()); + toolCallJson.add("function", functionJson); + toolCallsArray.add(toolCallJson); + jsonMsg.add("tool_calls", toolCallsArray); + } + + messagesJson.add(jsonMsg); + + // If this message has a function result, add it as a separate tool message + if (msg.getRole() == Role.ASSISTANT && msg.getFunctionCall().isPresent() + && msg.getFunctionResult().isPresent()) { + FunctionResult fr = msg.getFunctionResult().get(); + if (StringUtils.isNotBlank(fr.getId())) { + // Create a new tool message for the function result + JsonObject toolMsg = new JsonObject(); + toolMsg.addProperty("role", "tool"); + toolMsg.addProperty("content", fr.getResultJson()); + toolMsg.addProperty("tool_call_id", fr.getId()); + messagesJson.add(toolMsg); + } + } + } + + JsonObject req = createFromPresets(PromptType.CHAT); + req.addProperty("model", modelName); + req.addProperty("stream", true); + req.addProperty("max_completion_tokens", maxResponseTokens); // Corrected parameter name + req.add("messages", messagesJson); + + Map options = chat.getOptions(); + if (options.containsKey(TOOLS_ENABLED) && Boolean.TRUE.equals(options.get(TOOLS_ENABLED))) { + patchMissingProperties(req, ToolDefinitions.getInstance().getToolDefinitionsXAi()); + } + + ChatConversation.ChatMessage assistantMessage = new ChatConversation.ChatMessage( + ChatConversation.Role.ASSISTANT, ""); chat.addMessage(assistantMessage, true); - // Prepare the HTTP request - String requestBody = gson.toJson(req); - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(5)) - .followRedirects(Redirect.ALWAYS) - .build(); - - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(apiConnection.getBaseUri() + "/").resolve("chat/completions")) - .POST(HttpRequest.BodyPublishers.ofString(requestBody)) - .header("Content-Type", "application/json"); - if (StringUtils.isNotBlank(apiConnection.getApiKey())) { - requestBuilder.header("Authorization", "Bearer " + apiConnection.getApiKey()); - } - HttpRequest request = requestBuilder.build(); - - // Send the request asynchronously and process the streaming response - asyncRequest = client.sendAsync(request, HttpResponse.BodyHandlers.ofLines()) - .thenAccept(response -> { - try { - if (response.statusCode() >= 200 && response.statusCode() < 300) { - response.body().forEach(line -> { - if (line != null && line.startsWith("data: ")) { - String data = line.substring("data: ".length()).trim(); - if ("[DONE]".equals(data)) { - return; - } - try { - JsonObject jsonChunk = JsonParser.parseString(data).getAsJsonObject(); - JsonArray choices = jsonChunk.getAsJsonArray("choices"); - for (JsonElement choiceElement : choices) { - JsonObject choice = choiceElement.getAsJsonObject(); - if (choice.has("delta")) { - JsonObject delta = choice.getAsJsonObject("delta"); - if (delta.has("content")) { - String chunk = delta.get("content").getAsString(); - assistantMessage.setContent( - assistantMessage.getContent() + chunk); - chat.notifyMessageUpdated(assistantMessage); - } - } - } - } catch (JsonSyntaxException e) { - Activator.logError("Error parsing stream chunk: " + data, e); - } - } - }); - } else { - Activator.logError("Streaming chat failed with status: " + response.statusCode(), null); - } - } finally { - chat.notifyChatResponseFinished(assistantMessage); - asyncRequest = null; - } - }).exceptionally(e -> { - Activator.logError("Exception during streaming chat", e); - return null; - }); - } - - @Override + String requestBody = gson.toJson(req); // For logging + HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(5)).followRedirects(Redirect.ALWAYS).build(); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(apiConnection.getBaseUri() + "/").resolve("chat/completions")) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)).header("Content-Type", "application/json"); + if (StringUtils.isNotBlank(apiConnection.getApiKey())) { + requestBuilder.header("Authorization", "Bearer " + apiConnection.getApiKey()); + } + HttpRequest request = requestBuilder.build(); + + asyncRequest = client.sendAsync(request, HttpResponse.BodyHandlers.ofLines()).thenAccept(response -> { + final AtomicReference tempToolCallId = new AtomicReference<>(); + final AtomicReference tempToolCallName = new AtomicReference<>(); + final StringBuilder tempToolCallArgs = new StringBuilder(); + + try { + if (response.statusCode() >= 200 && response.statusCode() < 300) { + response.body().forEach(line -> { + if (line != null && line.startsWith("data: ")) { + String data = line.substring("data: ".length()).trim(); + if ("[DONE]".equals(data)) { + return; + } + try { + JsonObject jsonChunk = JsonParser.parseString(data).getAsJsonObject(); + JsonArray choices = jsonChunk.getAsJsonArray("choices"); + if (choices != null && !choices.isEmpty()) { + JsonObject choice = choices.get(0).getAsJsonObject(); // Process first choice + if (choice.has("delta")) { + JsonObject delta = choice.getAsJsonObject("delta"); + if (delta.has("content") && !delta.get("content").isJsonNull()) { + String chunk = delta.get("content").getAsString(); + assistantMessage.setContent(assistantMessage.getContent() + chunk); + } + + if (delta.has("tool_calls")) { + JsonArray toolCallsDelta = delta.getAsJsonArray("tool_calls"); + if (toolCallsDelta != null && !toolCallsDelta.isEmpty()) { + JsonObject toolCallChunk = toolCallsDelta.get(0).getAsJsonObject(); // Process + // first + // tool + // call + if (toolCallChunk.has("id")) { + tempToolCallId.set(toolCallChunk.get("id").getAsString()); + } + // type is assumed "function" by X.ai and not explicitly stored in our + // FunctionCall model + if (toolCallChunk.has("function")) { + JsonObject functionChunk = toolCallChunk + .getAsJsonObject("function"); + if (functionChunk.has("name")) { + tempToolCallName.set(functionChunk.get("name").getAsString()); + } + if (functionChunk.has("arguments")) { + tempToolCallArgs + .append(functionChunk.get("arguments").getAsString()); + } + } + } + } + } + + if (choice.has("finish_reason") && !choice.get("finish_reason").isJsonNull()) { + String finishReason = choice.get("finish_reason").getAsString(); + // TODO: Consider storing finishReason on assistantMessage if needed by + // UI/controller after stream + // e.g., assistantMessage.setLastFinishReason(finishReason); + + if ("tool_calls".equals(finishReason)) { + if (tempToolCallId.get() != null && tempToolCallName.get() != null) { + if (assistantMessage.getFunctionCall().isPresent()) { + Activator.logWarn( + "Model attempted to make multiple tool calls. Processing only the first one: " + + tempToolCallId.get()); + } else { + FunctionCall actualFc = new FunctionCall(tempToolCallId.get(), + tempToolCallName.get(), tempToolCallArgs.toString()); + assistantMessage.setFunctionCall(actualFc); // Uses the + // setFunctionCall(FunctionCall) + // overload + chat.notifyFunctionCalled(assistantMessage); + } + } else { + Activator.logWarn( + "Finish reason was 'tool_calls' but tool call data was incomplete."); + } + } + } + chat.notifyMessageUpdated(assistantMessage); + } + } catch (JsonSyntaxException e) { + Activator.logError("Error parsing stream chunk: " + data, e); + } + } + }); + } else { + String errorBody = response.body().collect(Collectors.joining("\n")); + Activator.logError(String.format("Streaming chat failed with status: %d. Response: %s. Request: %s", + response.statusCode(), errorBody, requestBody), null); + assistantMessage + .setContent("[Error: API request failed with status " + response.statusCode() + "]"); + // TODO: Consider storing "error" as finishReason on assistantMessage if needed + // e.g., assistantMessage.setLastFinishReason("error"); + } + } finally { + chat.notifyChatResponseFinished(assistantMessage); + asyncRequest = null; + } + }).exceptionally(e -> { + Activator.logError("Exception during streaming chat. Request: " + requestBody, e); + // TODO: Consider storing "exception" as finishReason on assistantMessage if + // needed + // e.g., assistantMessage.setLastFinishReason("exception"); + chat.notifyChatResponseFinished(assistantMessage); // Ensure this is called + asyncRequest = null; + return null; + }); + } + + @Override public String caption(String modelName, String content) { JsonObject req = new JsonObject(); req.addProperty("model", modelName); @@ -275,15 +376,15 @@ public String caption(String modelName, String content) { } @Override - public synchronized boolean isChatPending() { - return asyncRequest != null; - } - - @Override - public synchronized void abortChat() { - if (asyncRequest != null) { - asyncRequest.cancel(true); - asyncRequest = null; - } - } + public synchronized boolean isChatPending() { + return asyncRequest != null; + } + + @Override + public synchronized void abortChat() { + if (asyncRequest != null) { + asyncRequest.cancel(true); + asyncRequest = null; + } + } } \ No newline at end of file diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/AddSelectionToContextUtil.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/AddSelectionToContextUtil.java index 81403c0..fc0e381 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/AddSelectionToContextUtil.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/AddSelectionToContextUtil.java @@ -4,12 +4,18 @@ import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import org.apache.commons.beanutils.PropertyUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.filebuffers.FileBuffers; +import org.eclipse.core.filebuffers.ITextFileBuffer; +import org.eclipse.core.filebuffers.ITextFileBufferManager; +import org.eclipse.core.filebuffers.LocationKind; import org.eclipse.core.internal.resources.File; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IMarker; @@ -20,13 +26,17 @@ import org.eclipse.jdt.core.ISourceRange; import org.eclipse.jdt.core.ISourceReference; import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.ITextSelection; import org.eclipse.jface.viewers.ISelection; import org.eclipse.jface.viewers.IStructuredSelection; import org.eclipse.jface.viewers.TreePath; import org.eclipse.jface.viewers.TreeSelection; import org.eclipse.search.internal.ui.text.LineElement; +import org.eclipse.ui.IEditorInput; import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IFileEditorInput; import org.eclipse.ui.IWorkbenchPage; import org.eclipse.ui.IWorkbenchWindow; import org.eclipse.ui.PlatformUI; @@ -35,7 +45,6 @@ import com.chabicht.code_intelligence.Activator; import com.chabicht.code_intelligence.model.ChatConversation.MessageContext; import com.chabicht.code_intelligence.model.ChatConversation.RangeType; -import com.chabicht.code_intelligence.util.CodeUtil; public class AddSelectionToContextUtil { private AddSelectionToContextUtil() { @@ -57,10 +66,15 @@ static void addSelectionToContext(ISelection selectionObj) { if (selection != null) { String selectedText = selection.getText(); if (StringUtils.isNotBlank(selectedText)) { - String processedText = CodeUtil.removeCommonIndentation(selectedText); - String fileName = getTextEditor().getEditorInput().getName(); + IEditorInput editorInput = getTextEditor().getEditorInput(); + String fileName; + if (editorInput instanceof IFileEditorInput fi) { + fileName = fi.getFile().getFullPath().toString(); + } else { + fileName = editorInput.getName(); + } ChatView.addContext(new MessageContext(fileName, selection.getStartLine() + 1, - selection.getEndLine() + 1, processedText)); + selection.getEndLine() + 1, selectedText)); } } } @@ -71,17 +85,16 @@ private static void processSelectedObject(Object obj) { if (obj instanceof ISourceReference sre) { try { String ancestor = getFileOrTypeName(sre); - ISourceRange sourceRange = sre.getSourceRange(); + Range range = getLineRangeInFile(sre); String source = sre.getSource(); if (StringUtils.isNotBlank(source)) { - String processedText = CodeUtil.removeCommonIndentation(source); - ChatView.addContext(new MessageContext(ancestor, RangeType.OFFSET, sourceRange.getOffset(), - sourceRange.getOffset() + sourceRange.getLength(), processedText)); + ChatView.addContext( + new MessageContext(ancestor, range.type, range.start(), range.end, source)); } else { String stringRep = sre.toString(); String binaryLabel = "Binary " + stringRep.replaceAll(" \\[.*", ""); - ChatView.addContext(new MessageContext(binaryLabel, RangeType.OFFSET, sourceRange.getOffset(), - sourceRange.getOffset() + sourceRange.getLength(), stringRep) { + ChatView.addContext( + new MessageContext(binaryLabel, RangeType.OFFSET, range.start, range.end, stringRep) { @Override public String getLabel() { @@ -89,7 +102,7 @@ public String getLabel() { } @Override - public String getDescriptor() { + public String getDescriptor(boolean prefixLineNumbers) { return ""; } }); @@ -101,9 +114,8 @@ public String getDescriptor() { int line = le.getLine(); String parent = le.getParent().getName(); ChatView.addContext(new MessageContext(parent, line, line, le.getContents())); - } else if (obj instanceof File f) { - String name = f.getName(); + String name = f.getFullPath().toString(); try { AtomicInteger lines = new AtomicInteger(0); StringBuilder content = new StringBuilder(1025); @@ -128,21 +140,18 @@ public String getDescriptor() { String message = (String) marker.getAttribute("message"); Integer severity = (Integer) marker.getAttribute("severity"); - int startLine = Math.max(0, lineNumber - 5); - int endLine = Math.min(lineNumber + 5, content.size()); - String context = content.subList(startLine, endLine).stream().collect(Collectors.joining("\n")); + int startLine = Math.max(0, lineNumber - 5) + 1; + int endLine = Math.min(lineNumber + 5, content.size()) + 1; + String context = content.subList(startLine - 1, endLine - 1).stream() + .collect(Collectors.joining("\n")); StringBuilder sb = new StringBuilder(); sb.append(severity == 2 ? "Error" : "Warning").append(" on line ").append(lineNumber) .append(" in document ").append(file.getName()).append(": ").append(message).append("\n"); - sb.append("Lines ").append(startLine).append(" to ").append(endLine).append(" of the document:\n") - .append(context).append("\n"); - ChatView.addContext(new MessageContext(file.getName(), startLine, endLine, sb.toString()) { - @Override - public String getDescriptor() { - return ""; // No comment above this. - } - }); + ChatView.addContext( + new MessageContext(UUID.randomUUID(), file.getFullPath().toString(), RangeType.LINE, + startLine, endLine, + sb.toString(), context, null)); } catch (CoreException e) { Activator.logError("Could not add IMarker to context: " + marker.toString(), e); } @@ -150,12 +159,40 @@ public String getDescriptor() { } } + private static Range getLineRangeInFile(ISourceReference sre) throws JavaModelException { + ISourceRange sourceRange = sre.getSourceRange(); + Optional fOpt = getFile(sre); + if (fOpt.isPresent()) { + IFile file = fOpt.get(); + try { + ITextFileBufferManager bufferManager = FileBuffers.getTextFileBufferManager(); + bufferManager.connect(file.getFullPath(), LocationKind.IFILE, null); + ITextFileBuffer buffer = bufferManager.getTextFileBuffer(file.getFullPath(), LocationKind.IFILE); + IDocument doc = buffer.getDocument(); + int start = doc.getLineOfOffset(sourceRange.getOffset()) + 1; + int end = doc.getLineOfOffset(sourceRange.getOffset() + sourceRange.getLength()) + 1; + return new Range(RangeType.LINE, start, end); + } catch (CoreException | BadLocationException e) { + Activator.logError("Failed to obtain source range in file " + file.getFullPath().toString(), e); + } + } + return new Range(RangeType.OFFSET, sourceRange.getOffset(), sourceRange.getOffset()+sourceRange.getLength()); + } + + private static Optional getFile(ISourceReference sr) { + if (sr instanceof IJavaElement je) { + return Optional.ofNullable((IFile) je.getResource()); + } + + return Optional.empty(); + } + private static String getFileOrTypeName(ISourceReference sr) { if (sr instanceof IJavaElement je) { for (int type : new int[] { IJavaElement.COMPILATION_UNIT, IJavaElement.TYPE }) { IJavaElement ancestor = je.getAncestor(type); if (ancestor != null) { - return ancestor.getElementName(); + return ancestor.getPath().toString(); } } } @@ -194,4 +231,6 @@ private static ITextEditor getTextEditor() { return textEditor; } + private final record Range(RangeType type, int start, int end) { + } } diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettings.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettings.java index 1b09a1d..8089b94 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettings.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettings.java @@ -1,15 +1,23 @@ package com.chabicht.code_intelligence.chat; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + import com.chabicht.code_intelligence.Activator; import com.chabicht.code_intelligence.Bean; import com.chabicht.code_intelligence.model.PromptTemplate; public class ChatSettings extends Bean { + private static final Pattern CLAUDE_4_PATTERN = Pattern.compile("claude-[^-]+-4"); + private String model; private PromptTemplate promptTemplate; - private boolean reasoningEnabled; + private boolean reasoningEnabled = true; private int maxResponseTokens = Activator.getDefault().getMaxChatTokens(); private int reasoningTokens = 8192; + private boolean toolsEnabled = true; + private Map toolEnabledStates = new HashMap<>(); public String getModel() { return model; @@ -54,4 +62,40 @@ public void setReasoningTokens(int reasoningTokens) { propertyChangeSupport.firePropertyChange("reasoningTokens", this.reasoningTokens, this.reasoningTokens = reasoningTokens); } + + public boolean isToolsEnabled() { + return toolsEnabled; + } + + public void setToolsEnabled(boolean toolsEnabled) { + propertyChangeSupport.firePropertyChange("toolsEnabled", this.toolsEnabled, + this.toolsEnabled = toolsEnabled); + } + + public Map getToolEnabledStates() { + return toolEnabledStates; + } + + public void setToolEnabledStates(Map toolEnabledStates) { + propertyChangeSupport.firePropertyChange("toolEnabledStates", this.toolEnabledStates, + this.toolEnabledStates = toolEnabledStates); + } + + public boolean isToolEnabled(String toolName) { + return toolEnabledStates.getOrDefault(toolName, true); + } + + public void setToolEnabled(String toolName, boolean enabled) { + boolean oldValue = toolEnabledStates.put(toolName, enabled); + propertyChangeSupport.firePropertyChange("toolEnabledStates." + toolName, oldValue, enabled); + } + + public static boolean supportsReasoning(String modelId) { + return modelId != null && (modelId.contains("claude-3-7") || CLAUDE_4_PATTERN.matcher(modelId).find() + || modelId.contains("gemini-2.5")); + } + + public boolean isReasoningSupportedAndEnabled() { + return supportsReasoning(model) && isReasoningEnabled(); + } } diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettingsDialog.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettingsDialog.java index 15a5ed5..7cc460e 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettingsDialog.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatSettingsDialog.java @@ -1,5 +1,7 @@ package com.chabicht.code_intelligence.chat; +import static com.chabicht.code_intelligence.chat.ChatSettings.supportsReasoning; + import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.List; @@ -46,22 +48,16 @@ public class ChatSettingsDialog extends Dialog { public static final PromptTemplate NONE = createNoTemplateSelection(); - public ChatSettings getSettings() { - return settings; - } - private DataBindingContext m_bindingContext; - private final ChatSettings settings; private final WritableList systemPrompts = new WritableList<>(); private ComboViewer cvSystemPrompt; private Text txtModel; private Text txtReasoningBudgetTokens; - - private Button btnEnabled; - + private Button btnReasoningEnabled; private Text txtChatCompletionMaxTokens; + private Button btnToolsEnabled; protected ChatSettingsDialog(Shell parentShell, ChatSettings settings) { super(parentShell); @@ -116,10 +112,9 @@ public void widgetSelected(SelectionEvent e) { } } ModelSelectionDialog dialog = new ModelSelectionDialog(getShell(), models); - String res = null; if (dialog.open() == ModelSelectionDialog.OK) { AiModel model = dialog.getSelectedModel(); - res = model.getApiConnection().getName() + "/" + model.getId(); + String res = model.getApiConnection().getName() + "/" + model.getId(); settings.setModel(res); } } @@ -140,24 +135,36 @@ public void widgetSelected(SelectionEvent e) { cvSystemPrompt = new ComboViewer(composite, SWT.NONE); Combo cbSystemPrompt = cvSystemPrompt.getCombo(); cbSystemPrompt.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 2, 1)); - + Group grpReasoning = new Group(composite, SWT.NONE); grpReasoning.setLayout(new GridLayout(2, false)); GridData gd_grpReasoning = new GridData(SWT.FILL, SWT.CENTER, false, false, 3, 1); gd_grpReasoning.widthHint = 75; grpReasoning.setLayoutData(gd_grpReasoning); grpReasoning.setText("Reasoning"); - - btnEnabled = new Button(grpReasoning, SWT.CHECK); - btnEnabled.setText("enabled"); + + btnReasoningEnabled = new Button(grpReasoning, SWT.CHECK); + btnReasoningEnabled.setText("enabled"); new Label(grpReasoning, SWT.NONE); - + Label lblBudgetTokens = new Label(grpReasoning, SWT.NONE); lblBudgetTokens.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false, 1, 1)); lblBudgetTokens.setText("Budget tokens:"); - + txtReasoningBudgetTokens = new Text(grpReasoning, SWT.BORDER); txtReasoningBudgetTokens.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + + // Tool Configuration Group + Group grpTools = new Group(composite, SWT.NONE); + grpTools.setLayout(new GridLayout(1, false)); // Changed to 1 column + GridData gd_grpTools = new GridData(SWT.FILL, SWT.FILL, true, true, 3, 1); + grpTools.setLayoutData(gd_grpTools); + grpTools.setText("Tools"); + + btnToolsEnabled = new Button(grpTools, SWT.CHECK); + btnToolsEnabled.setText("Enable Tools Globally"); + btnToolsEnabled.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + cvSystemPrompt.setLabelProvider(new LabelProvider() { @Override public String getText(Object element) { @@ -191,15 +198,17 @@ private void init() { } }); updateReasoningEnablement(settings.getModel()); + + btnToolsEnabled.setSelection(settings.isToolsEnabled()); } private void updateReasoningEnablement(String modelId) { Display.getDefault().syncExec(() -> { - boolean supportsReasoning = modelId.contains("claude-3-7"); - if (!supportsReasoning) { - btnEnabled.setSelection(false); - } - btnEnabled.setEnabled(supportsReasoning); + boolean supportsReasoning = supportsReasoning(modelId); +// if (!supportsReasoning) { +// btnReasoningEnabled.setSelection(false); +// } + btnReasoningEnabled.setEnabled(supportsReasoning); txtReasoningBudgetTokens.setEnabled(supportsReasoning); }); } @@ -239,10 +248,11 @@ public Object convert(Object fromObject) { IObservableValue modelSettingsObserveValue = BeanProperties.value("model").observe(settings); bindingContext.bindValue(observeTextTxtModelObserveWidget, modelSettingsObserveValue, null, null); // - IObservableValue observeButtonEnabledWidget = WidgetProperties.buttonSelection().observe(btnEnabled); + IObservableValue observeButtonReasoningEnabledWidget = WidgetProperties.buttonSelection() + .observe(btnReasoningEnabled); IObservableValue reasoningEnabledSettingsObserveValue = BeanProperties.value("reasoningEnabled") .observe(settings); - bindingContext.bindValue(observeButtonEnabledWidget, reasoningEnabledSettingsObserveValue, null, null); + bindingContext.bindValue(observeButtonReasoningEnabledWidget, reasoningEnabledSettingsObserveValue, null, null); // IObservableValue observeTextTxtReasoningBudgetTokensObserveWidget = org.eclipse.jface.databinding.swt.typed.WidgetProperties .text(org.eclipse.swt.SWT.Modify).observe(txtReasoningBudgetTokens); @@ -262,6 +272,11 @@ public Object convert(Object fromObject) { new UpdateValueStrategy().setConverter(StringToNumberConverter.toInteger(true)), new UpdateValueStrategy().setConverter(NumberToStringConverter.fromInteger(true))); + // Bind btnToolsEnabled to settings.toolsEnabled + IObservableValue observeToolsEnabledCheckbox = WidgetProperties.buttonSelection().observe(btnToolsEnabled); + IObservableValue toolsEnabledSettingsObserveValue = BeanProperties.value("toolsEnabled").observe(settings); + bindingContext.bindValue(observeToolsEnabledCheckbox, toolsEnabledSettingsObserveValue, null, null); + return bindingContext; } @@ -271,4 +286,13 @@ protected Point getInitialSize() { res.x = 720; return res; } -} + + @Override + protected void okPressed() { + super.okPressed(); + } + + public ChatSettings getSettings() { + return settings; + } +} \ No newline at end of file diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java index aad0bd1..d67009e 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java @@ -2,13 +2,17 @@ import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.REASONING_BUDGET_TOKENS; import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.REASONING_ENABLED; +import static com.chabicht.code_intelligence.model.ChatConversation.ChatOption.TOOLS_ENABLED; import java.io.ByteArrayOutputStream; import java.lang.reflect.InvocationTargetException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -18,6 +22,7 @@ import org.apache.commons.beanutils.BeanUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.eclipse.core.databinding.observable.list.IListChangeListener; @@ -81,7 +86,9 @@ import org.eclipse.text.edits.TextEdit; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; +import org.eclipse.ui.IEditorInput; import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IFileEditorInput; import org.eclipse.ui.IWorkbenchPage; import org.eclipse.ui.IWorkbenchWindow; import org.eclipse.ui.PlatformUI; @@ -95,16 +102,24 @@ import com.chabicht.code_intelligence.model.ChatConversation; import com.chabicht.code_intelligence.model.ChatConversation.ChatListener; import com.chabicht.code_intelligence.model.ChatConversation.ChatMessage; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionCall; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionParamValue; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionResult; import com.chabicht.code_intelligence.model.ChatConversation.MessageContext; import com.chabicht.code_intelligence.model.ChatConversation.RangeType; import com.chabicht.code_intelligence.model.ChatConversation.Role; import com.chabicht.code_intelligence.model.ChatHistoryEntry; +import com.chabicht.code_intelligence.model.PromptTemplate; import com.chabicht.code_intelligence.model.PromptType; -import com.chabicht.code_intelligence.util.CodeUtil; +import com.chabicht.code_intelligence.util.Log; import com.chabicht.code_intelligence.util.MarkdownUtil; import com.chabicht.code_intelligence.util.ModelUtil; import com.chabicht.code_intelligence.util.ThemeUtil; import com.chabicht.codeintelligence.preferences.PreferenceConstants; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; public class ChatView extends ViewPart { private static final Pattern PATTERN_THINK_START = Pattern.compile("|<\\|begin_of_thought\\|>|"); @@ -146,6 +161,8 @@ public class ChatView extends ViewPart { private Button btnSettings; private Button btnHistory; + private FunctionCallSession functionCallSession = new FunctionCallSession(); + private ChatListener chatListener = new ChatListener() { @Override @@ -184,12 +201,26 @@ public void onMessageAdded(ChatMessage message, boolean updating) { }); } + @Override + public void onFunctionCall(ChatMessage message) { + functionCallSession.handleFunctionCall(message); + onMessageUpdated(message); + } + + private String getReexecuteIconBase64() { + // Material Design "replay" icon, fill #333333 + return "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZmlsbD0iIzMzMzMzMyI+PHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xMiA1VjFMNyA2bDUgNVY3YzMuMzEgMCA2IDIuNjkgNiA2cy0yLjY5IDYtNiA2LTYtMi42OS02LTZINGMwIDQuNDIgMy41OCA4IDggOHM4LTMuNTggOC04LTMuNTgtOC04LTh6Ii8+PC9zdmc+"; + } + private String getAttachmentIconHtml() { String dataUrl = "data:image/png;base64," + paperclipBase64; return String.format("", dataUrl); } private String messageContentToHtml(ChatMessage message) { + if (message.getRole() == Role.TOOL_SUMMARY) { // Add this new block + return toolSummaryToHtml(message); + } MessageContentWithReasoning thoughtsAndMessage = splitThoughtsFromMessage(message); String thinkHtml = ""; @@ -199,31 +230,313 @@ private String messageContentToHtml(ChatMessage message) { thoughtsAndMessage.isEndOfReasoningReached() ? "Thoughts" : "Thinking...", markdownRenderer.render(markdownParser.parse(thoughtsAndMessage.getThoughts()))); } + + String functionCallHtml = messageToolUseToHtml(message); + String messageHtml = markdownRenderer.render(markdownParser.parse(thoughtsAndMessage.getMessage())); - String combinedHtml = thinkHtml + messageHtml; + String combinedHtml = thinkHtml + messageHtml + functionCallHtml; return combinedHtml; } + private String messageToolUseToHtml(ChatMessage message) { + String functionCallHtml = ""; + if (message.getFunctionCall().isPresent()) { + FunctionCall call = message.getFunctionCall().get(); + FunctionResult result = message.getFunctionResult() + .orElse(new FunctionResult(call.getId(), call.getFunctionName())); + + // Build parameter divs + StringBuilder paramsTable = new StringBuilder("
"); + paramsTable.append("
Parameters
"); + + for (Map.Entry entry : call.getPrettyParams().entrySet()) { + String paramName = entry.getKey(); + FunctionParamValue paramValue = entry.getValue(); + String displayValue; + + if (paramValue.isMarkdown()) { + displayValue = markdownRenderer + .render(markdownParser.parse("```\n" + paramValue.getValue() + "\n```")); + } else { + displayValue = StringEscapeUtils.escapeHtml4(paramValue.getValue()); + } + + paramsTable.append( + String.format("
%s
%s
", + StringEscapeUtils.escapeHtml4(paramName), displayValue)); + } + paramsTable.append("
"); + + // Build result divs if we have results + StringBuilder resultTable = new StringBuilder(); + if (!result.getPrettyResults().isEmpty()) { + resultTable.append("
"); + resultTable.append("
Results
"); + + for (Map.Entry entry : result.getPrettyResults().entrySet()) { + String resultName = entry.getKey(); + FunctionParamValue resultValue = entry.getValue(); + String displayValue; + + if (resultValue.isMarkdown()) { + displayValue = markdownRenderer.render(markdownParser.parse(resultValue.getValue())); + } else { + displayValue = StringEscapeUtils.escapeHtml4(resultValue.getValue()); + } + + resultTable.append( + String.format("
%s
%s
", + StringEscapeUtils.escapeHtml4(resultName), displayValue)); + } + resultTable.append("
"); + } + + // Build raw JSON section + String rawArgsJson = ""; + if (StringUtils.isNotBlank(call.getArgsJson())) { + rawArgsJson = markdownRenderer + .render(markdownParser.parse("```json\n" + prettyPrintJson(call.getArgsJson()) + "\n```")); + } + + String rawResultJson = ""; + if (StringUtils.isNotBlank(result.getResultJson())) { + rawResultJson = markdownRenderer.render( + markdownParser.parse("```json\n" + prettyPrintJson(result.getResultJson()) + "\n```")); + } + + // Build combined raw JSON section + String rawJsonSection = ""; + if (StringUtils.isNotBlank(rawArgsJson) || StringUtils.isNotBlank(rawResultJson)) { + rawJsonSection = "
Raw JSON"; + if (StringUtils.isNotBlank(rawArgsJson)) { + rawJsonSection += "

Args:

" + rawArgsJson + "
"; + } + if (StringUtils.isNotBlank(rawResultJson)) { + rawJsonSection += "

Result:

" + rawResultJson + "
"; + } + rawJsonSection += "
"; + } + + // Combine everything into the final structure + // Build re-execute button HTML + String reexecuteButtonHtml = String.format( + "", + message.getId(), // Pass the message UUID + getReexecuteIconBase64() // Assuming this method is added to the class + ); + + functionCallHtml = String + .format("
Function call: %s" + + "
" + "%s" + // Params table + "%s" + // Result table + "%s" + // Raw JSON section + "
%s
" + // Container for action buttons + "
" + "
", StringEscapeUtils.escapeHtml4(call.getFunctionName()), + paramsTable.toString(), resultTable.toString(), rawJsonSection, reexecuteButtonHtml); // Add + // the + // re-execute + // button + // HTML + } + return functionCallHtml; + } + + private String toolSummaryToHtml(ChatMessage message) { + String contentHtml = markdownRenderer.render(markdownParser.parse(message.getContent())); + + // Create a "Re-execute All" button + String reexecuteAllButtonHtml = String.format( + "", + message.getId(), // Pass the summary message's UUID + getReexecuteIconBase64()); + + String actions = String.format("
%s
", reexecuteAllButtonHtml); + + return String.format("
%s%s
", contentHtml, actions); + } + @Override public void onChatResponseFinished(ChatMessage message) { Display.getDefault().asyncExec(() -> { - // Set text to "▶️" - btnSend.setText("\u25B6"); + if (message.getFunctionResult().isEmpty()) { + // Set text to "▶️" + btnSend.setText("\u25B6"); + + connection = null; + + if (isDebugPromptLoggingEnabled()) { + Activator.logInfo(conversation.toString()); + } - connection = null; + applyPendingChanges(); + + addConversationToHistory(); + } else { + sendFunctionResult(message); + } Display.getDefault().asyncExec(() -> { chat.markMessageFinished(message.getId()); }); + }); + } + }; + + public void reexecute(String messageUuidString) { + if (conversation == null || this.functionCallSession == null || this.chat == null) { + System.err.println( + "ChatView: Required components (conversation, functionCallSession, chatComponent) not available for re-execute."); + return; + } + + UUID messageUuid = UUID.fromString(messageUuidString); + + ChatMessage messageToReexecute = conversation.getMessages().stream() // Use conversation + .filter(m -> m.getId().equals(messageUuid)).findFirst().orElse(null); + + if (messageToReexecute != null) { + if (messageToReexecute.getFunctionCall().isPresent()) { + reexecuteToolCall(messageUuidString); + } + + if (messageToReexecute.getSummarizedToolCallIds() != null + && !messageToReexecute.getSummarizedToolCallIds().isEmpty()) { + reexecuteToolSummary(messageUuidString); + } + } + } + + private void reexecuteToolCall(String messageUuidString) { + // Ensure conversation, functionCallSession, and chat (ChatComponent) are + // initialized and available + if (conversation == null || this.functionCallSession == null || this.chat == null) { + System.err.println( + "ChatView: Required components (conversation, functionCallSession, chatComponent) not available for re-execute."); + return; + } + + UUID messageUuid = UUID.fromString(messageUuidString); + + ChatMessage messageToReexecute = conversation.getMessages().stream() // Use conversation + .filter(m -> m.getId().equals(messageUuid)).findFirst().orElse(null); + + if (messageToReexecute != null && messageToReexecute.getFunctionCall().isPresent()) { + FunctionCall call = messageToReexecute.getFunctionCall().get(); + System.out.println("ChatView: Re-executing tool call: " + call.getFunctionName() + " for message UUID: " + + messageUuidString); + + // Prepare the message for re-execution: + // Create a new, empty FunctionResult shell associated with the original call's + // ID and name. + // This ensures that handleFunctionCall populates this new shell. + FunctionResult newResultShell = new FunctionResult(call.getId(), call.getFunctionName()); + messageToReexecute.setFunctionResult(newResultShell); + + // Execute the function call again. + // This is expected to populate the 'newResultShell' within + // 'messageToReexecute'. + this.functionCallSession.handleFunctionCall(messageToReexecute); + + // Update this specific message in the UI to display the new result, + // using the listener's method to ensure correct HTML generation. + if (chatListener != null) { + chatListener.onMessageUpdated(messageToReexecute); + } else { + System.err.println("ChatView: chatListener is null, cannot update message view for re-execute."); + } + + this.functionCallSession.applyPendingChanges(); + } else { + Log.logError( + "ChatView: Cannot re-execute. Message not found, not a function call, or function call details missing for UUID: " + + messageUuidString); + } + } + + private void reexecuteToolSummary(String summaryMessageUuidString) { + if (conversation == null || this.functionCallSession == null) { + Log.logError("Cannot re-execute summary: conversation or session is null."); + return; + } + + UUID summaryMessageUuid = UUID.fromString(summaryMessageUuidString); + ChatMessage summaryMessage = conversation.getMessages().stream() + .filter(m -> m.getId().equals(summaryMessageUuid) && m.getRole() == Role.TOOL_SUMMARY).findFirst() + .orElse(null); + + if (summaryMessage == null) { + Log.logError("Could not find TOOL_SUMMARY message with UUID: " + summaryMessageUuidString); + return; + } + + // 1. IMPORTANT: Clear any changes from the previous run. + functionCallSession.clearPendingChanges(); + + List idsToReexecute = summaryMessage.getSummarizedToolCallIds(); + Log.logInfo("Re-executing tool summary for " + idsToReexecute.size() + " tool calls."); + + // 2. Re-process each tool call in the sequence + for (UUID messageId : idsToReexecute) { + ChatMessage messageToReexecute = conversation.getMessages().stream() + .filter(m -> m.getId().equals(messageId)).findFirst().orElse(null); - addConversationToHistory(); + if (messageToReexecute != null && messageToReexecute.getFunctionCall().isPresent()) { + // Reset the result from the previous run + messageToReexecute + .setFunctionResult(new FunctionResult(messageToReexecute.getFunctionCall().get().getId(), + messageToReexecute.getFunctionCall().get().getFunctionName())); - if (isDebugPromptLoggingEnabled()) { - Activator.logInfo(conversation.toString()); + // Re-handle the call. This will populate the new result and queue changes. + functionCallSession.handleFunctionCall(messageToReexecute); + + // Update the UI for this specific message to show it's processing again + if (chatListener != null) { + chatListener.onMessageUpdated(messageToReexecute); } - }); + } } - }; + + // 3. After all calls are re-processed, apply the newly accumulated changes. + // This will open the refactoring wizard with the new set of changes. + if (functionCallSession.hasPendingChanges()) { + functionCallSession.applyPendingChanges(); + } else { + Log.logInfo("Re-execution finished, but no pending changes were generated."); + // Optionally, add a message to the chat informing the user. + } + } + + private void clearAllPendingChanges() { + if (this.functionCallSession != null) { + this.functionCallSession.clearPendingChanges(); + Log.logInfo("All pending changes cleared by user action."); + } + } + + private void sendFunctionResult(ChatMessage message) { + if (connection != null && connection.isChatPending()) { + connection.abortChat(); + } + + Display.getDefault().asyncExec(() -> { + chat.markMessageFinished(message.getId()); + }); + + connection.chat(conversation, settings.getMaxResponseTokens()); + } + + protected String prettyPrintJson(String jsonString) { + JsonElement json = JsonParser.parseString(jsonString); + + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + String prettyJson = gson.toJson(json); + + return prettyJson; + } private static class MessageContentWithReasoning { private final String thoughts; @@ -350,6 +663,7 @@ public void widgetSelected(SelectionEvent e) { c.setConversationId(null); c.setCaption(null); } + updateSystemPrompt(c); replaceChat(c); } } @@ -684,6 +998,11 @@ private void initListeners() { // Reset settings, e.g. the chat model. init(); }); + + settings.addPropertyChangeListener("promptTemplate", e -> { + updateSystemPrompt(); + }); + IListChangeListener listChangeListener = e -> { for (ListDiffEntry diff : e.diff.getDifferences()) { MessageContext ctx = diff.getElement(); @@ -728,6 +1047,23 @@ private void initListeners() { }; externallyAddedContext.addListChangeListener(listChangeListener); + // Add context menu listener to the attachment composite itself + cmpAttachments.addMenuDetectListener(event -> { + Menu contextMenu = new Menu(cmpAttachments.getShell(), SWT.POP_UP); + MenuItem clearAllItem = new MenuItem(contextMenu, SWT.NONE); + clearAllItem.setText("Clear All"); + clearAllItem.addListener(SWT.Selection, evt -> { + if (!externallyAddedContext.isEmpty()) { + externallyAddedContext.clear(); + } + }); + // Disable if list is already empty + clearAllItem.setEnabled(!externallyAddedContext.isEmpty()); + + contextMenu.setLocation(event.x, event.y); + contextMenu.setVisible(true); + }); + chat.addProgressListener(ProgressListener.completedAdapter(event -> { final Browser bChat = chat.getBrowser(); final BrowserFunction function = new OnClickFunction(bChat, "elementClicked"); @@ -760,6 +1096,28 @@ public void changing(LocationEvent event) { }); } + private void updateSystemPrompt() { + ChatConversation chatToUpdate = conversation; + boolean changed = updateSystemPrompt(chatToUpdate); + if (changed) { + replaceChat(conversation); + } + } + + private boolean updateSystemPrompt(ChatConversation chatToUpdate) { + PromptTemplate promptTemplate = settings.getPromptTemplate(); + String templateString = promptTemplate == null ? null : promptTemplate.getPrompt(); + + // Replace system prompt if neccessary. + boolean changed; + if (StringUtils.isBlank(templateString)) { + changed = chatToUpdate.removeSystemMessage(); + } else { + changed = chatToUpdate.addOrReplaceSystemMessage(templateString); + } + return changed; + } + private void removeAttachmentLabel(MessageContext ctx) { if (cmpAttachments != null && !cmpAttachments.isDisposed() && cmpAttachments.getChildren() != null) { Control[] children = cmpAttachments.getChildren(); @@ -786,6 +1144,10 @@ private void sendMessageOrAbortChat() { if (connection.isChatPending()) { connection.abortChat(); + // apply pending changes, if any were added so far. + // this will also add a message summarizing the changes. + applyPendingChanges(); + chat.markAllMessagesFinished(); // Set text to "▶️" @@ -806,13 +1168,13 @@ private void sendMessageOrAbortChat() { selectionRange.x + selectionRange.y, consoleSelection)); } - externallyAddedContext.forEach(ctx -> addContextToMessageIfNotDuplicate(chatMessage, ctx.getFileName(), - ctx.getRangeType(), ctx.getStart(), ctx.getEnd(), ctx.getContent())); + externallyAddedContext.forEach(ctx -> addContextToMessageIfNotDuplicate(chatMessage, ctx)); externallyAddedContext.clear(); addSelectionAsContext(chatMessage); - conversation.getOptions().put(REASONING_ENABLED, settings.isReasoningEnabled()); + conversation.getOptions().put(REASONING_ENABLED, settings.isReasoningSupportedAndEnabled()); conversation.getOptions().put(REASONING_BUDGET_TOKENS, settings.getReasoningTokens()); + conversation.getOptions().put(TOOLS_ENABLED, settings.isToolsEnabled()); conversation.addMessage(chatMessage, true); connection.chat(conversation, settings.getMaxResponseTokens()); @@ -876,28 +1238,36 @@ private void addSelectionAsContext(ChatMessage chatMessage) { return; } - String fileName = textEditor.getEditorInput().getName(); + IEditorInput editorInput = textEditor.getEditorInput(); + String fileName; + if (editorInput instanceof IFileEditorInput fi) { + fileName = fi.getFile().getFullPath().toString(); + } else { + fileName = editorInput.getName(); + } + int startLine = textSelection.getStartLine(); int endLine = textSelection.getEndLine(); - String processedText = CodeUtil.removeCommonIndentation(selectedText); addContextToMessageIfNotDuplicate(chatMessage, fileName, RangeType.LINE, startLine + 1, endLine + 1, - processedText); + selectedText); } } public void addContextToMessageIfNotDuplicate(ChatMessage chatMessage, String fileName, RangeType rangeType, int start, int end, String selectedText) { + MessageContext newCtx = new MessageContext(fileName, rangeType, start, end, selectedText); + addContextToMessageIfNotDuplicate(chatMessage, newCtx); + } + + private void addContextToMessageIfNotDuplicate(ChatMessage chatMessage, MessageContext newCtx) { boolean duplicate = false; - // Process the selected text to remove common indentation - String processedText = CodeUtil.removeCommonIndentation(selectedText); - MessageContext newCtx = new MessageContext(fileName, rangeType, start, end, processedText); for (MessageContext ctx : chatMessage.getContext()) { if (newCtx.isDuplicate(ctx)) { duplicate = true; } } if (!duplicate) { - chatMessage.getContext().add(new MessageContext(fileName, start, end, processedText)); + chatMessage.getContext().add(newCtx); } } @@ -910,6 +1280,7 @@ private void clearChatInternal(ChatConversation replacement) { conversation = replacement; conversation.addListener(chatListener); externallyAddedContext.clear(); + clearAllPendingChanges(); chat.reset(); userInput.set(""); @@ -942,7 +1313,7 @@ private ChatConversation createNewChatConversation() { ChatConversation res = new ChatConversation(); if (settings.getPromptTemplate() != null) { - res.addMessage(new ChatMessage(Role.SYSTEM, settings.getPromptTemplate().getPrompt()), false); + res.addOrReplaceSystemMessage(settings.getPromptTemplate().getPrompt()); } return res; @@ -983,6 +1354,9 @@ public Object function(Object[] arguments) { } else if (str.startsWith("attachment:")) { // Add this case String attachmentUuid = str.substring("attachment:".length()); openAttachmentDialog(attachmentUuid); + } else if (str.startsWith("reexecute:")) { + String messageUuid = str.substring("reexecute:".length()); + reexecute(messageUuid); } } return null; @@ -1028,15 +1402,15 @@ public void copyMessageToClipboard(String messageUuidString) { TextTransfer textTransfer = TextTransfer.getInstance(); MessageContentWithReasoning thoughtsAndMessage = splitThoughtsFromMessage(message); - String messageMarkdown = thoughtsAndMessage.getMessage(); + StringBuilder sb = new StringBuilder(thoughtsAndMessage.getMessage()); + if (!message.getContext().isEmpty()) { - StringBuilder sb = new StringBuilder(); - sb.append("\n\n#Context:\n"); - sb.append(message.getContext().stream().map(c -> c.compile()).collect(Collectors.joining("\n"))); - messageMarkdown += sb.toString(); + sb.append("\n\n# Context:\n"); + sb.append(message.getContext().stream().map(c -> c.compile(true)).collect(Collectors.joining("\n"))); } + sb.append(message.getToolCallDetailsAsMarkdown()); - clipboard.setContents(new Object[] { messageMarkdown }, new Transfer[] { textTransfer }); + clipboard.setContents(new Object[] { sb.toString() }, new Transfer[] { textTransfer }); clipboard.dispose(); } } @@ -1249,6 +1623,36 @@ private void createPaperclipBase64() { paperclipBase64 = java.util.Base64.getEncoder().encodeToString(baos.toByteArray()); } + private void applyPendingChanges() { + // Apply pending changes after all function calls are done. + if (functionCallSession.hasPendingChanges()) { + // 1. Identify the sequence of tool calls that just finished. + Set messagesWithPendingChanges = new HashSet<>( + functionCallSession.getMessagesWithPendingChanges()); + List toolCallSequence = new ArrayList<>(); + conversation.getMessages().stream().filter(m -> messagesWithPendingChanges.contains(m.getId())) + .forEach(toolCallSequence::add); + + // 2. Create the new TOOL_SUMMARY message + // Get the detailed summary from the session + String summaryContent = functionCallSession.getPendingChangesSummary(); + ChatMessage summaryMessage = new ChatMessage(Role.TOOL_SUMMARY, summaryContent); + + // 3. Populate the summary message with the IDs of the calls + for (ChatMessage msg : toolCallSequence) { + if (msg.getFunctionCall().isPresent()) { + summaryMessage.getSummarizedToolCallIds().add(msg.getId()); + } + } + + // 4. Add the summary message to the conversation + conversation.addMessage(summaryMessage, false); // false because it's a final message + + // 5. Trigger the refactoring dialog as before + functionCallSession.applyPendingChanges(); + } + } + private String ONCLICK_LISTENER = "document.onmousedown = function(e) {" + "if (!e) {e = window.event;} " + "if (e) {var target = e.target || e.srcElement; " + "var elementId = target.id ? target.id : 'no-id';" + "elementClicked(elementId);}}"; diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/FunctionCallSession.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/FunctionCallSession.java new file mode 100644 index 0000000..384b749 --- /dev/null +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/FunctionCallSession.java @@ -0,0 +1,816 @@ +package com.chabicht.code_intelligence.chat; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.CompositeChange; +import org.eclipse.ltk.core.refactoring.MultiStateTextFileChange; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; +import org.eclipse.swt.widgets.Display; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PlatformUI; + +import com.chabicht.code_intelligence.Activator; +import com.chabicht.code_intelligence.chat.tools.ApplyChangeTool; +import com.chabicht.code_intelligence.chat.tools.ApplyPatchTool; +import com.chabicht.code_intelligence.chat.tools.BufferedResourceAccess; +import com.chabicht.code_intelligence.chat.tools.CreateFileTool; // Added import +import com.chabicht.code_intelligence.chat.tools.FindFilesTool; // Add this import +import com.chabicht.code_intelligence.chat.tools.IResourceAccess; +import com.chabicht.code_intelligence.chat.tools.ListProjectsTool; +import com.chabicht.code_intelligence.chat.tools.ReadFileContentTool; +import com.chabicht.code_intelligence.chat.tools.ResourceAccess; +import com.chabicht.code_intelligence.chat.tools.TextSearchTool; +import com.chabicht.code_intelligence.chat.tools.ToolChangePreparationResult; +import com.chabicht.code_intelligence.model.ChatConversation.ChatMessage; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionCall; +import com.chabicht.code_intelligence.model.ChatConversation.FunctionResult; +import com.chabicht.code_intelligence.util.GsonUtil; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; + +public class FunctionCallSession { + private final IResourceAccess realResourceAccess; + private final BufferedResourceAccess bufferedResourceAccess; + + // Tools use the buffered resource access to see pending changes + private final ApplyChangeTool applyChangeTool; + private final ApplyPatchTool applyPatchTool; + private final ReadFileContentTool readFileContentTool; + private final CreateFileTool createFileTool; + private final TextSearchTool searchTool; + private final FindFilesTool findFilesTool; // Add this field + private final ListProjectsTool listProjectsTool; + private final Gson gson = GsonUtil.createGson(); + + private final Map pendingTextFileChanges = new HashMap<>(); + private final Map pendingCreateFileChanges = new HashMap<>(); + private final List messagesWithPendingChanges = new ArrayList<>(); + + public FunctionCallSession() { + // Create the real resource access + this.realResourceAccess = new ResourceAccess(); + + // Create the buffered wrapper that applies pending changes transparently + this.bufferedResourceAccess = new BufferedResourceAccess(realResourceAccess, this); + + // Initialize all tools with the buffered resource access so they can see + // pending changes + this.applyChangeTool = new ApplyChangeTool(bufferedResourceAccess); + this.applyPatchTool = new ApplyPatchTool(bufferedResourceAccess); + this.readFileContentTool = new ReadFileContentTool(bufferedResourceAccess); + this.createFileTool = new CreateFileTool(); // Doesn't use resource access + this.searchTool = new TextSearchTool(bufferedResourceAccess); + this.findFilesTool = new FindFilesTool(bufferedResourceAccess); // Add this line + this.listProjectsTool = new ListProjectsTool(realResourceAccess); + + Activator.logInfo("FunctionCallSession: Initialized with BufferedResourceAccess"); + } + + private MultiStateTextFileChange getOrCreateMultiStateTextFileChange(IFile file) { + String fullPath = file.getFullPath().toString(); + return pendingTextFileChanges.computeIfAbsent(fullPath, k -> { + MultiStateTextFileChange mstfc = new MultiStateTextFileChange("Changes for " + file.getName(), file); + if (file.getFileExtension() != null) { + mstfc.setTextType(file.getFileExtension()); + } else { + mstfc.setTextType("txt"); + } + Activator.logInfo("Created new MultiStateTextFileChange for: " + fullPath); + return mstfc; + }); + } + + public ApplyChangeTool getApplyChangeTool() { + return applyChangeTool; + } + + public ApplyPatchTool getApplyPatchTool() { + return applyPatchTool; + } + + /** + * Handles an incoming function call from the AI model. Routes the call to the + * appropriate tool based on the function name. + * + * @param message + * + * @param functionName The name of the function to call (e.g., + * "apply_change"). + * @param functionArgsJson A JSON string representing the arguments for the + * function. + * @return + */ + public void handleFunctionCall(ChatMessage message) { + Optional callOpt = message.getFunctionCall(); + if (callOpt.isPresent()) { + FunctionCall call = callOpt.get(); + String functionName = call.getFunctionName(); + String argsJson = call.getArgsJson(); + + FunctionResult result = new FunctionResult(call.getId(), functionName); + + switch (functionName) { + case "apply_change": + handleApplyChange(message.getId(), call, result, argsJson); + break; + case "apply_patch": + handleApplyPatch(message.getId(), call, result, argsJson); + break; + case "perform_text_search": + handlePerformSearch(call, result, argsJson, false); + break; + case "perform_regex_search": + handlePerformSearch(call, result, argsJson, true); + break; + case "read_file_content": + handleReadFileContent(call, result, argsJson); + break; + case "create_file": // Added case for create_file + handleCreateFile(message.getId(), call, result, argsJson); + break; + case "find_files": // Add this new case + handleFindFiles(call, result, argsJson); + break; + case "list_projects": + handleListProjects(call, result, argsJson); + break; + default: + Activator.logError("Unsupported function call: " + functionName); + break; + } + + message.setFunctionResult(result); + } + } + + /** + * Specifically handles the "apply_change" function call. Parses arguments and + * adds the change to the ApplyChangeTool queue. + * + * @param call The FunctionCall object + * @param result The FunctionResult object to populate + * @param functionArgsJson JSON arguments for apply_change. + */ + private void handleApplyChange(UUID messageId, FunctionCall call, FunctionResult result, String functionArgsJson) { + try { + JsonObject args = gson.fromJson(functionArgsJson, JsonObject.class); + String fileName = args.has("file_name") ? args.get("file_name").getAsString() : null; + String location = args.has("location_in_file") ? args.get("location_in_file").getAsString() : null; + String originalText = args.has("original_text") ? args.get("original_text").getAsString() : null; + String replacementText = args.has("replacement_text") ? args.get("replacement_text").getAsString() : null; + + // Basic validation + if (fileName == null || location == null || originalText == null || replacementText == null) { + String errorMsg = "Missing required argument for apply_change. Args: " + functionArgsJson; + Activator.logError(errorMsg); + result.addPrettyResult("error", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + return; + } + + ToolChangePreparationResult prepResult = applyChangeTool.prepareChange(fileName, location, originalText, + replacementText); + + call.addPrettyParam("file_name", fileName, false); + call.addPrettyParam("location_in_file", location, false); + call.addPrettyParam("original_text", originalText, true); + call.addPrettyParam("replacement_text", replacementText, true); + + JsonObject jsonResult = new JsonObject(); + if (prepResult.isSuccess()) { + MultiStateTextFileChange mstfc = getOrCreateMultiStateTextFileChange(prepResult.getFile()); + TextFileChange tfc = new TextFileChange("Apply Change", prepResult.getFile()); + MultiTextEdit multiEdit = new MultiTextEdit(); + multiEdit.addChildren(prepResult.getEdits().toArray(new TextEdit[0])); + tfc.setEdit(multiEdit); + mstfc.addChange(tfc); + + result.addPrettyResult("preview", "```diff\n" + prepResult.getDiffPreview() + "\n```", true); + result.addPrettyResult("status", "Change Queued", false); + jsonResult.addProperty("status", "Queued"); + messagesWithPendingChanges.add(messageId); + } else { + result.addPrettyResult("status", "Error", false); + jsonResult.addProperty("status", "Error"); + } + result.addPrettyResult("message", prepResult.getMessage(), false); + jsonResult.addProperty("message", prepResult.getMessage()); + result.setResultJson(gson.toJson(jsonResult)); + } catch (Exception e) { + String errorMsg = "Error processing apply_change function call: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + } + } + + /** + * Specifically handles the "apply_patch" function call. Parses arguments and + * adds the changes from the patch to the ApplyPatchTool queue. + * + * @param call The FunctionCall object + * @param result The FunctionResult object to populate + * @param functionArgsJson JSON arguments for apply_patch. Expected: + * {"file_name": "...", "patch_content": "..."} + */ + private void handleApplyPatch(UUID messageId, FunctionCall call, FunctionResult result, String functionArgsJson) { + try { + JsonObject args = gson.fromJson(functionArgsJson, JsonObject.class); + String fileName = args.has("file_name") ? args.get("file_name").getAsString() : null; + String patchContent = args.has("patch_content") ? args.get("patch_content").getAsString() : null; + + if (fileName == null || patchContent == null) { + String errorMsg = "Missing required argument for apply_patch. Expected file_name and patch_content. Args: " + + functionArgsJson; + Activator.logError(errorMsg); + result.addPrettyResult("error", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + return; + } + + ToolChangePreparationResult prepResult = applyPatchTool.preparePatchChange(fileName, patchContent); + + call.addPrettyParam("file_name", fileName, false); + call.addPrettyParam("patch_content", "```diff\n" + patchContent + "\n```", true); + + JsonObject jsonResult = new JsonObject(); + if (prepResult.isSuccess()) { + MultiStateTextFileChange mstfc = getOrCreateMultiStateTextFileChange(prepResult.getFile()); + TextFileChange tfc = new TextFileChange("Apply Patch", prepResult.getFile()); + tfc.setEdit(prepResult.getEdits().get(0)); // Patch tool returns one big ReplaceEdit + mstfc.addChange(tfc); + + result.addPrettyResult("status", "Patch Queued", false); + jsonResult.addProperty("status", "Queued"); + messagesWithPendingChanges.add(messageId); + } else { + result.addPrettyResult("status", "Error", false); + jsonResult.addProperty("status", "Error"); + } + result.addPrettyResult("message", prepResult.getMessage(), true); + jsonResult.addProperty("message", prepResult.getMessage()); + result.setResultJson(gson.toJson(jsonResult)); + } catch (Exception e) { + String errorMsg = "Error processing apply_patch function call: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + } + } + + private void handlePerformSearch(FunctionCall call, FunctionResult result, String functionArgsJson, + boolean isRegEx) { + try { + // Parse the JSON arguments directly into a JsonObject + JsonObject args = gson.fromJson(functionArgsJson, JsonObject.class); + + // Extract arguments using the names defined in the function declaration + String searchText = null; + String searchParamName = null; + if (args.has("search_text")) { + searchText = args.get("search_text").getAsString(); + searchParamName = "search_text"; + } else if (args.has("search_pattern")) { + searchText = args.get("search_pattern").getAsString(); + searchParamName = "search_pattern"; + } + + List fileNamePatterns = null; + if (args.has("file_name_patterns") && !args.get("file_name_patterns").isJsonNull()) { + JsonArray patternsArray = args.get("file_name_patterns").getAsJsonArray(); + fileNamePatterns = new ArrayList<>(); + for (JsonElement element : patternsArray) { + fileNamePatterns.add(element.getAsString()); + } + } + + boolean isCaseSensitive = args.has("is_case_sensitive") ? args.get("is_case_sensitive").getAsBoolean() + : false; + boolean isWholeWord = args.has("is_whole_word") ? args.get("is_whole_word").getAsBoolean() : false; + + // Basic validation + if (searchText == null) { + String errorMsg = "Missing required argument (search_text) for perform_text_search. Args: " + + functionArgsJson; + Activator.logError(errorMsg); + result.addPrettyResult("error", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + return; + } + + TextSearchTool.SearchExecutionResult searchExecResult = searchTool.performSearch(searchText, isRegEx, + isCaseSensitive, isWholeWord, fileNamePatterns); + + call.addPrettyParam(searchParamName, searchText, isRegEx); // Mark as code if regex + if (fileNamePatterns != null) { + call.addPrettyParam("file_name_patterns", gson.toJson(fileNamePatterns), false); + } else { + call.addPrettyParam("file_name_patterns", "all files", false); + } + call.addPrettyParam("is_case_sensitive", String.valueOf(isCaseSensitive), false); + if (!isRegEx) { + call.addPrettyParam("is_whole_word", String.valueOf(isWholeWord), false); + } + + JsonObject jsonResult = new JsonObject(); + if (searchExecResult.isSuccess()) { + result.addPrettyResult("status", "Success", false); + result.addPrettyResult("message", searchExecResult.getMessage(), false); + jsonResult.addProperty("status", "Success"); + jsonResult.addProperty("message", searchExecResult.getMessage()); + + StringBuilder resultsPreview = new StringBuilder(); + resultsPreview.append("Found ").append(searchExecResult.getResults().size()).append(" matches:\n"); + for (TextSearchTool.SearchResultItem item : searchExecResult.getResults()) { + resultsPreview.append(String.format("- %s (Line %d): `%s` (Matched: `%s`)\n", item.getFilePath(), + item.getLineNumber(), item.getLineContent(), item.getMatchedText())); + } + result.addPrettyResult("search_results_summary", resultsPreview.toString(), true); // Markdown for code + // backticks + jsonResult.add("results", gson.toJsonTree(searchExecResult.getResults())); + + } else { + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", searchExecResult.getMessage(), false); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", searchExecResult.getMessage()); + } + result.setResultJson(gson.toJson(jsonResult)); + + } catch (JsonSyntaxException e) { + String errorMsg = "Failed to parse JSON arguments for perform_text_search: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + // ... set JSON error result ... + } catch (Exception e) { + String errorMsg = "Error processing perform_text_search function call: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + // ... set JSON error result ... + } + } + + /** + * Specifically handles the "read_file_content" function call. Parses arguments + * and reads the content of the specified file or line range. + * + * @param call The FunctionCall object + * @param result The FunctionResult object to populate + * @param functionArgsJson JSON arguments for read_file_content. Expected: + * {"file_name": "...", "start_line": ..., "end_line": + * ...} + */ + private void handleReadFileContent(FunctionCall call, FunctionResult result, String functionArgsJson) { + try { + JsonObject args = gson.fromJson(functionArgsJson, JsonObject.class); + + String fileName = args.has("file_name") ? args.get("file_name").getAsString() : null; + Integer startLine = args.has("start_line") && !args.get("start_line").isJsonNull() + ? args.get("start_line").getAsInt() + : null; + Integer endLine = args.has("end_line") && !args.get("end_line").isJsonNull() + ? args.get("end_line").getAsInt() + : null; + + if (fileName == null) { + String errorMsg = "Missing required argument 'file_name' for read_file_content. Args: " + + functionArgsJson; + Activator.logError(errorMsg); + result.addPrettyResult("error", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + return; + } + + // Populate pretty params for the call + call.addPrettyParam("file_name", fileName, false); + if (startLine != null) { + call.addPrettyParam("start_line", String.valueOf(startLine), false); + } + if (endLine != null) { + call.addPrettyParam("end_line", String.valueOf(endLine), false); + } + + ReadFileContentTool.ReadFileContentResult readResult = readFileContentTool.readFileContent(fileName, + startLine, endLine); + + JsonObject jsonResponse = new JsonObject(); + if (readResult.isSuccess()) { + result.addPrettyResult("status", "Success", false); + result.addPrettyResult("message", readResult.getMessage(), false); + // Add file path and actual range to pretty results for clarity + result.addPrettyResult("file_path_read", + readResult.getFilePath() != null ? readResult.getFilePath() : "N/A", false); + if (readResult.getActualStartLine() > 0 || readResult.getActualEndLine() > 0) { // Check if a valid + // range was read + result.addPrettyResult("lines_read", + readResult.getActualStartLine() + " - " + readResult.getActualEndLine(), false); + } else if (readResult.getFilePath() != null && readResult.getContentWithLineNumbers() != null + && readResult.getContentWithLineNumbers().isEmpty()) { + result.addPrettyResult("lines_read", "File is empty", false); + } + + // The content itself is the main result, show it as markdown (code block) + String contentToDisplay = readResult.getContentWithLineNumbers(); + result.addPrettyResult("file_content", "```\n" + contentToDisplay + "\n```", true); + + jsonResponse.addProperty("status", "Success"); + jsonResponse.addProperty("message", readResult.getMessage()); + jsonResponse.addProperty("file_path", readResult.getFilePath()); + jsonResponse.addProperty("content", readResult.getContentWithLineNumbers()); // Prefixed content + jsonResponse.addProperty("actual_start_line", readResult.getActualStartLine()); + jsonResponse.addProperty("actual_end_line", readResult.getActualEndLine()); + } else { + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", readResult.getMessage(), false); + jsonResponse.addProperty("status", "Error"); + jsonResponse.addProperty("message", readResult.getMessage()); + } + result.setResultJson(gson.toJson(jsonResponse)); + + } catch (Exception e) { // Catch general Exception + String errorMsg = "Error processing read_file_content function call: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + } + } + + /** + * Specifically handles the "create_file" function call. Parses arguments and + * attempts to create a new file with the given content. + * + * @param call The FunctionCall object + * @param result The FunctionResult object to populate + * @param functionArgsJson JSON arguments for create_file. Expected: + * {"file_path": "...", "content": "..."} + */ + private void handleCreateFile(UUID messageId, FunctionCall call, FunctionResult result, String functionArgsJson) { + try { + JsonObject args = gson.fromJson(functionArgsJson, JsonObject.class); + String filePath = args.has("file_path") ? args.get("file_path").getAsString() : null; + String content = args.has("content") ? args.get("content").getAsString() : null; + + if (filePath == null || content == null) { + String errorMsg = "Missing required arguments for create_file. Expected 'file_path' and 'content'. Args: " + + functionArgsJson; + Activator.logError(errorMsg); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + return; + } + + CreateFileTool.CreateFilePreparationResult prepResult = createFileTool.prepareCreateFileChange(filePath, + content); + + call.addPrettyParam("file_path", filePath, false); + call.addPrettyParam("content", content, true); + + JsonObject jsonResponse = new JsonObject(); + if (prepResult.isSuccess()) { + pendingCreateFileChanges.put(filePath, prepResult.getChange()); + result.addPrettyResult("status", "Queued for Review", false); + jsonResponse.addProperty("status", "Queued"); + messagesWithPendingChanges.add(messageId); + } else { + result.addPrettyResult("status", "Error", false); + jsonResponse.addProperty("status", "Error"); + } + result.addPrettyResult("message", prepResult.getMessage(), false); + jsonResponse.addProperty("message", prepResult.getMessage()); + result.setResultJson(gson.toJson(jsonResponse)); + } catch (Exception e) { + String errorMsg = "Error processing create_file function call: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + } + } + + /** + * Checks if there are any pending changes accumulated from any tool. + * + * @return true if there are pending changes, false otherwise. + */ + public boolean hasPendingChanges() { + return !pendingTextFileChanges.isEmpty() || !pendingCreateFileChanges.isEmpty(); + } + + /** + * Triggers the application of all accumulated changes from all tools. This + * typically shows a preview dialog to the user for each tool's changes. + */ + public void applyPendingChanges() { + CompositeChange rootChange = new CompositeChange("Apply AI Suggested Code Changes"); + + rootChange.addAll(pendingTextFileChanges.values().toArray(new Change[0])); + rootChange.addAll(pendingCreateFileChanges.values().toArray(new Change[0])); + + try { + if (rootChange.getChildren().length > 0) { + launchRefactoringWizard(rootChange); + } else { + Activator.logInfo("No pending changes from any tool to apply."); + } + } catch (Exception e) { + Activator.logError("Failed to initiate refactoring process: " + e.getMessage(), e); + } finally { + clearPendingChanges(); + } + } + + /** + * Clears any pending changes that have been accumulated but not yet applied + * from all tools. + */ + public void clearPendingChanges() { + pendingTextFileChanges.clear(); + pendingCreateFileChanges.clear(); + messagesWithPendingChanges.clear(); + + // Clear the buffer caches + if (bufferedResourceAccess != null) { + bufferedResourceAccess.clearCaches(); + Activator.logInfo("FunctionCallSession: Cleared pending changes and buffer caches"); + } + } + + public List getMessagesWithPendingChanges() { + return messagesWithPendingChanges; + } + + /** + * Gets the map of pending text file changes. The key is the file path, the + * value is the MultiStateTextFileChange. + * + * @return Unmodifiable view of pending text file changes + */ + public Map getPendingTextFileChanges() { + return java.util.Collections.unmodifiableMap(pendingTextFileChanges); + } + + /** + * Gets the map of pending create file changes. The key is the file path, the + * value is the Change object. + * + * @return Unmodifiable view of pending create file changes + */ + public Map getPendingCreateFileChanges() { + return java.util.Collections.unmodifiableMap(pendingCreateFileChanges); + } + + /** + * Generates a user-friendly summary of all pending file creations and + * modifications. + * + * @return A string summarizing the pending changes, formatted for display in + * the chat. + */ + public String getPendingChangesSummary() { + List modifiedFiles = new ArrayList<>(pendingTextFileChanges.keySet()); + List createdFiles = new ArrayList<>(pendingCreateFileChanges.keySet()); + + java.util.Collections.sort(modifiedFiles); + java.util.Collections.sort(createdFiles); + + if (modifiedFiles.isEmpty() && createdFiles.isEmpty()) { + return "Tool usage complete. No file changes were queued."; + } + + StringBuilder summary = new StringBuilder("Tool usage complete. The following changes are queued for review:"); + + for (String path : createdFiles) { + summary.append("\n- **Create:** `").append(path).append("`"); + } + for (String path : modifiedFiles) { + summary.append("\n- **Modify:** `").append(path).append("`"); + } + + return summary.toString(); + } + + private void launchRefactoringWizard(CompositeChange rootChange) { + Refactoring refactoring = new Refactoring() { + @Override + public String getName() { + return "Apply AI Suggested Code Changes"; // Wizard title + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) { + return new RefactoringStatus(); + } + + @Override + public RefactoringStatus checkFinalConditions(IProgressMonitor pm) { + return new RefactoringStatus(); + } + + @Override + public Change createChange(IProgressMonitor pm) { + return rootChange; + } + }; + + Display.getDefault().asyncExec(() -> { + try { + IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); + if (window == null) { + Activator.logError("Cannot apply AI changes: No active workbench window."); + return; + } + RefactoringWizard wizard = new RefactoringWizard(refactoring, + RefactoringWizard.DIALOG_BASED_USER_INTERFACE | RefactoringWizard.PREVIEW_EXPAND_FIRST_NODE) { + @Override + protected void addUserInputPages() { + // No custom input pages needed + } + }; + RefactoringWizardOpenOperation operation = new RefactoringWizardOpenOperation(wizard); + operation.run(window.getShell(), "Preview AI Suggested Changes"); // Dialog title + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Activator.logError("AI changes refactoring wizard interrupted: " + e.getMessage(), e); + } catch (Exception e) { + Activator.logError("Failed to open or run AI changes refactoring wizard: " + e.getMessage(), e); + } + }); + } + + private void handleFindFiles(FunctionCall call, FunctionResult result, String functionArgsJson) { + try { + JsonObject args = gson.fromJson(functionArgsJson, JsonObject.class); + + String pattern = args.has("file_path_pattern") ? args.get("file_path_pattern").getAsString() : null; + + List projectNames = null; + if (args.has("project_names") && !args.get("project_names").isJsonNull()) { + JsonArray projectsArray = args.get("project_names").getAsJsonArray(); + projectNames = new ArrayList<>(); + for (JsonElement element : projectsArray) { + projectNames.add(element.getAsString()); + } + } + + boolean isCaseSensitive = args.has("is_case_sensitive") ? args.get("is_case_sensitive").getAsBoolean() + : false; + + if (pattern == null) { + String errorMsg = "Missing required argument 'file_path_pattern' for find_files."; + Activator.logError(errorMsg); + result.addPrettyResult("error", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + return; + } + + // Add pretty params to the call for UI display + call.addPrettyParam("file_path_pattern", pattern, true); // True for code formatting + if (projectNames != null && !projectNames.isEmpty()) { + call.addPrettyParam("project_names", gson.toJson(projectNames), false); + } else { + call.addPrettyParam("project_names", "all projects", false); + } + call.addPrettyParam("is_case_sensitive", String.valueOf(isCaseSensitive), false); + + FindFilesTool.FindFilesResult findResult = findFilesTool.findFiles(pattern, projectNames, isCaseSensitive); + + JsonObject jsonResponse = new JsonObject(); + if (findResult.isSuccess()) { + result.addPrettyResult("status", "Success", false); + jsonResponse.addProperty("status", "Success"); + + // Create a markdown list for the pretty result (for the user) + StringBuilder filesPreview = new StringBuilder(); + filesPreview.append("Found ").append(findResult.getFilePaths().size()).append(" matching files:\n"); + for (String path : findResult.getFilePaths()) { + filesPreview.append("- `").append(path).append("`\n"); + } + result.addPrettyResult("found_files_summary", filesPreview.toString(), true); + + // Create a structured JSON array for the model, similar to TextSearchTool + JsonArray resultsArray = new JsonArray(); + for (String path : findResult.getFilePaths()) { + JsonObject fileObject = new JsonObject(); + fileObject.addProperty("file_path", path); + resultsArray.add(fileObject); + } + jsonResponse.add("results", resultsArray); + + } else { + result.addPrettyResult("status", "Error", false); + jsonResponse.addProperty("status", "Error"); + } + result.addPrettyResult("message", findResult.getMessage(), false); + jsonResponse.addProperty("message", findResult.getMessage()); + result.setResultJson(gson.toJson(jsonResponse)); + + } catch (Exception e) { + String errorMsg = "Error processing find_files function call: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + } + } + + private void handleListProjects(FunctionCall call, FunctionResult result, String functionArgsJson) { + try { + // This tool takes no arguments. + call.addPrettyParam("action", "list all projects", false); + + ListProjectsTool.ListProjectsResult listResult = listProjectsTool.listProjects(); + + JsonObject jsonResponse = new JsonObject(); + if (listResult.isSuccess()) { + result.addPrettyResult("status", "Success", false); + jsonResponse.addProperty("status", "Success"); + + // Create a markdown list for the pretty result (for the user) + StringBuilder projectsPreview = new StringBuilder(); + projectsPreview.append("Found ").append(listResult.getProjects().size()) + .append(" projects in the workspace:\n"); + for (ListProjectsTool.ProjectInfo info : listResult.getProjects()) { + projectsPreview.append("- `").append(info.getProjectName()).append("` (") + .append(info.isOpen() ? "Open" : "Closed").append(")\n"); + } + result.addPrettyResult("projects_summary", projectsPreview.toString(), true); + + // Create a structured JSON array for the model + jsonResponse.add("projects", gson.toJsonTree(listResult.getProjects())); + + } else { + result.addPrettyResult("status", "Error", false); + jsonResponse.addProperty("status", "Error"); + } + result.addPrettyResult("message", listResult.getMessage(), false); + jsonResponse.addProperty("message", listResult.getMessage()); + result.setResultJson(gson.toJson(jsonResponse)); + + } catch (Exception e) { + String errorMsg = "Error processing list_projects function call: " + e.getMessage(); + Activator.logError(errorMsg, e); + result.addPrettyResult("status", "Error", false); + result.addPrettyResult("message", errorMsg, false); + JsonObject jsonResult = new JsonObject(); + jsonResult.addProperty("status", "Error"); + jsonResult.addProperty("message", errorMsg); + result.setResultJson(gson.toJson(jsonResult)); + } + } +} diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/MessageContextDialog.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/MessageContextDialog.java index a6e6f35..7714906 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/MessageContextDialog.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/MessageContextDialog.java @@ -39,7 +39,7 @@ protected Control createDialogArea(Composite parent) { StyledText styledText = new StyledText(container, SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL); styledText.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); - styledText.setText(messageContext.getContent()); + styledText.setText(messageContext.compile()); styledText.setFont(ThemeUtil.getTextEditorFont()); styledText.setEditable(false); diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/chat-template.html b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/chat-template.html index 80c53de..718385d 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/chat-template.html +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/chat-template.html @@ -166,7 +166,7 @@ .floating-header { position: fixed !important; top: 10px !important; - right: 10px !important; + right: 27px !important; z-index: 1000 !important; } @@ -276,11 +276,82 @@ td:last-child { border-bottom: 0; } - /* Remove vertical lines in responsive mode */ - td, th { - border-right: none; - } - } + /* Remove vertical lines in responsive mode */ + td, th { + border-right: none; + } + } + + /* Function call parameter and result styles */ + .function-params-container, + .function-results-container { + width: 100%; + margin: 15px 0; + border: 1px solid #bfbfbf; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + background-color: #ffffff; + } + + .params-header, + .results-header { + padding: 10px 12px; + background-color: #f5f5f5; + font-weight: bold; + color: #333333; + border-bottom: 2px solid #bfbfbf; + } + + .param-name, + .result-name { + padding: 10px 12px; + font-weight: bold; + background-color: #f9f9f9; + border-bottom: 1px solid #bfbfbf; + } + + .param-value, + .result-value { + padding: 10px 12px; + border-bottom: 1px solid #bfbfbf; + background-color: #ffffff; + white-space: pre-wrap; + } + + /* Remove bottom border from the last value in a container */ + .function-params-container > .param-value:last-child, + .function-results-container > .result-value:last-child { + border-bottom: none; + } + + .tool-actions { + margin-top: 10px; + text-align: right; /* Adjust alignment as needed */ + } + + .tool-action-button { + background-color: #FFFFFF; + border: 1px solid #CCCCCC; + color: #333333; + cursor: pointer; + font-size: 12px; + padding: 5px 8px; + border-radius: 4px; + margin-left: 5px; + display: inline-flex; /* Helps align icon and text */ + align-items: center; + gap: 5px; /* Space between icon (if any) and text */ + } + + .tool-action-button:hover { + background-color: #F0F0F0; /* Or your theme's hover color */ + } + + /* Ensure img within button is aligned if not handled by inline styles */ + .tool-action-button img { + vertical-align: middle; + }