diff --git a/package.json b/package.json index 5b2b15f..80116a7 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "devDependencies": { "@fluentui/react": "8.111.2", "@hpcc-js/common": "2.71.11", - "@hpcc-js/comms": "2.87.0", + "@hpcc-js/comms": "^2.93.0", "@hpcc-js/ddl-shim": "2.20.5", "@hpcc-js/dgrid2": "2.3.11", "@hpcc-js/loader": "2.104.28", @@ -74,13 +74,14 @@ "@types/react": "17.0.65", "@types/react-dom": "17.0.20", "@types/tmp": "0.2.4", - "@types/vscode": "1.76.0", + "@types/vscode": "1.82.0", "@types/vscode-notebook-renderer": "1.72.0", "@typescript-eslint/eslint-plugin": "6.7.0", "@typescript-eslint/parser": "6.7.0", "@vscode/debugadapter": "1.63.0", "@vscode/debugprotocol": "1.63.0", "@vscode/extension-telemetry": "0.6.2", + "@vscode/prompt-tsx": "^0.2.3-alpha", "@vscode/vsce": "2.21.0", "acorn-walk": "8.2.0", "adm-zip": "0.5.10", @@ -133,7 +134,8 @@ "theme": "light" }, "extensionDependencies": [ - "GordonSmith.observable-js" + "GordonSmith.observable-js", + "github.copilot-chat" ], "activationEvents": [ "onLanguage:ecl", @@ -221,6 +223,30 @@ ], "viewsWelcome": [], "commands": [ + { + "command": "eclagent.summarizeFileContent", + "title": "Summarize File Content" + }, + { + "command": "eclagent.commentFileContent", + "title": "Normal Comment" + }, + { + "command": "eclagent.commentDetailedFileContent", + "title": "Detailed Comment" + }, + { + "command": "eclagent.commentTerseFileContent", + "title": "Terse Comment" + }, + { + "command": "eclagent.redoPrompt", + "title": "Redo Last Prompt" + }, + { + "command": "eclagent.revertFileContent", + "title": "Revert File Content" + }, { "command": "ecl.submit", "category": "ECL", @@ -854,6 +880,26 @@ } ], "editor/context": [ + { + "when": "resourceLangId == ecl && resourceExtname == .ecl", + "command": "eclagent.summarizeFileContent", + "group": "aaa@1" + }, + { + "when": "resourceLangId == ecl && resourceExtname == .ecl", + "submenu": "comments", + "group": "aaa@2" + }, + { + "when": "resourceLangId == ecl && resourceExtname == .ecl", + "command": "eclagent.redoPrompt", + "group": "aaa@3" + }, + { + "when": "resourceLangId == ecl && resourceExtname == .ecl", + "command": "eclagent.revertFileContent", + "group": "aaa@4" + }, { "when": "resourceLangId == ecl && resourceExtname == .ecl", "command": "ecl.submit", @@ -992,6 +1038,10 @@ } ], "view/item/context": [ + { + "submenu": "comments", + "group": "navigation@1" + }, { "command": "ecl.openECLWatchExternal", "when": "view == hpccPlatform && viewItem =~ /^ECLWUNode/", @@ -1113,6 +1163,20 @@ "group": "inline" } ], + "comments": [ + { + "command": "eclagent.commentFileContent", + "group": "navigation@1" + }, + { + "command": "eclagent.commentDetailedFileContent", + "group": "navigation@2" + }, + { + "command": "eclagent.commentTerseFileContent", + "group": "navigation@3" + } + ], "notebook/cell/title": [ { "command": "notebook.cell.public", diff --git a/src/chat/main.ts b/src/chat/main.ts new file mode 100644 index 0000000..41b62a1 --- /dev/null +++ b/src/chat/main.ts @@ -0,0 +1,389 @@ +import { renderPrompt } from "@vscode/prompt-tsx"; +import * as vscode from "vscode"; +import { SummarizePrompt, CommentPrompt, CommentDetailedPrompt, CommentTersePrompt, FollowupQuestionsPrompt, GeneralECLQuestion } from "./prompts" + +const ECLAGENT_SUMMARIZE_COMMAND = "eclagent.summarizeFileContent"; +const ECLAGENT_COMMENT_COMMAND = "eclagent.commentFileContent"; +const ECLAGENT_REVERT_COMMAND = "eclagent.revertFileContent"; +const ECLAGENT_PARTICIPANT_ID = "chat-sample.eclagent"; + +const ECLTEACH_TOPICS = [ + "Overview ", + "Constants", + "Environment Variables", + "Definitions", + "Basic Definition Types", + "Recordset Filtering", + "Function Definitions (Parameter Passing)", + "Definition Visibility", + "Field and Definition Qualification", + "Actions and Definitions" +]; + +interface ICatChatResult extends vscode.ChatResult { + metadata: { + command: string; + } +} + +const MODEL_SELECTOR: vscode.LanguageModelChatSelector = { vendor: "copilot", family: "gpt-3.5-turbo" }; + +let lastPrompt = {command: ECLAGENT_SUMMARIZE_COMMAND, prompt: SummarizePrompt}; + +export function activate(context: vscode.ExtensionContext) { + + // Define a eclagent chat handler. + const handler: vscode.ChatRequestHandler = async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise => { + // To talk to an LLM in your subcommand handler implementation, your + // extension can use VS Code"s `requestChatAccess` API to access the Copilot API. + // The GitHub Copilot Chat extension implements this provider. + + if (request.command == "summarize") { + stream.progress("Summarizing your file..."); + vscode.commands.executeCommand(ECLAGENT_SUMMARIZE_COMMAND); + } + else if (request.command == "comment") { + stream.progress("Commenting your file..."); + await vscode.commands.executeCommand(ECLAGENT_COMMENT_COMMAND, {prompt: CommentDetailedPrompt}); + } + else if (request.command == "revert") { + stream.progress("Reverting your file..."); + vscode.commands.executeCommand(ECLAGENT_REVERT_COMMAND); + } + else if (request.command == "teach") { + stream.progress("Picking the right topic to teach..."); + const topic = getTopic(context.history); + try { + const [model] = await vscode.lm.selectChatModels(MODEL_SELECTOR); + if (model) { + let messages = [ + vscode.LanguageModelChatMessage.User("You are an ECL language expert! Your job is to explain ECL concepts. Always start your response by stating what concept you are explaining. Always include code samples."), + vscode.LanguageModelChatMessage.User(topic) + ]; + let chatResponse = await model.sendRequest(messages, {}, token); + stream.markdown(`## Topic: ${topic}\n`); + for await (const fragment of chatResponse.text) { + stream.markdown(fragment); + } + + await followupQuestions(eclagent, topic, model, request, context, stream, token); + } + } catch(err) { + handleError(err, stream); + } + + return { metadata: { command: "teach" } }; + } else { + try { + const [model] = await vscode.lm.selectChatModels(MODEL_SELECTOR); + if (model) { + const { messages } = await renderPrompt( + GeneralECLQuestion, + { userQuery: request.prompt, topic: "ECL"}, + { modelMaxPromptTokens: model.maxInputTokens }, + model); + const chatResponse = await model.sendRequest(messages, {}, token); + for await (const fragment of chatResponse.text) { + stream.markdown(fragment); + } + await followupQuestions(eclagent, request.prompt, model, request, context, stream, token); + } + } catch(err) { + handleError(err, stream); + } + + return { metadata: { command: "" } }; + } + }; + + // Chat participants appear as top-level options in the chat input + // when you type `@`, and can contribute sub-commands in the chat input + // that appear when you type `/`. + const eclagent = vscode.chat.createChatParticipant(ECLAGENT_PARTICIPANT_ID, handler); + eclagent.iconPath = vscode.Uri.joinPath(context.extensionUri, "hpcc-icon.png"); + eclagent.followupProvider = { + provideFollowups(result: ICatChatResult, context: vscode.ChatContext, token: vscode.CancellationToken) { + return [{ + prompt: "show me another topic", + label: vscode.l10n.t("Show me another topic"), + command: '/teach' + } satisfies vscode.ChatFollowup]; + } + }; + + context.subscriptions.push( + eclagent, + vscode.commands.registerTextEditorCommand("eclagent.redoPrompt", async (textEditor: vscode.TextEditor) => { + vscode.commands.executeCommand(ECLAGENT_REVERT_COMMAND).then(() => { + vscode.commands.executeCommand(lastPrompt.command, {prompt: lastPrompt.prompt}); + }); + }) + ); + + context.subscriptions.push( + eclagent, + vscode.commands.registerTextEditorCommand("eclagent.commentDetailedFileContent", async (textEditor: vscode.TextEditor) => { + vscode.commands.executeCommand(ECLAGENT_COMMENT_COMMAND, {prompt: CommentDetailedPrompt}); + }) + ); + + context.subscriptions.push( + eclagent, + vscode.commands.registerTextEditorCommand("eclagent.commentTerseFileContent", async (textEditor: vscode.TextEditor) => { + vscode.commands.executeCommand(ECLAGENT_COMMENT_COMMAND, {prompt: CommentTersePrompt}); + }) + ); + + context.subscriptions.push( + eclagent, + // Register the command handler for the /meow followup + vscode.commands.registerTextEditorCommand(ECLAGENT_SUMMARIZE_COMMAND, async (textEditor: vscode.TextEditor) => { + // Replace all variables in active editor with eclagent names and words + const text = textEditor.document.getText(); + + let chatResponse: vscode.LanguageModelChatResponse | undefined; + try { + const [model] = await vscode.lm.selectChatModels({ vendor: "copilot", family: "gpt-3.5-turbo" }); + if (!model) { + console.log("Model not found. Please make sure the GitHub Copilot Chat extension is installed and enabled.") + return; + } + + const { messages } = await renderPrompt( + SummarizePrompt, + { userQuery: text, topic: "ECL"}, + { modelMaxPromptTokens: model.maxInputTokens }, + model); + chatResponse = await model.sendRequest(messages, {}, new vscode.CancellationTokenSource().token); + lastPrompt = {command: ECLAGENT_SUMMARIZE_COMMAND, prompt: SummarizePrompt}; + + } catch (err) { + if (err instanceof vscode.LanguageModelError) { + console.log(err.message, err.code, err.cause) + } else { + throw err; + } + return; + } + + // Stream the code into the editor as it is coming in from the Language Model + try { + const firstLine = new vscode.Position(0, 0); + const startLine = new vscode.Position(2, 0); + textEditor.selection = new vscode.Selection(firstLine, firstLine); + + await textEditor.edit(edit => { + edit.insert(firstLine, "/*\n\n\n\n*/\n\n"); + }); + + let first = true; + for await (const fragment of chatResponse.text) { + await textEditor.edit(edit => { + const cursorPosition = first? startLine : textEditor.selection.active; + first = false; + edit.insert(cursorPosition, fragment); + const ppp = new vscode.Position(cursorPosition.line, cursorPosition.character + fragment.length); + textEditor.selection = new vscode.Selection(ppp, ppp); + }); + } + + } catch (err) { + // async response stream may fail, e.g network interruption or server side error + await textEditor.edit(edit => { + const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1); + const position = new vscode.Position(lastLine.lineNumber, lastLine.text.length); + edit.insert(position, (err).message); + }); + } + }), + ); + + context.subscriptions.push( + eclagent, + // Register the command handler for the /meow followup + vscode.commands.registerTextEditorCommand(ECLAGENT_COMMENT_COMMAND, async (textEditor: vscode.TextEditor, edit, args) => { + const text = textEditor.document.getText(); + + let chatResponse: vscode.LanguageModelChatResponse | undefined; + try { + const [model] = await vscode.lm.selectChatModels({ vendor: "copilot", family: "gpt-3.5-turbo" }); + if (!model) { + console.log("Model not found. Please make sure the GitHub Copilot Chat extension is installed and enabled.") + return; + } + + let prompt = CommentPrompt; + if (args.prompt !== undefined) { + prompt = args.prompt; + } + + const { messages } = await renderPrompt( + prompt, + { userQuery: text, topic: "ECL"}, + { modelMaxPromptTokens: model.maxInputTokens }, + model); + + chatResponse = await model.sendRequest(messages, {}, new vscode.CancellationTokenSource().token); + lastPrompt = {command: ECLAGENT_COMMENT_COMMAND, prompt: prompt}; + + } catch (err) { + if (err instanceof vscode.LanguageModelError) { + console.log(err.message, err.code, err.cause) + } else { + throw err; + } + return; + } + + await textEditor.edit(edit => { + const start = new vscode.Position(0, 0); + const end = new vscode.Position(textEditor.document.lineCount - 1, textEditor.document.lineAt(textEditor.document.lineCount - 1).text.length); + edit.delete(new vscode.Range(start, end)); + }); + + // Stream the code into the editor as it is coming in from the Language Model + try { + for await (const fragment of chatResponse.text) { + await textEditor.edit(edit => { + const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1); + const position = new vscode.Position(lastLine.lineNumber, lastLine.text.length); + edit.insert(position, fragment); + }); + } + + } catch (err) { + // async response stream may fail, e.g network interruption or server side error + await textEditor.edit(edit => { + const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1); + const position = new vscode.Position(lastLine.lineNumber, lastLine.text.length); + edit.insert(position, (err).message); + }); + } + }), + ); + + context.subscriptions.push( + eclagent, + // Register the command handler for the /meow followup + vscode.commands.registerTextEditorCommand(ECLAGENT_REVERT_COMMAND, async (textEditor: vscode.TextEditor) => { + + const filePath = textEditor.document.uri.fsPath; + try { + const fileContent = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); + const newText = Buffer.from(fileContent).toString("utf-8").substring(1); + await textEditor.edit((editBuilder) => { + const firstLine = new vscode.Position(0, 0); + editBuilder.replace(new vscode.Range(firstLine, textEditor.document.positionAt(textEditor.document.getText().length)), newText); + }); + } catch (error) { + console.error("Error reading file:", error); + } + }), + ); +} + +async function followupQuestions(eclagent: vscode.ChatParticipant, topic: string, model: vscode.LanguageModelChat, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise { + let chatResponse = undefined; + try { + const { messages } = await renderPrompt( + FollowupQuestionsPrompt, + { userQuery: request.prompt, topic: topic}, + { modelMaxPromptTokens: model.maxInputTokens }, + model); + chatResponse = await model.sendRequest(messages, {}, new vscode.CancellationTokenSource().token); + + } catch (err) { + if (err instanceof vscode.LanguageModelError) { + console.log(err.message, err.code, err.cause) + } else { + throw err; + } + return; + } + + if (!chatResponse) { + return { metadata: { command: "teach" } }; + } + try { + let answer = ""; + for await (const fragment of chatResponse.text) { + answer += fragment; + } + stream.markdown("\n## Follow up questions:\n"); + const lines = answer.split("\n"); + let questions = []; + let count = 0; + for (const line of lines) { + if (/^\d/.test(line) || line.startsWith("*")) { + const index = line.indexOf(' '); + const str = index !== -1 ? line.substring(index + 1) : line; + questions.push(str); + // stream.markdown(`${str}\n`); + count++; + } + if (count >= 3) { + break; + } + } + + const followups: vscode.ChatFollowup[] = []; + for (let i = 0; i < count; i++) { + const question = questions[i]; + followups.push({ + prompt: question, + command: "", + label: vscode.l10n.t(question) + }); + } + followups.push({ + prompt: "show me another topic", + label: vscode.l10n.t("Show me another topic"), + command: "" + }); + eclagent.followupProvider = { + provideFollowups(result: ICatChatResult, context: vscode.ChatContext, token: vscode.CancellationToken) { + return followups; + } + }; + } + catch (err) { + let donothing = true; + } +} + +function handleError(err: any, stream: vscode.ChatResponseStream): void { + // making the chat request might fail because + // - model does not exist + // - user consent not given + // - quote limits exceeded + if (err instanceof vscode.LanguageModelError) { + console.log(err.message, err.code, err.cause); + if (err.cause instanceof Error && err.cause.message.includes("off_topic")) { + stream.markdown(vscode.l10n.t("I'm sorry, I can only explain computer science concepts.")); + } + } else { + // re-throw other errors so they show up in the UI + throw err; + } +} + +// Get a random topic that the eclagent has not taught in the chat history yet +function getTopic(history: ReadonlyArray): string { + const topics = ECLTEACH_TOPICS; + // Filter the chat history to get only the responses from the eclagent + const previousCatResponses = history.filter(h => { + return h instanceof vscode.ChatResponseTurn && h.participant == ECLAGENT_PARTICIPANT_ID + }) as vscode.ChatResponseTurn[]; + // Filter the topics to get only the topics that have not been taught by the eclagent yet + const topicsNoRepetition = topics.filter(topic => { + return !previousCatResponses.some(catResponse => { + return catResponse.response.some(r => { + return r instanceof vscode.ChatResponseMarkdownPart && r.value.value.includes(topic) + }); + }); + }); + + return topicsNoRepetition[Math.floor(Math.random() * topicsNoRepetition.length)] || "I have taught you everything I know. Meow!"; +} + +export function deactivate() { } diff --git a/src/chat/prompts.tsx b/src/chat/prompts.tsx new file mode 100644 index 0000000..87e3aa3 --- /dev/null +++ b/src/chat/prompts.tsx @@ -0,0 +1,87 @@ +import { + BasePromptElementProps, + PromptElement, + PromptSizing, + UserMessage +} from '@vscode/prompt-tsx'; + +export interface PromptProps extends BasePromptElementProps { + userQuery: string; + topic: string; +} + +export class SummarizePrompt extends PromptElement { + render(state: void, sizing: PromptSizing) { + return ( + <> + + You are an ECL expert! Your job is to read the following ECL code and summarize it. When summarizing, please avoid listing a step-by-step description and use more of a business-like tone. Please do not use markdown! + + {this.props.userQuery} + + ); + } +} + +export class CommentPrompt extends PromptElement { + render(state: void, sizing: PromptSizing) { + return ( + <> + + Your are an ECL programming language expert. Your job is to insert comments throughout this code. Do not use markdown!. + + {this.props.userQuery} + + ); + } +} +export class CommentDetailedPrompt extends PromptElement { + render(state: void, sizing: PromptSizing) { + return ( + <> + + Your are an ECL programming language expert. Your job is to insert comments throughout this code. Be extremely detailed. Do not use markdown!). + + {this.props.userQuery} + + ); + } +} +export class CommentTersePrompt extends PromptElement { + render(state: void, sizing: PromptSizing) { + return ( + <> + + Your are an ECL programming language expert. Your job is to insert comments throughout this code. Be very sparse and terse. Do not use markdown!). + + {this.props.userQuery} + + ); + } +} + +export class FollowupQuestionsPrompt extends PromptElement { + render(state: void, sizing: PromptSizing) { + return ( + <> + + Suggest some similar questions about the ECL programming language that can be asked about the topic ${this.props.topic}. + + {this.props.userQuery} + + ); + } +} + +export class GeneralECLQuestion extends PromptElement { + render(state: void, sizing: PromptSizing) { + return ( + <> + + You are an ECL language expert! Think carefully and step by step like an ECL language expert who is good at explaining something. Your job is to explain computer science concepts in fun and entertaining way. Always start your response by stating what concept you are explaining. Always include code samples. + + In the ECL language, explain {this.props.userQuery} + + ); + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 337aea4..3d3e7e3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,8 @@ export function activate(context: vscode.ExtensionContext): void { return Promise.all([ import("./ecl/main").then(({ activate }) => activate(context)), import("./kel/main").then(({ activate }) => activate(context)), - import("./dashy/main").then(({ activate }) => activate(context)) + import("./dashy/main").then(({ activate }) => activate(context)), + import("./chat/main").then(({ activate }) => activate(context)) ]); }).then(() => { reporter.sendTelemetryEvent("initialized"); diff --git a/tsconfig.json b/tsconfig.json index 661baff..76dde38 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,16 @@ "compilerOptions": { "rootDir": "./src", "outDir": "lib-es6", - "target": "ES2021", - "module": "ES6", - "moduleResolution": "Node", + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", "declaration": true, "declarationDir": "./types", "sourceMap": true, "declarationMap": true, "jsx": "react", + "jsxFactory": "vscpp", + "jsxFragmentFactory": "vscppf", "allowJs": true, "resolveJsonModule": true, "noEmitOnError": false, @@ -23,7 +25,7 @@ "allowSyntheticDefaultImports": true, "lib": [ "DOM", - "ES2021" + "ES2022" ], "paths": { "@hpcc-js/*": [