From a7e15fc7b1591c8e4d3cc1945042bb90ea188ef2 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 24 Jul 2024 16:55:02 -0400 Subject: [PATCH] Add chat agent with domain driven design command --- vscode/README.md | 23 +++++ vscode/package.json | 19 +++- vscode/src/chatAgent.ts | 214 ++++++++++++++++++++++++++++++++++++++++ vscode/src/rubyLsp.ts | 24 ++++- 4 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 vscode/src/chatAgent.ts diff --git a/vscode/README.md b/vscode/README.md index fb2d876dc2..90149f1e36 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -29,6 +29,29 @@ See complete information about features [here](https://shopify.github.io/ruby-ls If you experience issues, please see the [troubleshooting guide](https://github.com/Shopify/ruby-lsp/blob/main/TROUBLESHOOTING.md). +### Copilot chat agent + +For users of Copilot, the Ruby LSP contributes a Ruby agent for AI assisted development of Ruby applications. Below you +can find the documentation of each command for the Ruby agent. For information about how to interact with Copilot Chat, +check [VS Code's official documentation](https://code.visualstudio.com/docs/copilot/copilot-chat). + +#### Design command + +The `@ruby /design` command is intended to be a domain driven design expert to help users model concepts for their +applications. Users should describe what type of application they are building and which concept they are trying to +model. The command will read their Rails application's schema and use their prompt, previous interactions and the schema +information to provide suggestions of how to design the application. For example, + +``` +@ruby /design I'm working on a web application for schools. How do I model courses? And how do they relate to students? +``` + +The output is a suggested schema for courses including relationships with users. In the chat window, two buttons will appear: `Generate with Rails`, which invokes the Rails generators to create the models suggested, and `Revert previous generation`, which will delete files generated by a previous click in the generate button. + +As with most LLM chat functionality, suggestions may not be fully accurate, especially in the first iteration. Users can +continue chatting with the `@ruby` agent to fine tune the suggestions given, before deciding to move forward with +generation. + ## Usage Search for `Shopify.ruby-lsp` in the extensions tab and click install. diff --git a/vscode/package.json b/vscode/package.json index 607334be92..b57a8a3f70 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -16,7 +16,9 @@ "categories": [ "Programming Languages", "Snippets", - "Testing" + "Testing", + "AI", + "Chat" ], "activationEvents": [ "onLanguage:ruby", @@ -26,6 +28,21 @@ ], "main": "./out/extension.js", "contributes": { + "chatParticipants": [ + { + "id": "rubyLsp.chatAgent", + "fullName": "Ruby", + "name": "ruby", + "description": "How can I help with your Ruby application?", + "isSticky": true, + "commands": [ + { + "name": "design", + "description": "Explain what you're trying to build and I will suggest possible ways to model the domain" + } + ] + } + ], "menus": { "editor/context": [ { diff --git a/vscode/src/chatAgent.ts b/vscode/src/chatAgent.ts new file mode 100644 index 0000000000..333c0712cd --- /dev/null +++ b/vscode/src/chatAgent.ts @@ -0,0 +1,214 @@ +import * as vscode from "vscode"; + +import { Command } from "./common"; +import { Workspace } from "./workspace"; + +const CHAT_AGENT_ID = "rubyLsp.chatAgent"; +const DESIGN_PROMPT = ` + You are a domain driven design and Ruby on Rails expert. + The user will provide you with details about their Rails application. + The user will ask you to help model a single specific concept. + Your job is to suggest a model name and attributes to model that concept. + Include all Rails \`generate\` commands in a single Markdown shell code block at the end. + The \`generate\` commands should ONLY include the type of generator and arguments, not the \`rails generate\` part + (e.g.: \`model User name:string\` but not \`rails generate model User name:string\`). + NEVER include commands to migrate the database as part of the code block. +`.trim(); + +export class ChatAgent implements vscode.Disposable { + private readonly agent: vscode.ChatParticipant; + private readonly showWorkspacePick: () => Promise; + + constructor( + context: vscode.ExtensionContext, + showWorkspacePick: () => Promise, + ) { + this.agent = vscode.chat.createChatParticipant( + CHAT_AGENT_ID, + this.handler.bind(this), + ); + this.agent.iconPath = vscode.Uri.joinPath(context.extensionUri, "icon.png"); + this.showWorkspacePick = showWorkspacePick; + } + + dispose() { + this.agent.dispose(); + } + + // Handle a new chat message or command + private async handler( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + ) { + if (this.withinConversation("design", request, context)) { + return this.runDesignCommand(request, context, stream, token); + } + + stream.markdown( + "Please indicate which command you would like to use for our chat.", + ); + return { metadata: { command: "" } }; + } + + // Logic for the domain driven design command + private async runDesignCommand( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + ) { + const previousInteractions = this.previousInteractions(context); + const messages = [ + vscode.LanguageModelChatMessage.User(`User prompt: ${request.prompt}`), + vscode.LanguageModelChatMessage.User(DESIGN_PROMPT), + vscode.LanguageModelChatMessage.User( + `Previous interactions with the user: ${previousInteractions}`, + ), + ]; + const workspace = await this.showWorkspacePick(); + + // On the first interaction with the design command, we gather the application's schema and include it as part of + // the prompt + if (request.command && workspace) { + const schema = await this.schema(workspace); + + if (schema) { + messages.push( + vscode.LanguageModelChatMessage.User( + `Existing application schema: ${schema}`, + ), + ); + } + } + + try { + // Select the LLM model + const [model] = await vscode.lm.selectChatModels({ + vendor: "copilot", + family: "gpt-4-turbo", + }); + + stream.progress("Designing the models for the requested concept..."); + const chatResponse = await model.sendRequest(messages, {}, token); + + let response = ""; + for await (const fragment of chatResponse.text) { + // Maybe show the buttons here and display multiple shell blocks? + stream.markdown(fragment); + response += fragment; + } + + const match = /(?<=```shell)[^.$]*(?=```)/.exec(response); + + if (workspace && match && match[0]) { + // The shell code block includes all of the `rails generate` commands. We need to strip out the `rails generate` + // from all of them since our commands only accept from the generator forward + const commandList = match[0] + .trim() + .split("\n") + .map((command) => { + return command.replace(/\s*(bin\/rails|rails) generate\s*/, ""); + }); + + stream.button({ + command: Command.RailsGenerate, + title: "Generate with Rails", + arguments: [commandList, workspace], + }); + + stream.button({ + command: Command.RailsDestroy, + title: "Revert previous generation", + arguments: [commandList, workspace], + }); + } + } catch (err) { + this.handleError(err, stream); + } + + return { metadata: { command: "design" } }; + } + + private async schema(workspace: Workspace) { + try { + const content = await vscode.workspace.fs.readFile( + vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/schema.rb"), + ); + return content.toString(); + } catch (error) { + // db/schema.rb doesn't exist + } + + try { + const content = await vscode.workspace.fs.readFile( + vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/structure.sql"), + ); + return content.toString(); + } catch (error) { + // db/structure.sql doesn't exist + } + + return undefined; + } + + // Returns `true` if the current or any previous interactions with the chat match the given `command`. Useful for + // ensuring that the user can continue chatting without having to re-type the desired command multiple times + private withinConversation( + command: string, + request: vscode.ChatRequest, + context: vscode.ChatContext, + ) { + return ( + request.command === command || + (!request.command && + context.history.some( + (entry) => + entry instanceof vscode.ChatRequestTurn && + entry.command === command, + )) + ); + } + + // Default error handling + private handleError(err: any, stream: vscode.ChatResponseStream) { + if (err instanceof vscode.LanguageModelError) { + if ( + err.cause instanceof Error && + err.cause.message.includes("off_topic") + ) { + stream.markdown( + "Sorry, I can only help you with Ruby related questions", + ); + } + } else { + throw err; + } + } + + // Get the content of all previous interactions (including requests and responses) as a string + private previousInteractions(context: vscode.ChatContext): string { + let history = ""; + + context.history.forEach((entry) => { + if (entry instanceof vscode.ChatResponseTurn) { + if (entry.participant === CHAT_AGENT_ID) { + let content = ""; + + entry.response.forEach((part) => { + if (part instanceof vscode.ChatResponseMarkdownPart) { + content += part.value.value; + } + }); + + history += `Response: ${content}`; + } + } else { + history += `Request: ${entry.prompt}`; + } + }); + + return history; + } +} diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index ca7c66f7a1..148fa14a27 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -16,6 +16,7 @@ import { newMinitestFile, openFile, openUris } from "./commands"; import { Debugger } from "./debugger"; import { DependenciesTree } from "./dependenciesTree"; import { Rails } from "./rails"; +import { ChatAgent } from "./chatAgent"; // The RubyLsp class represents an instance of the entire extension. This should only be instantiated once at the // activation event. One instance of this class controls all of the existing workspaces, telemetry and handles all @@ -50,6 +51,7 @@ export class RubyLsp { this.statusItems, this.debug, dependenciesTree, + new ChatAgent(context, this.showWorkspacePick.bind(this)), // Switch the status items based on which workspace is currently active vscode.window.onDidChangeActiveTextEditor((editor) => { @@ -457,7 +459,7 @@ export class RubyLsp { vscode.commands.registerCommand( Command.RailsGenerate, async ( - generatorWithArguments: string | undefined, + generatorWithArguments: string | string[] | undefined, workspace: Workspace | undefined, ) => { // If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need @@ -474,13 +476,20 @@ export class RubyLsp { return; } - await this.rails.generate(command, workspace); + if (typeof command === "string") { + await this.rails.generate(command, workspace); + return; + } + + for (const generate of command) { + await this.rails.generate(generate, workspace); + } }, ), vscode.commands.registerCommand( Command.RailsDestroy, async ( - generatorWithArguments: string | undefined, + generatorWithArguments: string | string[] | undefined, workspace: Workspace | undefined, ) => { // If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need @@ -497,7 +506,14 @@ export class RubyLsp { return; } - await this.rails.destroy(command, workspace); + if (typeof command === "string") { + await this.rails.destroy(command, workspace); + return; + } + + for (const generate of command) { + await this.rails.destroy(generate, workspace); + } }, ), vscode.commands.registerCommand(Command.FileOperation, async () => {