copilot spring boot agent implementation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<CPE> classpathEntries;
|
||||
private String javaVersion;
|
||||
|
||||
private Cache<String, Optional<CPE>> binaryLibLookupCache;
|
||||
|
||||
public ClasspathData() {}
|
||||
|
||||
public ClasspathData(String name, Collection<CPE> classpathEntries) {
|
||||
public ClasspathData(String name, Collection<CPE> 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<CPE> getClasspathEntries() {
|
||||
|
||||
@@ -79,6 +79,11 @@ public class DelegatingCachedClasspath implements IClasspath {
|
||||
public ImmutableList<CPE> getClasspathEntries() throws Exception {
|
||||
return ImmutableList.copyOf(cachedData.get().getClasspathEntries());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getJavaVersion() {
|
||||
return cachedData.get().getJavaVersion();
|
||||
}
|
||||
|
||||
public boolean isCached() {
|
||||
return fileBasedCache.isCached();
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ public class JandexClasspathTest {
|
||||
ClasspathData getClasspath() {
|
||||
return new ClasspathData(name, ImmutableList.of(
|
||||
CPE.source(new File(root, "src"), outputFolder)
|
||||
));
|
||||
), "");
|
||||
}
|
||||
|
||||
JandexClasspath getJandexClasspath() {
|
||||
|
||||
@@ -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.<CPE>emptyList());
|
||||
public static final Classpath EMPTY = new Classpath(Collections.<CPE>emptyList(), "");
|
||||
|
||||
private List<CPE> entries;
|
||||
private String javaVersion;
|
||||
|
||||
public Classpath(List<CPE> entries) {
|
||||
public Classpath(List<CPE> entries, String javaVersion) {
|
||||
super();
|
||||
this.entries = entries;
|
||||
this.javaVersion = javaVersion;
|
||||
}
|
||||
|
||||
public List<CPE> getEntries() {
|
||||
@@ -44,10 +46,18 @@ public class Classpath {
|
||||
public void setEntries(List<CPE> 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 {
|
||||
|
||||
@@ -226,6 +226,11 @@ public class MavenProjectClasspath implements IClasspath {
|
||||
public ImmutableList<CPE> 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<Artifact> projectDependencies(MavenProject project) {
|
||||
return project == null ? Collections.emptySet() : project.getArtifacts();
|
||||
@@ -252,8 +257,9 @@ public class MavenProjectClasspath implements IClasspath {
|
||||
|
||||
ImmutableList<CPE> 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
|
||||
|
||||
@@ -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<CPE> createCpes(IJavaProject javaProject, IClasspathEntry entry) throws MalformedURLException, JavaModelException {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -376,4 +376,4 @@
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
</project>
|
||||
@@ -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<String> 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<Optional<ExecutableProject>> mapToExecProject(IJavaProject project) {
|
||||
@@ -113,4 +125,36 @@ public class WorkspaceBootExecutableProjects {
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
private CompletableFuture<Optional<BootProjectInfo>> mapToBootProjectInfo(IJavaProject project) {
|
||||
BeansParams params = new BeansParams();
|
||||
params.setProjectName(project.getElementName());
|
||||
return symbolIndex.beans(params).thenApply(beans -> {
|
||||
List<Bean> 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<BootProjectInfo> 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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,7 @@ public class MockProjects {
|
||||
|
||||
final private File root;
|
||||
final private String name;
|
||||
// final private String javaVersion;
|
||||
final private List<File> sourceFolders = new ArrayList<File>();
|
||||
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");
|
||||
|
||||
@@ -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<ExtensionAPI> {
|
||||
|
||||
@@ -162,9 +169,15 @@ export function activate(context: ExtensionContext): Thenable<ExtensionAPI> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<boolean> => {
|
||||
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('');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SpringBootChatAgentResult> {
|
||||
|
||||
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');
|
||||
}
|
||||
121
vscode-extensions/vscode-spring-boot/lib/copilot/springCli.ts
Normal file
121
vscode-extensions/vscode-spring-boot/lib/copilot/springCli.ts
Normal file
@@ -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<WorkspaceEdit> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string>((resolve, reject) => {
|
||||
process.on("exit", (code) => {
|
||||
if (code) {
|
||||
reject(`Failed to execute command: ${dataChunks.join()}`);
|
||||
} else {
|
||||
resolve(dataChunks.join());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async exec<T>(title: string, message: string, args: string[], cwd?: string): Promise<T> {
|
||||
return vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Window,
|
||||
cancellable: true,
|
||||
title,
|
||||
}, async (progress, cancellation) => {
|
||||
|
||||
if (message) {
|
||||
progress.report({ message });
|
||||
}
|
||||
|
||||
return new Promise<T>(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<T>(title: string, message: string, args: string[], cwd?: string, omitJsonParam?: boolean): Promise<T> {
|
||||
|
||||
return window.withProgress({
|
||||
location: ProgressLocation.Window,
|
||||
cancellable: true,
|
||||
title
|
||||
}, (progress, cancellation) => {
|
||||
|
||||
if (message) {
|
||||
progress.report({ message });
|
||||
}
|
||||
|
||||
return new Promise<T>(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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
\`\`\`
|
||||
<!-- MySQL Connector -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
</dependency>
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
|
||||
export const systemBoot2Prompt = `
|
||||
- All JPA-related imports should use 'javax.persistence'
|
||||
- The MySQL JDBC driver coordinates are "mysql:mysql-connector-java"
|
||||
`
|
||||
@@ -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.
|
||||
`
|
||||
91
vscode-extensions/vscode-spring-boot/lib/copilot/util.ts
Normal file
91
vscode-extensions/vscode-spring-boot/lib/copilot/util.ts
Normal file
@@ -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<Uri | undefined> {
|
||||
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<Uri> {
|
||||
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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Reference in New Issue
Block a user