|
| 1 | +import * as vscode from "vscode"; |
| 2 | + |
| 3 | +import { Command } from "./common"; |
| 4 | +import { Workspace } from "./workspace"; |
| 5 | + |
| 6 | +const CHAT_AGENT_ID = "rubyLsp.chatAgent"; |
| 7 | +const DESIGN_PROMPT = ` |
| 8 | + You are a domain driven design and Ruby on Rails expert. |
| 9 | + The user will provide you with details about their Rails application. |
| 10 | + The user will ask you to help model a single specific concept. |
| 11 | +
|
| 12 | + Analyze the provided concept carefully and think step by step. Consider the following aspects: |
| 13 | + 1. The core purpose of the concept |
| 14 | + 2. Its relationships with other potential entities in the system |
| 15 | + 3. The attributes that would best represent this concept in a database |
| 16 | +
|
| 17 | + Based on your analysis, suggest an appropriate model name and attributes to effectively model the concept. |
| 18 | + Follow these guidelines: |
| 19 | +
|
| 20 | + 1. Choose a clear, singular noun for the model name that accurately represents the concept |
| 21 | + 2. Select attributes that capture the essential characteristics of the concept |
| 22 | + 3. Use appropriate data types for each attribute (e.g. string, integer, datetime, boolean) |
| 23 | + 4. Consider adding foreign keys for relationships with other models, if applicable |
| 24 | +
|
| 25 | + After determining the model structure, generate the Rails commands to create the model and any associated resources. |
| 26 | + Include all relevant \`generate\` commands in a single Markdown shell code block at the end of your response. |
| 27 | +
|
| 28 | + The \`generate\` commands should ONLY include the type of generator and arguments, not the \`rails generate\` part |
| 29 | + (e.g.: \`model User name:string\` but not \`rails generate model User name:string\`). |
| 30 | + NEVER include commands to migrate the database as part of the code block. |
| 31 | + NEVER include redundant commands (e.g. including the migration and model generation commands for the same model). |
| 32 | +`.trim(); |
| 33 | + |
| 34 | +export class ChatAgent implements vscode.Disposable { |
| 35 | + private readonly agent: vscode.ChatParticipant; |
| 36 | + private readonly showWorkspacePick: () => Promise<Workspace | undefined>; |
| 37 | + |
| 38 | + constructor( |
| 39 | + context: vscode.ExtensionContext, |
| 40 | + showWorkspacePick: () => Promise<Workspace | undefined>, |
| 41 | + ) { |
| 42 | + this.agent = vscode.chat.createChatParticipant( |
| 43 | + CHAT_AGENT_ID, |
| 44 | + this.handler.bind(this), |
| 45 | + ); |
| 46 | + this.agent.iconPath = vscode.Uri.joinPath(context.extensionUri, "icon.png"); |
| 47 | + this.showWorkspacePick = showWorkspacePick; |
| 48 | + } |
| 49 | + |
| 50 | + dispose() { |
| 51 | + this.agent.dispose(); |
| 52 | + } |
| 53 | + |
| 54 | + // Handle a new chat message or command |
| 55 | + private async handler( |
| 56 | + request: vscode.ChatRequest, |
| 57 | + context: vscode.ChatContext, |
| 58 | + stream: vscode.ChatResponseStream, |
| 59 | + token: vscode.CancellationToken, |
| 60 | + ) { |
| 61 | + if (this.withinConversation("design", request, context)) { |
| 62 | + return this.runDesignCommand(request, context, stream, token); |
| 63 | + } |
| 64 | + |
| 65 | + stream.markdown( |
| 66 | + "Please indicate which command you would like to use for our chat.", |
| 67 | + ); |
| 68 | + return { metadata: { command: "" } }; |
| 69 | + } |
| 70 | + |
| 71 | + // Logic for the domain driven design command |
| 72 | + private async runDesignCommand( |
| 73 | + request: vscode.ChatRequest, |
| 74 | + context: vscode.ChatContext, |
| 75 | + stream: vscode.ChatResponseStream, |
| 76 | + token: vscode.CancellationToken, |
| 77 | + ) { |
| 78 | + const previousInteractions = this.previousInteractions(context); |
| 79 | + const messages = [ |
| 80 | + vscode.LanguageModelChatMessage.User(`User prompt: ${request.prompt}`), |
| 81 | + vscode.LanguageModelChatMessage.User(DESIGN_PROMPT), |
| 82 | + vscode.LanguageModelChatMessage.User( |
| 83 | + `Previous interactions with the user: ${previousInteractions}`, |
| 84 | + ), |
| 85 | + ]; |
| 86 | + const workspace = await this.showWorkspacePick(); |
| 87 | + |
| 88 | + // On the first interaction with the design command, we gather the application's schema and include it as part of |
| 89 | + // the prompt |
| 90 | + if (request.command && workspace) { |
| 91 | + const schema = await this.schema(workspace); |
| 92 | + |
| 93 | + if (schema) { |
| 94 | + messages.push( |
| 95 | + vscode.LanguageModelChatMessage.User( |
| 96 | + `Existing application schema: ${schema}`, |
| 97 | + ), |
| 98 | + ); |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + try { |
| 103 | + // Select the LLM model |
| 104 | + const [model] = await vscode.lm.selectChatModels({ |
| 105 | + vendor: "copilot", |
| 106 | + family: "gpt-4o", |
| 107 | + }); |
| 108 | + |
| 109 | + stream.progress("Designing the models for the requested concept..."); |
| 110 | + const chatResponse = await model.sendRequest(messages, {}, token); |
| 111 | + |
| 112 | + let response = ""; |
| 113 | + for await (const fragment of chatResponse.text) { |
| 114 | + // Maybe show the buttons here and display multiple shell blocks? |
| 115 | + stream.markdown(fragment); |
| 116 | + response += fragment; |
| 117 | + } |
| 118 | + |
| 119 | + const match = /(?<=```shell)[^.$]*(?=```)/.exec(response); |
| 120 | + |
| 121 | + if (workspace && match && match[0]) { |
| 122 | + // The shell code block includes all of the `rails generate` commands. We need to strip out the `rails generate` |
| 123 | + // from all of them since our commands only accept from the generator forward |
| 124 | + const commandList = match[0] |
| 125 | + .trim() |
| 126 | + .split("\n") |
| 127 | + .map((command) => { |
| 128 | + return command.replace(/\s*(bin\/rails|rails) generate\s*/, ""); |
| 129 | + }); |
| 130 | + |
| 131 | + stream.button({ |
| 132 | + command: Command.RailsGenerate, |
| 133 | + title: "Generate with Rails", |
| 134 | + arguments: [commandList, workspace], |
| 135 | + }); |
| 136 | + |
| 137 | + stream.button({ |
| 138 | + command: Command.RailsDestroy, |
| 139 | + title: "Revert previous generation", |
| 140 | + arguments: [commandList, workspace], |
| 141 | + }); |
| 142 | + } |
| 143 | + } catch (err) { |
| 144 | + this.handleError(err, stream); |
| 145 | + } |
| 146 | + |
| 147 | + return { metadata: { command: "design" } }; |
| 148 | + } |
| 149 | + |
| 150 | + private async schema(workspace: Workspace) { |
| 151 | + try { |
| 152 | + const content = await vscode.workspace.fs.readFile( |
| 153 | + vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/schema.rb"), |
| 154 | + ); |
| 155 | + return content.toString(); |
| 156 | + } catch (error) { |
| 157 | + // db/schema.rb doesn't exist |
| 158 | + } |
| 159 | + |
| 160 | + try { |
| 161 | + const content = await vscode.workspace.fs.readFile( |
| 162 | + vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/structure.sql"), |
| 163 | + ); |
| 164 | + return content.toString(); |
| 165 | + } catch (error) { |
| 166 | + // db/structure.sql doesn't exist |
| 167 | + } |
| 168 | + |
| 169 | + return undefined; |
| 170 | + } |
| 171 | + |
| 172 | + // Returns `true` if the current or any previous interactions with the chat match the given `command`. Useful for |
| 173 | + // ensuring that the user can continue chatting without having to re-type the desired command multiple times |
| 174 | + private withinConversation( |
| 175 | + command: string, |
| 176 | + request: vscode.ChatRequest, |
| 177 | + context: vscode.ChatContext, |
| 178 | + ) { |
| 179 | + return ( |
| 180 | + request.command === command || |
| 181 | + (!request.command && |
| 182 | + context.history.some( |
| 183 | + (entry) => |
| 184 | + entry instanceof vscode.ChatRequestTurn && |
| 185 | + entry.command === command, |
| 186 | + )) |
| 187 | + ); |
| 188 | + } |
| 189 | + |
| 190 | + // Default error handling |
| 191 | + private handleError(err: any, stream: vscode.ChatResponseStream) { |
| 192 | + if (err instanceof vscode.LanguageModelError) { |
| 193 | + if ( |
| 194 | + err.cause instanceof Error && |
| 195 | + err.cause.message.includes("off_topic") |
| 196 | + ) { |
| 197 | + stream.markdown( |
| 198 | + "Sorry, I can only help you with Ruby related questions", |
| 199 | + ); |
| 200 | + } |
| 201 | + } else { |
| 202 | + throw err; |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + // Get the content of all previous interactions (including requests and responses) as a string |
| 207 | + private previousInteractions(context: vscode.ChatContext): string { |
| 208 | + let history = ""; |
| 209 | + |
| 210 | + context.history.forEach((entry) => { |
| 211 | + if (entry instanceof vscode.ChatResponseTurn) { |
| 212 | + if (entry.participant === CHAT_AGENT_ID) { |
| 213 | + let content = ""; |
| 214 | + |
| 215 | + entry.response.forEach((part) => { |
| 216 | + if (part instanceof vscode.ChatResponseMarkdownPart) { |
| 217 | + content += part.value.value; |
| 218 | + } |
| 219 | + }); |
| 220 | + |
| 221 | + history += `Response: ${content}`; |
| 222 | + } |
| 223 | + } else { |
| 224 | + history += `Request: ${entry.prompt}`; |
| 225 | + } |
| 226 | + }); |
| 227 | + |
| 228 | + return history; |
| 229 | + } |
| 230 | +} |
0 commit comments