From 3d18619e982a8d9cbe6620688af99680fec78dbc Mon Sep 17 00:00:00 2001 From: vudayani Date: Wed, 25 Sep 2024 09:25:02 +0530 Subject: [PATCH] copilot spring boot agent implementation --- .../gradle/GradleProjectClasspath.java | 5 + .../vscode/commons/java/ClasspathData.java | 19 ++- .../java/DelegatingCachedClasspath.java | 5 + .../ide/vscode/commons/java/IClasspath.java | 8 +- .../commons/jandex/JandexClasspathTest.java | 2 +- .../commons/protocol/java/Classpath.java | 16 ++- .../maven/java/MavenProjectClasspath.java | 10 +- .../ls/commons/classpath/ClasspathUtil.java | 22 +++- .../SendClasspathNotificationsJob.java | 2 +- .../spring-boot-language-server/pom.xml | 2 +- .../WorkspaceBootExecutableProjects.java | 46 ++++++- .../vscode/boot/jdt/ls/JdtLsProjectCache.java | 2 +- .../boot/java/value/test/MockProjects.java | 8 ++ .../vscode-spring-boot/lib/Main.ts | 25 +++- .../lib/copilot/copilotRequest.ts | 90 +++++++++++++ .../lib/copilot/guideApply.ts | 35 +++++ .../lib/copilot/springBootAgent.ts | 110 ++++++++++++++++ .../lib/copilot/springCli.ts | 121 ++++++++++++++++++ .../lib/copilot/system-ai-prompt.ts | 46 +++++++ .../lib/copilot/user-ai-prompt.ts | 9 ++ .../vscode-spring-boot/lib/copilot/util.ts | 91 +++++++++++++ .../vscode-spring-boot/package.json | 35 +++++ .../readme-imgs/spring-tools-icon.png | Bin 0 -> 2668 bytes 23 files changed, 693 insertions(+), 16 deletions(-) create mode 100644 vscode-extensions/vscode-spring-boot/lib/copilot/copilotRequest.ts create mode 100644 vscode-extensions/vscode-spring-boot/lib/copilot/guideApply.ts create mode 100644 vscode-extensions/vscode-spring-boot/lib/copilot/springBootAgent.ts create mode 100644 vscode-extensions/vscode-spring-boot/lib/copilot/springCli.ts create mode 100644 vscode-extensions/vscode-spring-boot/lib/copilot/system-ai-prompt.ts create mode 100644 vscode-extensions/vscode-spring-boot/lib/copilot/user-ai-prompt.ts create mode 100644 vscode-extensions/vscode-spring-boot/lib/copilot/util.ts create mode 100644 vscode-extensions/vscode-spring-boot/readme-imgs/spring-tools-icon.png diff --git a/headless-services/commons/commons-gradle/src/main/java/org/springframework/ide/vscode/commons/gradle/GradleProjectClasspath.java b/headless-services/commons/commons-gradle/src/main/java/org/springframework/ide/vscode/commons/gradle/GradleProjectClasspath.java index b3343c073..66b256ffc 100644 --- a/headless-services/commons/commons-gradle/src/main/java/org/springframework/ide/vscode/commons/gradle/GradleProjectClasspath.java +++ b/headless-services/commons/commons-gradle/src/main/java/org/springframework/ide/vscode/commons/gradle/GradleProjectClasspath.java @@ -126,6 +126,11 @@ public class GradleProjectClasspath implements IClasspath { public String getName() { return project == null ? null : project.getName(); } + + @Override + public String getJavaVersion() { + return JavaUtils.getJavaRuntimeMinorVersion(getJavaRuntimeVersion()); + } public String getGradleVersion() throws GradleException { if (buildEnvironment == null) { diff --git a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/ClasspathData.java b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/ClasspathData.java index 1e97c5287..b801c81d3 100644 --- a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/ClasspathData.java +++ b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/ClasspathData.java @@ -30,19 +30,22 @@ public class ClasspathData implements IClasspath { final public static ClasspathData EMPTY_CLASSPATH_DATA = new ClasspathData( null, - Collections.emptySet() + Collections.emptySet(), + null ); private String name; private Set classpathEntries; + private String javaVersion; private Cache> binaryLibLookupCache; public ClasspathData() {} - public ClasspathData(String name, Collection classpathEntries) { + public ClasspathData(String name, Collection classpathEntries, String javaVersion) { this.name = name; this.classpathEntries = ImmutableSet.copyOf(classpathEntries); + this.javaVersion = javaVersion; this.binaryLibLookupCache = CacheBuilder.newBuilder().build(); } @@ -56,7 +59,8 @@ public class ClasspathData implements IClasspath { } return new ClasspathData( d.getName(), - entries==null ? ImmutableSet.of() : entries + entries==null ? ImmutableSet.of() : entries, + d.getJavaVersion() ); } @@ -68,6 +72,15 @@ public class ClasspathData implements IClasspath { public void setName(String name) { this.name = name; } + + @Override + public String getJavaVersion() { + return javaVersion; + } + + public void setJavaVersion(String javaVersion) { + this.javaVersion = javaVersion; + } @Override public Set getClasspathEntries() { diff --git a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/DelegatingCachedClasspath.java b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/DelegatingCachedClasspath.java index 1f283df3b..83fa5ee43 100644 --- a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/DelegatingCachedClasspath.java +++ b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/DelegatingCachedClasspath.java @@ -79,6 +79,11 @@ public class DelegatingCachedClasspath implements IClasspath { public ImmutableList getClasspathEntries() throws Exception { return ImmutableList.copyOf(cachedData.get().getClasspathEntries()); } + + @Override + public String getJavaVersion() { + return cachedData.get().getJavaVersion(); + } public boolean isCached() { return fileBasedCache.isCached(); diff --git a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/IClasspath.java b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/IClasspath.java index 3951d8731..422db8163 100644 --- a/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/IClasspath.java +++ b/headless-services/commons/commons-java/src/main/java/org/springframework/ide/vscode/commons/java/IClasspath.java @@ -31,7 +31,7 @@ public interface IClasspath { public static final Logger log = LoggerFactory.getLogger(IClasspath.class); String getName(); - + /** * Classpath entries paths * @@ -58,4 +58,10 @@ public interface IClasspath { return Optional.empty(); } + /** + * Finds Java Version by parsing the classpath entries + * @return returns java version + */ + String getJavaVersion(); + } diff --git a/headless-services/commons/commons-java/src/test/java/org/springframework/ide/vscode/commons/jandex/JandexClasspathTest.java b/headless-services/commons/commons-java/src/test/java/org/springframework/ide/vscode/commons/jandex/JandexClasspathTest.java index 98472f8ba..0f25cbfb1 100644 --- a/headless-services/commons/commons-java/src/test/java/org/springframework/ide/vscode/commons/jandex/JandexClasspathTest.java +++ b/headless-services/commons/commons-java/src/test/java/org/springframework/ide/vscode/commons/jandex/JandexClasspathTest.java @@ -63,7 +63,7 @@ public class JandexClasspathTest { ClasspathData getClasspath() { return new ClasspathData(name, ImmutableList.of( CPE.source(new File(root, "src"), outputFolder) - )); + ), ""); } JandexClasspath getJandexClasspath() { diff --git a/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/java/Classpath.java b/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/java/Classpath.java index f02a2b325..cb71b6c59 100644 --- a/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/java/Classpath.java +++ b/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/java/Classpath.java @@ -28,13 +28,15 @@ public class Classpath { public static final String ENTRY_KIND_SOURCE = "source"; public static final String ENTRY_KIND_BINARY = "binary"; - public static final Classpath EMPTY = new Classpath(Collections.emptyList()); + public static final Classpath EMPTY = new Classpath(Collections.emptyList(), ""); private List entries; + private String javaVersion; - public Classpath(List entries) { + public Classpath(List entries, String javaVersion) { super(); this.entries = entries; + this.javaVersion = javaVersion; } public List getEntries() { @@ -44,10 +46,18 @@ public class Classpath { public void setEntries(List entries) { this.entries = entries; } + + public String getJavaVersion() { + return javaVersion; + } + + public void setJavaVersion(String javaVersion) { + this.javaVersion = javaVersion; + } @Override public String toString() { - return "Classpath [entries=" + entries + "]"; + return "Classpath [entries=" + entries + ", javaVersion=" + javaVersion + "]"; } public static class CPE { diff --git a/headless-services/commons/commons-maven/src/main/java/org/springframework/ide/vscode/commons/maven/java/MavenProjectClasspath.java b/headless-services/commons/commons-maven/src/main/java/org/springframework/ide/vscode/commons/maven/java/MavenProjectClasspath.java index e3812f9fe..70b3cf7bf 100644 --- a/headless-services/commons/commons-maven/src/main/java/org/springframework/ide/vscode/commons/maven/java/MavenProjectClasspath.java +++ b/headless-services/commons/commons-maven/src/main/java/org/springframework/ide/vscode/commons/maven/java/MavenProjectClasspath.java @@ -226,6 +226,11 @@ public class MavenProjectClasspath implements IClasspath { public ImmutableList getClasspathEntries() throws Exception { return cachedData != null ? ImmutableList.copyOf(cachedData.getClasspathEntries()) : ImmutableList.of(); } + + @Override + public String getJavaVersion() { + return cachedData.getJavaVersion() != null ? cachedData.getJavaVersion() : null; + } private Set projectDependencies(MavenProject project) { return project == null ? Collections.emptySet() : project.getArtifacts(); @@ -252,8 +257,9 @@ public class MavenProjectClasspath implements IClasspath { ImmutableList entries = resolveClasspathEntries(project); String name = project.getArtifact().getArtifactId(); - - return new ClasspathData(name, new LinkedHashSet<>(entries)); + String javaVersion = maven.getJavaRuntimeVersion(); + + return new ClasspathData(name, new LinkedHashSet<>(entries), javaVersion); } @Override diff --git a/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/ClasspathUtil.java b/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/ClasspathUtil.java index cbf4656fb..9132e9afd 100644 --- a/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/ClasspathUtil.java +++ b/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/ClasspathUtil.java @@ -95,11 +95,31 @@ public class ClasspathUtil { } } } - Classpath classpath = new Classpath(cpEntries); + String javaVersion = extractJavaVersion(getJreContainer(javaProject.getRawClasspath()).getPath().lastSegment()); + Classpath classpath = new Classpath(cpEntries, javaVersion); logger.debug("classpath=" + classpath.getEntries().size() + " entries"); return classpath; } + private static String extractJavaVersion(String versionString) { + String[] parts = versionString.split("-"); + if (parts.length > 1) { + return parts[1]; // for "JavaSE-17" style + } else if (versionString.contains(".")) { + parts = versionString.split("\\."); + if (parts[0] == "1") { + if (parts.length > 1) { + return parts[1]; + } + } else { + String version = parts[0]; + int idx = version.indexOf('+'); + return idx >= 0 ? version.substring(0, idx) : version; + } + } + return versionString; + } + private static AtomicBoolean enabledDownloadSources = new AtomicBoolean(false); public static List createCpes(IJavaProject javaProject, IClasspathEntry entry) throws MalformedURLException, JavaModelException { diff --git a/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/SendClasspathNotificationsJob.java b/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/SendClasspathNotificationsJob.java index 283474e6a..85f309fb1 100644 --- a/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/SendClasspathNotificationsJob.java +++ b/headless-services/jdt-ls-extension/org.springframework.tooling.jdt.ls.commons/src/org/springframework/tooling/jdt/ls/commons/classpath/SendClasspathNotificationsJob.java @@ -137,7 +137,7 @@ public class SendClasspathNotificationsJob extends Job { } if (filteredCPEs.size() != classpath.getEntries().size()) { // Only send effective classpath that has all entries physically present. - classpath = new Classpath(filteredCPEs); + classpath = new Classpath(filteredCPEs, ""); } } catch (Exception e) { logger.log(e); diff --git a/headless-services/spring-boot-language-server/pom.xml b/headless-services/spring-boot-language-server/pom.xml index e20e3770f..944ad5730 100644 --- a/headless-services/spring-boot-language-server/pom.xml +++ b/headless-services/spring-boot-language-server/pom.xml @@ -376,4 +376,4 @@ - + \ No newline at end of file diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/WorkspaceBootExecutableProjects.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/WorkspaceBootExecutableProjects.java index 5507c5e5c..db2192a6f 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/WorkspaceBootExecutableProjects.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/WorkspaceBootExecutableProjects.java @@ -18,6 +18,8 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import org.eclipse.lsp4j.ExecuteCommandParams; +import org.eclipse.lsp4j.TextDocumentIdentifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ide.vscode.boot.app.SpringSymbolIndex; @@ -33,12 +35,18 @@ import org.springframework.ide.vscode.commons.protocol.spring.AnnotationMetadata import org.springframework.ide.vscode.commons.protocol.spring.Bean; import org.springframework.ide.vscode.commons.protocol.spring.BeansParams; +import com.google.gson.JsonElement; + public class WorkspaceBootExecutableProjects { public record ExecutableProject(String name, String uri, String gav, String mainClass, Collection classpath) {} + public record BootProjectInfo(String name, String uri, String mainClass, String buildTool, String springBootVersion, String javaVersion) {} + final static String CMD = "sts/spring-boot/executableBootProjects"; + final static String BOOT_PROJECT_INFO_CMD = "sts/spring-boot/bootProjectInfo"; + private final static Logger log = LoggerFactory.getLogger(WorkspaceBootExecutableProjects.class); final private JavaProjectFinder projectFinder; @@ -50,6 +58,10 @@ public class WorkspaceBootExecutableProjects { this.projectFinder = projectFinder; this.symbolIndex = symbolIndex; server.onCommand(CMD, params -> findExecutableProjects()); + + server.onCommand(BOOT_PROJECT_INFO_CMD, (params) -> { + return getBootProjectInfo(params); + }); } private CompletableFuture> mapToExecProject(IJavaProject project) { @@ -113,4 +125,36 @@ public class WorkspaceBootExecutableProjects { }); } -} + private CompletableFuture> mapToBootProjectInfo(IJavaProject project) { + BeansParams params = new BeansParams(); + params.setProjectName(project.getElementName()); + return symbolIndex.beans(params).thenApply(beans -> { + List bootAppBeans = beans.stream() + .filter(b -> hasAnnotation(b, Annotations.BOOT_APP)) + .limit(2) + .collect(Collectors.toList()); + if (bootAppBeans.size() > 0) { + try { + String appBean = bootAppBeans.get(0) != null ? bootAppBeans.get(0).getType() : null; + String springBootVersion = SpringProjectUtil.getSpringBootVersion(project).toString(); + String buildTool = project.getProjectBuild().getType(); + String javaVersion = project.getClasspath().getJavaVersion(); + return Optional + .of(new BootProjectInfo(project.getElementName(), project.getLocationUri().toASCIIString(), + appBean, buildTool, springBootVersion, javaVersion)); + } catch (Exception e) { + log.error("", e); + } + } + return Optional.empty(); + }); + } + + private CompletableFuture getBootProjectInfo(ExecuteCommandParams params) { + String projectUri = ((JsonElement) params.getArguments().get(0)).getAsString(); + IJavaProject project = projectFinder.find(new TextDocumentIdentifier(projectUri)).orElse(null); + + return mapToBootProjectInfo(project).thenApply(opt -> opt.orElse(null)); + } + +} \ No newline at end of file diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/jdt/ls/JdtLsProjectCache.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/jdt/ls/JdtLsProjectCache.java index 68caaaed3..be689ded5 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/jdt/ls/JdtLsProjectCache.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/jdt/ls/JdtLsProjectCache.java @@ -358,7 +358,7 @@ public class JdtLsProjectCache implements InitializableJavaProjectsService, Serv } else { log.debug("deleted = false"); URI projectUri = new URI(uri); - ClasspathData classpath = new ClasspathData(event.name, event.classpath.getEntries()); + ClasspathData classpath = new ClasspathData(event.name, event.classpath.getEntries(), event.classpath.getJavaVersion()); IJavaProject oldProject = table.get(uri); if (oldProject != null && classpath.equals(oldProject.getClasspath())) { // nothing has changed diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java index 180e0a4ca..8e8f3e2b2 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/MockProjects.java @@ -80,6 +80,7 @@ public class MockProjects { final private File root; final private String name; +// final private String javaVersion; final private List sourceFolders = new ArrayList(); private File defaultOutputFolder; @@ -100,12 +101,19 @@ public class MockProjects { } return cp; } + + @Override + public String getJavaVersion() { +// return javaVersion; + return null; + } }; public MockProject(String name) { synchronized (projectsByName) { assertFalse(projectsByName.containsKey(name)); this.name = name; +// this.javaVersion = ""; this.root = Files.createTempDir(); createSourceFolder("src/main/java"); createSourceFolder("src/main/resources"); diff --git a/vscode-extensions/vscode-spring-boot/lib/Main.ts b/vscode-extensions/vscode-spring-boot/lib/Main.ts index 26499c905..556ce2641 100644 --- a/vscode-extensions/vscode-spring-boot/lib/Main.ts +++ b/vscode-extensions/vscode-spring-boot/lib/Main.ts @@ -6,7 +6,8 @@ import { window, workspace, ExtensionContext, - Uri + Uri, + lm } from 'vscode'; import * as commons from '@pivotal-tools/commons-vscode'; @@ -22,6 +23,11 @@ import * as setLogLevelUi from './set-log-levels-ui'; import { startTestJarSupport } from "./test-jar-launch"; import { startPropertiesConversionSupport } from "./convert-props-yaml"; import { activateCopilotFeatures } from "./copilot"; +import * as springBootAgent from './copilot/springBootAgent'; +import { SpringCli } from './copilot/springCli'; +import { applyLspEdit } from "./copilot/guideApply"; +import { isLlmApiReady } from "./copilot/util"; +import CopilotRequest, { logger } from "./copilot/copilotRequest"; const PROPERTIES_LANGUAGE_ID = "spring-boot-properties"; const YAML_LANGUAGE_ID = "spring-boot-properties-yaml"; @@ -32,6 +38,7 @@ const JPA_QUERY_PROPERTIES_LANGUAGE_ID = "jpa-query-properties"; const STOP_ASKING = "Stop Asking"; +export const SPRINGCLI = new SpringCli(); /** Called when extension is activated */ export function activate(context: ExtensionContext): Thenable { @@ -162,9 +169,15 @@ export function activate(context: ExtensionContext): Thenable { rewrite.activate(client, options, context); setLogLevelUi.activate(client, options, context); startPropertiesConversionSupport(context); + if(isLlmApiReady) + activateSpringBootParticipant(context); + else + window.showInformationMessage("Spring Boot chat participant is not available. Please use the vscode insiders version 1.90.0 or above and make sure all `lm` API is enabled."); registerMiscCommands(context); + commands.registerCommand('vscode-spring-boot.agent.apply', applyLspEdit); + return new ApiManager(client).api; }); } @@ -192,3 +205,13 @@ function registerMiscCommands(context: ExtensionContext) { }), ); } + +async function activateSpringBootParticipant(context: ExtensionContext) { + const model = (await lm.selectChatModels(CopilotRequest.DEFAULT_MODEL_SELECTOR))?.[0]; + if (!model) { + const models = await lm.selectChatModels(); + logger.error(`Not a suitable model. The available models are: [${models.map(m => m.name).join(', ')}]. Please make sure you have installed the latest "GitHub Copilot Chat" (v0.16.0 or later) and all \`lm\` API is enabled.`); + return; + } + springBootAgent.activate(context); +} diff --git a/vscode-extensions/vscode-spring-boot/lib/copilot/copilotRequest.ts b/vscode-extensions/vscode-spring-boot/lib/copilot/copilotRequest.ts new file mode 100644 index 000000000..280047378 --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/copilot/copilotRequest.ts @@ -0,0 +1,90 @@ +import { LanguageModelChatRequestOptions, LanguageModelChatSelector, CancellationToken, Disposable, LanguageModelChatMessage, window, ProgressLocation, lm, LogOutputChannel, LanguageModelChatMessageRole, LanguageModelError } from "vscode"; + +export const logger: LogOutputChannel = window.createOutputChannel("Spring tools agent", { log: true }); + +export default class CopilotRequest { + + public static readonly DEFAULT_END_MARK = '<|endofresponse|>'; + public static readonly DEFAULT_MAX_ROUNDS = 2; + public static readonly DEFAULT_MODEL_SELECTOR: LanguageModelChatSelector = { vendor: 'copilot', family: 'gpt-3.5-turbo' }; + public static readonly DEFAULT_MODEL_OPTIONS: LanguageModelChatRequestOptions = { modelOptions: {} }; + + public static readonly NOT_CANCELLABLE: CancellationToken = { isCancellationRequested: false, onCancellationRequested: () => Disposable.from() }; + + public constructor( + private readonly systemMessagesOrPrompts: LanguageModelChatMessage[] = [], + private readonly modelSelector: LanguageModelChatSelector = CopilotRequest.DEFAULT_MODEL_SELECTOR, + private readonly modelOptions: LanguageModelChatRequestOptions = CopilotRequest.DEFAULT_MODEL_OPTIONS, + private readonly endMark: string = CopilotRequest.DEFAULT_END_MARK, + private readonly maxRounds: number = CopilotRequest.DEFAULT_MAX_ROUNDS, + ) { + } + + public async chatRequest(userMessage: LanguageModelChatMessage[], modelOptions: LanguageModelChatRequestOptions = CopilotRequest.DEFAULT_MODEL_OPTIONS, cancellationToken: CancellationToken = CopilotRequest.NOT_CANCELLABLE): Promise { + const messages = [...this.systemMessagesOrPrompts]; + let answer: string = ''; + let rounds: number = 0; + + return window.withProgress({ + location: ProgressLocation.Window, + title: "Copilot request", + cancellable: true + }, async (progress, cancellation) => { + progress.report({ message: "processing..." }); + + if (cancellation.isCancellationRequested) { + logger.info("Chat request cancelled"); + return 'Chat request cancelled'; + } + const _send = async (message: LanguageModelChatMessage[]): Promise => { + rounds++; + let response: string = ''; + messages.push(...message); + try { + messages.forEach(m => logger.info(m.content)); + response = await this.sendRequest(messages, modelOptions, cancellationToken); + answer += response; + logger.info(`Response: \n`, response); + } catch (e) { + if (e instanceof LanguageModelError) { + logger.error(e.message, e.code); + throw e; + } else { + const cause = e.cause || e; + logger.error(`Failed to chat with copilot`, e.message, e.stack); + throw cause; + } + + } + messages.push(new LanguageModelChatMessage(LanguageModelChatMessageRole.Assistant, response)); + return answer.trim().endsWith(this.endMark); + } + let completeResponse: boolean = await _send(userMessage); + while (!completeResponse && rounds < this.maxRounds) { + completeResponse = await _send([new LanguageModelChatMessage(LanguageModelChatMessageRole.User, `continue your response from where you left off, or end your response with "//${this.endMark}" to finish the conversation.`)]); + } + logger.debug('rounds', rounds); + return answer.replace("//"+this.endMark, ""); + + }); + } + + private async selectModel() { + const model = (await lm.selectChatModels(this.modelSelector))?.[0]; + if (!model) { + const models = await lm.selectChatModels(); + throw new Error(`No suitable model, available models: [${models.map(m => m.name).join(', ')}]. Please make sure you have installed the latest "GitHub Copilot Chat" (v0.16.0 or later).`); + } + return model; + } + + private async sendRequest(messages: LanguageModelChatMessage[], modelOptions: LanguageModelChatRequestOptions, cancellationToken: CancellationToken) { + const response = []; + const model = await this.selectModel(); + const chatResponse = await model.sendRequest(messages, modelOptions ?? this.modelOptions, cancellationToken); + for await (const fragment of chatResponse.text) { + response.push(fragment); + } + return response.join(''); + } +} diff --git a/vscode-extensions/vscode-spring-boot/lib/copilot/guideApply.ts b/vscode-extensions/vscode-spring-boot/lib/copilot/guideApply.ts new file mode 100644 index 000000000..dbe63e882 --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/copilot/guideApply.ts @@ -0,0 +1,35 @@ +import { Uri, workspace, window } from "vscode"; +import { SPRINGCLI } from "../Main"; +import { getTargetGuideMardown } from "./util"; +import { createConverter } from "vscode-languageclient/lib/common/protocolConverter"; +import fs from "fs"; + + +const CONVERTER = createConverter(undefined, true, true); +const CANCELLED = "Cancelled"; + +export async function applyLspEdit(uri: Uri) { + try { + if (!uri) { + uri = await getTargetGuideMardown(); + } + const lspEdit = await SPRINGCLI.guideLspEdit(uri); + const workspaceEdit = await CONVERTER.asWorkspaceEdit(lspEdit); + console.log(lspEdit); + + await Promise.all(workspaceEdit.entries().map(async ([uri, edits]) => { + if (fs.existsSync(uri.fsPath)) { + const doc = await workspace.openTextDocument(uri.fsPath); + await window.showTextDocument(doc); + } + })); + + return await workspace.applyEdit(workspaceEdit, { + isRefactoring: true + }); + } catch (error) { + if (error !== CANCELLED) { + window.showErrorMessage(error); + } + } +} \ No newline at end of file diff --git a/vscode-extensions/vscode-spring-boot/lib/copilot/springBootAgent.ts b/vscode-extensions/vscode-spring-boot/lib/copilot/springBootAgent.ts new file mode 100644 index 000000000..310238394 --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/copilot/springBootAgent.ts @@ -0,0 +1,110 @@ +import CopilotRequest from "./copilotRequest"; +import { CancellationToken, chat, ChatContext, ChatRequest, ChatResponseStream, ChatResult, commands, ExtensionContext, l10n, LanguageModelChatMessage, LanguageModelChatMessageRole, Uri, workspace } from "vscode"; +import { systemBoot2Prompt, systemBoot3Prompt, systemPrompt } from "./system-ai-prompt"; +import { userPrompt } from "./user-ai-prompt"; +import { getWorkspaceRoot, writeResponseToFile } from "./util"; +import { SPRINGCLI } from "../Main"; + +const PARTICIPANT_ID = 'springboot.agent'; +const SYSTEM_PROMPT = systemPrompt; +const USER_PROMPT = userPrompt; + +interface BootProjectInfo { + name: string; + uri: string; + mainClass: string; + buildTool: string; + springBootVersion: string; + javaVersion: string; +} + +interface SpringBootChatAgentResult extends ChatResult { + metadata: { + command: string; + } +} + +export default class SpringBootChatAgent { + + copilotRequest: CopilotRequest; + + constructor(copilotRequest: CopilotRequest) { + this.copilotRequest = copilotRequest; + } + + public async handlePrompts(request: ChatRequest, context: ChatContext, stream: ChatResponseStream, cancellationToken: CancellationToken): Promise { + + const selectedProject = (await getWorkspaceRoot()); + if(!selectedProject) { + stream.markdown('No project selected from the workspace'); + return; + } + const selectedProjectUri = Uri.file(selectedProject?.fsPath).toString(); + + // Fetch project related information from the Spring boot language server + const bootProjInfo = await commands.executeCommand("sts/spring-boot/bootProjectInfo", selectedProjectUri) as BootProjectInfo; + const projectContext = ` + Use the following project information for the solution: Please suggest code compatible with the project version. + + Main Spring project name: ${bootProjInfo.name} + Root Package name: ${bootProjInfo.mainClass.substring(0, bootProjInfo.mainClass.lastIndexOf('.'))} + Build tool: ${bootProjInfo.buildTool} + Spring boot version: ${bootProjInfo.springBootVersion} + Java version: ${bootProjInfo.javaVersion} + User prompt: ${request.prompt} + `; + + // Enhance prompt with project information and user prompt. Provide spring boot 3 speicifc context when necessary + const messages = [ + LanguageModelChatMessage.User(projectContext), + bootProjInfo.springBootVersion.startsWith('3') ? LanguageModelChatMessage.User(systemBoot3Prompt) : LanguageModelChatMessage.User(systemBoot2Prompt), + LanguageModelChatMessage.User('User Input: ' +request.prompt), + + ]; + + stream.progress('Generating code....This will take a few minutes'); + + // Chat request to copilot LLM + const response = await this.copilotRequest.chatRequest(messages, {}, cancellationToken); + + // write the response to markdown file + const targetMarkdownUri = await writeResponseToFile(response, bootProjInfo.name, selectedProject.fsPath); + + let documentContent; + + if (!targetMarkdownUri) { + documentContent = 'Note: The code provided is just an example and may not be suitable for production use. \n ' + response; + } else { + // modify the response from copilot LLM i.e. make response Boot 3 compliant if necessary + if (bootProjInfo.springBootVersion.startsWith('3')) { + const enhancedResponse = await SPRINGCLI.enhanceResponse(targetMarkdownUri, selectedProject.fsPath); + await writeResponseToFile(enhancedResponse, bootProjInfo.name, selectedProject.fsPath); + } + documentContent = await workspace.fs.readFile(targetMarkdownUri); + } + + const chatResponse = Buffer.from(documentContent).toString(); + stream.markdown(chatResponse); + stream.button({ + command: 'vscode-spring-boot.agent.apply', + title: l10n.t('Preview Changes') + }); + return { metadata: { command: '' } }; + } +} + +export function activate( + context: ExtensionContext +) { + const systemPrompts: LanguageModelChatMessage[] = [ + new LanguageModelChatMessage(LanguageModelChatMessageRole.User, SYSTEM_PROMPT), + // new LanguageModelChatMessage(LanguageModelChatMessageRole.User, USER_PROMPT) + ]; + const copilotRequest = new CopilotRequest(systemPrompts); + const springBootChatAgent = new SpringBootChatAgent(copilotRequest); + + const agent = chat.createChatParticipant(PARTICIPANT_ID, async (request, context, progress, token) => { + return springBootChatAgent.handlePrompts(request, context, progress, token); + }); + agent.iconPath = Uri.joinPath(context.extensionUri, 'readme-imgs', 'spring-tools-icon.png'); +} \ No newline at end of file diff --git a/vscode-extensions/vscode-spring-boot/lib/copilot/springCli.ts b/vscode-extensions/vscode-spring-boot/lib/copilot/springCli.ts new file mode 100644 index 000000000..b43946ddd --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/copilot/springCli.ts @@ -0,0 +1,121 @@ +import { ProgressLocation, Uri, window, workspace } from "vscode"; +import cp from "child_process"; +import * as vscode from 'vscode'; +import { homedir } from "os"; +import { getWorkspaceRoot, getWorkspaceRootPath } from "./util"; +import path from "path"; +import { WorkspaceEdit } from "vscode-languageclient"; + +export const CANCELLED = "Cancelled"; + +export class SpringCli { + + get executable(): string { + return workspace.getConfiguration("spring-cli").get("executable") || "spring"; + } + + guideLspEdit(uri: Uri, cwd?: string): Promise { + const args = [ + "guide", + "apply", + "--lsp-edit", + "--file", + uri.fsPath + ]; + return this.fetchJson("Applying guide", uri.fsPath, args, cwd || path.dirname(uri.fsPath), true); + } + + enhanceResponse(uri: Uri, cwd: string): Thenable { + const args = [ + "ai", + "enhance-response", + "--file", + uri.fsPath + ]; + return this.exec("Spring cli ai", "Enhance response", args, cwd); + } + + private async executeCommand(args: string[], cwd?: string): Promise { + const processOpts = { cwd: cwd || (await getWorkspaceRoot())?.fsPath || homedir() }; + const process = this.executable.endsWith(".jar") ? await cp.exec(`java -jar ${this.executable} ${args.join(" ")}`, processOpts) : await cp.exec(`${this.executable} ${args.join(" ")}`, processOpts); + const dataChunks: string[] = []; + process.stdout.on("data", s => dataChunks.push(s)); + return new Promise((resolve, reject) => { + process.on("exit", (code) => { + if (code) { + reject(`Failed to execute command: ${dataChunks.join()}`); + } else { + resolve(dataChunks.join()); + } + }); + }); + } + + private async exec(title: string, message: string, args: string[], cwd?: string): Promise { + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Window, + cancellable: true, + title, + }, async (progress, cancellation) => { + + if (message) { + progress.report({ message }); + } + + return new Promise(async (resolve, reject) => { + if (cancellation.isCancellationRequested) { + reject("Cancelled"); + } + try { + const output: string = await this.executeCommand(args, cwd); + resolve(output as T); + } catch (error) { + console.error(`Error: ${error}`); + reject(error); + } + }); + }); + } + + private async fetchJson(title: string, message: string, args: string[], cwd?: string, omitJsonParam?: boolean): Promise { + + return window.withProgress({ + location: ProgressLocation.Window, + cancellable: true, + title + }, (progress, cancellation) => { + + if (message) { + progress.report({ message }); + } + + return new Promise(async (resolve, reject) => { + if (cancellation.isCancellationRequested) { + reject(CANCELLED); + } + const processOpts = { cwd: cwd || getWorkspaceRootPath()?.fsPath || homedir() }; + const process = this.executable.endsWith(".jar") ? await cp.exec(`java -jar ${this.executable} ${args.join(" ")}`, processOpts) : await cp.exec(`${this.executable} ${args.join(" ")} ${omitJsonParam ? "" : "--json"}`, processOpts); + cancellation.onCancellationRequested(() => process.kill()); + const dataChunks: string[] = []; + process.stdout.on("data", s => dataChunks.push(s)); + process.on("exit", (code) => { + if (code) { + if (cancellation.isCancellationRequested) { + reject(CANCELLED); + } else { + reject(`Failed to fetch data: ${dataChunks.join()}`); + } + } else { + try { + resolve(JSON.parse(dataChunks.join()) as T); + } catch (error) { + reject(error); + } + } + }); + }); + }); + } + +} + diff --git a/vscode-extensions/vscode-spring-boot/lib/copilot/system-ai-prompt.ts b/vscode-extensions/vscode-spring-boot/lib/copilot/system-ai-prompt.ts new file mode 100644 index 000000000..1ce937e51 --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/copilot/system-ai-prompt.ts @@ -0,0 +1,46 @@ +import CopilotRequest from "./copilotRequest" + +export const systemPrompt = `**Your task is to create Java source code for a Spring boot application. Follow these guidelines:** +- IMPORTANT: CONCLUDE YOUR RESPONSE WITH THE MARKER \"//${CopilotRequest.DEFAULT_END_MARK}\" TO INDICATE END OF RESPONSE. +- Generate a pom.xml snippet that includes the necessary Spring Boot Starter dependencies such as "spring-boot-starter-jpa". +- Organize code into appropriate package. Use the Package name as the root package and place the files in sub-packages accordingly. +- Include import statements in all code files. +- Generate architectural layers (Controller, Service, Repository, Entity) code as appropriate. +- If no entity or domain object is specified in the description, use a Person entity with the properties name and phone number. +- Generate constructors, property getters, and setters for the entity or domain object (e.g., "getName" and "setName" for the name property). +- Add any required annotations to the main application class (class with the @SpringBootApplication annotation). +- When generating markdown for code blocks in the final response, include an appropriate value for the "info" field. +- Provide application.properties file with sample configurations. +- Include unit tests for each architectural layer (Controller, Service, Repository) as appropriate. +- Include an integration test if there are multiple architectural layers. +` + +export const systemBoot3Prompt = `For Spring boot 3 and above: +- IMPORTANT: For JPA related applications, the 'javax' package has been replaced with 'jakarta' package. All JPA-related imports should use 'jakarta.persistence' instead of 'javax.persistence'. +\`\`\` +""" +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; + +@Entity +public class Person { +} +\`\`\` +- The coordinates of the MySQL JDBC driver have changed from "mysql:mysql-connector-java" to "com.mysql:mysql-connector-j". If you are using the MySQL JDBC driver, update its coordinates accordingly. +Only when the generated code requires a MySQL JDBC driver, the MySQL JDBC driver should be "com.mysql:mysql-connector-j". This is a change from the older "mysql:mysql-connector-java". + +Please ensure to use the "com.mysql:mysql-connector-j" MySQL JDBC driver in pom.xml. Here is the maven dependency to be used in this case: +\`\`\` + + + com.mysql + mysql-connector-j + +\`\`\` +` + + +export const systemBoot2Prompt = ` +- All JPA-related imports should use 'javax.persistence' +- The MySQL JDBC driver coordinates are "mysql:mysql-connector-java" +` \ No newline at end of file diff --git a/vscode-extensions/vscode-spring-boot/lib/copilot/user-ai-prompt.ts b/vscode-extensions/vscode-spring-boot/lib/copilot/user-ai-prompt.ts new file mode 100644 index 000000000..170d8e432 --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/copilot/user-ai-prompt.ts @@ -0,0 +1,9 @@ +export const userPrompt = `Create the Spring Java application with the following project information + +Ensure the solution meets the following additional criteria + +1. Include import statements in all code files +2. Generate getter and setters in entity classes +3. Provide the necessary Spring Boot Starter dependencies in the pom.xml file +4. When generating markdown for the 'application.properties' file in Spring Boot, please ensure that the markdown 'info' field is set to 'properties' for that specific section. +` \ No newline at end of file diff --git a/vscode-extensions/vscode-spring-boot/lib/copilot/util.ts b/vscode-extensions/vscode-spring-boot/lib/copilot/util.ts new file mode 100644 index 000000000..94174170e --- /dev/null +++ b/vscode-extensions/vscode-spring-boot/lib/copilot/util.ts @@ -0,0 +1,91 @@ +import path from "path"; +import { Uri, WorkspaceFolder, version, window, workspace } from "vscode"; +import fs from "fs"; +import { SemVer } from "semver"; + +export function getExecutable(): string { + return workspace.getConfiguration("spring-cli").get("executable") || "spring"; +} + +export async function getWorkspaceRoot(): Promise { + if (workspace.workspaceFolders && workspace.workspaceFolders.length) { + if (workspace.workspaceFolders.length === 1) { + return workspace.workspaceFolders[0].uri; + } else { + return await window.showQuickPick( + workspace.workspaceFolders.map((c: WorkspaceFolder) => ({ value: c.uri, label: getRelativePathToWorkspaceFolder(c.uri), description: getWorkspaceFolderName(c.uri) })), + { placeHolder: "Select the target project." }, + ).then(res => res && res.value); + } + } +} + +export function getWorkspaceRootPath(): Uri | undefined { + if (workspace.workspaceFolders && workspace.workspaceFolders.length) { + return workspace.workspaceFolders[0].uri + } +} + +function getRelativePathToWorkspaceFolder(file: Uri): string { + if (file) { + const wf: WorkspaceFolder = workspace.getWorkspaceFolder(file); + if (wf) { + return path.relative(wf.uri.fsPath, file.fsPath); + } + } + return ''; +} + +function getWorkspaceFolderName(file: Uri): string { + if (file) { + const wf: WorkspaceFolder = workspace.getWorkspaceFolder(file); + if (wf) { + return wf.name; + } + } + return ''; +} + +export async function getTargetGuideMardown(): Promise { + if (window.activeTextEditor) { + const activeUri = window.activeTextEditor.document.uri; + if (/README-\S+.md/.test(path.basename(activeUri.path).toLowerCase())) { + return activeUri; + } + } + + const candidates: Uri[] = await workspace.findFiles("**/README-*.md"); + if (candidates.length > 0) { + if (candidates.length === 1) { + return candidates[0]; + } else { + return await window.showQuickPick( + candidates.map((c: Uri) => ({ value: c, label: getRelativePathToWorkspaceFolder(c), description: getWorkspaceFolderName(c) })), + { placeHolder: "Select the target project." }, + ).then(res => res && res.value); + } + } + return undefined; +} + +export async function writeResponseToFile(response: string, appName: string, selectedProject: string) { + const readmeFilePath = path.resolve(selectedProject, `README-ai-${appName}.md`); + if (fs.existsSync(readmeFilePath)) { + try { + fs.unlinkSync(readmeFilePath); + } catch (ex) { + throw new Error(`Could not delete readme file: ${readmeFilePath}, ${ex}`); + } + } + + try { + fs.writeFileSync(readmeFilePath, response); + return Uri.file(readmeFilePath); + } catch (ex) { + throw new Error(`Could not write readme file: ${readmeFilePath}, ${ex}`); + } +} + +export function isLlmApiReady(): boolean { + return version.includes('insider') && new SemVer(version).compare(new SemVer("1.90.0-insider")) >= 0; +} \ No newline at end of file diff --git a/vscode-extensions/vscode-spring-boot/package.json b/vscode-extensions/vscode-spring-boot/package.json index 2489560a8..3f9b63665 100644 --- a/vscode-extensions/vscode-spring-boot/package.json +++ b/vscode-extensions/vscode-spring-boot/package.json @@ -30,7 +30,24 @@ "activationEvents": [ "onCommand:vscode-spring-boot.ls.start" ], + "enabledApiProposals": [ + "chatVariableResolver" + ], "contributes": { + "chatParticipants": [ + { + "id": "springboot.agent", + "fullName": "Spring boot", + "name": "springboot", + "description": "Spring boot chat agent", + "commands": [ + { + "name": "add", + "description": "Add a new spring module to the exisiting project" + } + ] + } + ], "javaExtensions": [ "./jars/io.projectreactor.reactor-core.jar", "./jars/org.reactivestreams.reactive-streams.jar", @@ -248,6 +265,12 @@ "command": "vscode-spring-boot.query.explain", "title": "Explain Spel expressions, Spring Data queries, and AOP annotations (using Copilot)", "category": "Spring Boot" + }, + { + "command": "vscode-spring-boot.agent.apply", + "title": "Apply Changes", + "category": "Spring Boot Agent", + "enablement": "false" } ], "configuration": [ @@ -433,6 +456,17 @@ } } }, + { + "id": "spring-cli", + "title": "Spring CLI", + "properties": { + "spring-cli.executable": { + "type": "string", + "default": "spring", + "description": "Spring CLI executable. Either name of the executable if it's on the PATH environment variable or absolute path to the spring CLI executable (or JAR for dev purposes)" + } + } + }, { "id": "ls", "title": "Language Server", @@ -1334,6 +1368,7 @@ "@pivotal-tools/commons-vscode": "file:../commons-vscode/pivotal-tools-commons-vscode-0.2.4.tgz", "portfinder": "1.0.32", "ps-list": "^7.2.0", + "semver": "^7.6.2", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/vscode-extensions/vscode-spring-boot/readme-imgs/spring-tools-icon.png b/vscode-extensions/vscode-spring-boot/readme-imgs/spring-tools-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9572b4200fbb69371cec724fefe704c8e54ec0 GIT binary patch literal 2668 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 a0sjGE_=XE~3-3(;0000