copilot spring boot agent implementation

This commit is contained in:
vudayani
2024-09-25 09:25:02 +05:30
committed by Martin Lippert
parent a63fc2a541
commit 3d18619e98
23 changed files with 693 additions and 16 deletions

View File

@@ -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);
}

View File

@@ -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('');
}
}

View File

@@ -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);
}
}
}

View File

@@ -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');
}

View 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);
}
}
});
});
});
}
}

View File

@@ -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"
`

View File

@@ -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.
`

View 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;
}

View File

@@ -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