在 VS Code 中试试

教程:使用语言模型 API 生成由 AI 驱动的代码注解

在本教程中,你将学习如何创建 VS Code 扩展来构建一个由 AI 驱动的代码辅导员。你将使用语言模型 (LM) API 来生成改进代码的建议,并利用 VS Code 扩展 API 将其无缝集成到编辑器中,作为用户可以悬停以获取更多信息的内联注解。完成本教程后,你将知道如何在 VS Code 中实现自定义 AI 功能。

VS Code displaying custom annotations from GitHub Copilot as annotations

先决条件

完成本教程,你需要以下工具和帐户

搭建扩展框架

首先,使用 Yeoman 和 VS Code 扩展生成器搭建一个准备好进行开发的 TypeScript 或 JavaScript 项目框架。

npx --package yo --package generator-code -- yo code

选择以下选项来完成新扩展向导...

# ? What type of extension do you want to create? New Extension (TypeScript)
# ? What's the name of your extension? Code Tutor

### Press <Enter> to choose default for all options below ###

# ? What's the identifier of your extension? code-tutor
# ? What's the description of your extension? LEAVE BLANK
# ? Initialize a git repository? Yes
# ? Bundle the source code with webpack? No
# ? Which package manager to use? npm

# ? Do you want to open the new folder with Visual Studio Code? Open with `code`

修改 package.json 文件以包含正确的命令

搭建好的项目在 package.json 文件中包含一个“helloWorld”命令。安装扩展后,此命令会显示在命令面板中。

"contributes": {
  "commands": [
      {
      "command": "code-tutor.helloWorld",
      "title": "Hello World"
      }
  ]
}

由于我们正在构建一个代码辅导员扩展,它将向行添加注解,因此我们需要一个命令来允许用户打开和关闭这些注解。更新 commandtitle 属性

"contributes": {
  "commands": [
      {
      "command": "code-tutor.annotate",
      "title": "Toggle Tutor Annotations"
      }
  ]
}

package.json 文件定义了扩展的命令和 UI 元素,而 src/extension.ts 文件则是存放这些命令应执行的代码的地方。

打开 src/extension.ts 文件并更改 registerCommand 方法,使其与 package.json 文件中的 command 属性匹配。

const disposable = vscode.commands.registerCommand('code-tutor.annotate', () => {

F5 运行扩展。这将打开一个安装了该扩展的新 VS Code 实例。按 ⇧⌘P(Windows、Linux 上是 Ctrl+Shift+P打开命令面板,然后搜索“tutor”。你应该看到“Tutor Annotations”命令。

The "Toggle Tutor Annotations" command in the VS Code Command Palette

如果你选择“Tutor Annotations”命令,你会看到一条“Hello World”通知消息。

The message 'Hello World from Code Tutor' displayed in a notification

实现“annotate”命令

为了让我们的代码辅导员注解工作,我们需要向其发送一些代码并要求它提供注解。我们将分三步完成此操作

  1. 从用户当前打开的标签页中获取带行号的代码。
  2. 将代码以及指导模型如何提供注解的自定义提示发送给语言模型 API。
  3. 解析注解并在编辑器中显示它们。

步骤 1:获取带行号的代码

要从当前标签页获取代码,我们需要引用用户打开的标签页。我们可以通过将 registerCommand 方法修改为 registerTextEditorCommand 来实现。这两个命令之间的区别在于,后者提供了用户打开的标签页的引用,称为 TextEditor

const disposable = vscode.commands.registerTextEditorCommand('code-tutor.annotate', async (textEditor: vscode.TextEditor) => {

现在我们可以使用 textEditor 引用来获取“可视图编辑器空间”中的所有代码。这是屏幕上可见的代码 - 它不包括可视图编辑器空间上方或下方的代码。

将以下方法直接添加到 extension.ts 文件底部 export function deactivate() { } 行的上方。

function getVisibleCodeWithLineNumbers(textEditor: vscode.TextEditor) {
  // get the position of the first and last visible lines
  let currentLine = textEditor.visibleRanges[0].start.line;
  const endLine = textEditor.visibleRanges[0].end.line;

  let code = '';

  // get the text from the line at the current position.
  // The line number is 0-based, so we add 1 to it to make it 1-based.
  while (currentLine < endLine) {
    code += `${currentLine + 1}: ${textEditor.document.lineAt(currentLine).text} \n`;
    // move to the next line position
    currentLine++;
  }
  return code;
}

此代码使用 TextEditor 的 visibleRanges 属性获取当前编辑器中可见行的位置。然后,它从第一行位置开始移动到最后一行位置,将每行代码及其行号添加到字符串中。最后,它返回包含所有可视图代码和行号的字符串。

现在我们可以从 code-tutor.annotate 命令中调用此方法。修改命令的实现,使其看起来像这样

const disposable = vscode.commands.registerTextEditorCommand(
  'code-tutor.annotate',
  async (textEditor: vscode.TextEditor) => {
    // Get the code with line numbers from the current editor
    const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor);
  }
);

步骤 2:向语言模型 API 发送代码和提示

下一步是调用 GitHub Copilot 语言模型,并将用户的代码以及创建注解的指令发送给它。

为此,我们首先需要指定要使用的聊天模型。我们在此选择 4o,因为它对于我们正在构建的交互类型来说是一个快速且强大的模型。

const disposable = vscode.commands.registerTextEditorCommand(
  'code-tutor.annotate',
  async (textEditor: vscode.TextEditor) => {
    // Get the code with line numbers from the current editor
    const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor);

    // select the 4o chat model
    let [model] = await vscode.lm.selectChatModels({
      vendor: 'copilot',
      family: 'gpt-4o'
    });
  }
);

我们需要指示 - 或者说“提示” - 告诉模型创建注解以及我们希望响应采用何种格式。将以下代码添加到文件顶部,紧接在导入之后。

const ANNOTATION_PROMPT = `You are a code tutor who helps students learn how to write better code. Your job is to evaluate a block of code that the user gives you and then annotate any lines that could be improved with a brief suggestion and the reason why you are making that suggestion. Only make suggestions when you feel the severity is enough that it will impact the readability and maintainability of the code. Be friendly with your suggestions and remember that these are students so they need gentle guidance. Format each suggestion as a single JSON object. It is not necessary to wrap your response in triple backticks. Here is an example of what your response should look like:

{ "line": 1, "suggestion": "I think you should use a for loop instead of a while loop. A for loop is more concise and easier to read." }{ "line": 12, "suggestion": "I think you should use a for loop instead of a while loop. A for loop is more concise and easier to read." }
`;

这是一个特殊的提示,指导语言模型如何生成注解。它还包含模型应如何格式化其响应的示例。这些示例(也称为“多轮”)使我们能够定义响应的格式,以便我们可以解析它并将其显示为注解。

我们将消息作为数组传递给模型。此数组可以包含任意数量的消息。在我们的例子中,它包含提示,后跟带行号的用户代码。

const disposable = vscode.commands.registerTextEditorCommand(
  'code-tutor.annotate',
  async (textEditor: vscode.TextEditor) => {
    // Get the code with line numbers from the current editor
    const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor);

    // select the 4o chat model
    let [model] = await vscode.lm.selectChatModels({
      vendor: 'copilot',
      family: 'gpt-4o'
    });

    // init the chat message
    const messages = [
      vscode.LanguageModelChatMessage.User(ANNOTATION_PROMPT),
      vscode.LanguageModelChatMessage.User(codeWithLineNumbers)
    ];
  }
);

要将消息发送到模型,我们首先需要确保所选模型可用。这处理了扩展尚未准备好或用户未登录 GitHub Copilot 的情况。然后我们将消息发送到模型。

const disposable = vscode.commands.registerTextEditorCommand(
  'code-tutor.annotate',
  async (textEditor: vscode.TextEditor) => {
    // Get the code with line numbers from the current editor
    const codeWithLineNumbers = getVisibleCodeWithLineNumbers(textEditor);

    // select the 4o chat model
    let [model] = await vscode.lm.selectChatModels({
      vendor: 'copilot',
      family: 'gpt-4o'
    });

    // init the chat message
    const messages = [
      vscode.LanguageModelChatMessage.User(ANNOTATION_PROMPT),
      vscode.LanguageModelChatMessage.User(codeWithLineNumbers)
    ];

    // make sure the model is available
    if (model) {
      // send the messages array to the model and get the response
      let chatResponse = await model.sendRequest(
        messages,
        {},
        new vscode.CancellationTokenSource().token
      );

      // handle chat response
      await parseChatResponse(chatResponse, textEditor);
    }
  }
);

聊天响应以片段形式出现。这些片段通常包含单个单词,但有时只包含标点符号。为了在响应流进来时显示注解,我们希望等到获得完整的注解后再显示它。由于我们指导模型返回响应的方式,我们知道当我们看到一个闭合的 } 时,我们就获得了一个完整的注解。然后我们可以解析该注解并将其显示在编辑器中。

将缺失的 parseChatResponse 函数添加到 extension.ts 文件中 getVisibleCodeWithLineNumbers 方法的上方。

async function parseChatResponse(
  chatResponse: vscode.LanguageModelChatResponse,
  textEditor: vscode.TextEditor
) {
  let accumulatedResponse = '';

  for await (const fragment of chatResponse.text) {
    accumulatedResponse += fragment;

    // if the fragment is a }, we can try to parse the whole line
    if (fragment.includes('}')) {
      try {
        const annotation = JSON.parse(accumulatedResponse);
        applyDecoration(textEditor, annotation.line, annotation.suggestion);
        // reset the accumulator for the next line
        accumulatedResponse = '';
      } catch (e) {
        // do nothing
      }
    }
  }
}

我们需要最后一个方法来实际显示注解。VS Code 将这些称为“装饰器 (decorations)”。将以下方法添加到 extension.ts 文件中 parseChatResponse 方法的上方。

function applyDecoration(editor: vscode.TextEditor, line: number, suggestion: string) {
  const decorationType = vscode.window.createTextEditorDecorationType({
    after: {
      contentText: ` ${suggestion.substring(0, 25) + '...'}`,
      color: 'grey'
    }
  });

  // get the end of the line with the specified line number
  const lineLength = editor.document.lineAt(line - 1).text.length;
  const range = new vscode.Range(
    new vscode.Position(line - 1, lineLength),
    new vscode.Position(line - 1, lineLength)
  );

  const decoration = { range: range, hoverMessage: suggestion };

  vscode.window.activeTextEditor?.setDecorations(decorationType, [decoration]);
}

此方法接收我们从模型中解析的注解,并用它来创建一个装饰器。首先创建一个 TextEditorDecorationType 来指定装饰器的外观。在本例中,我们只是添加一个灰色的注解并将其截断为 25 个字符。当用户将鼠标悬停在消息上时,我们将显示完整消息。

然后我们设置装饰器应显示的位置。我们需要它位于注解中指定的行号上,并且在行的末尾。

最后,我们在活动文本编辑器上设置装饰器,这使得注解出现在编辑器中。

如果你的扩展仍在运行,请从调试栏中选择绿色箭头重新启动。如果你关闭了调试会话,请按 F5 运行扩展。在新打开的 VS Code 窗口实例中打开一个代码文件。从命令面板中选择“Toggle Tutor Annotations”时,你应该会看到代码注解出现在编辑器中。

A code file with annotations from GitHub Copilot

向编辑器标题栏添加按钮

你可以使你的命令除了命令面板之外,还可以从其他地方调用。在我们的例子中,我们可以在当前标签页的顶部添加一个按钮,让用户可以轻松地切换注解。

为此,请修改 package.json 的“contributes”部分,如下所示

"contributes": {
  "commands": [
    {
      "command": "code-tutor.annotate",
      "title": "Toggle Tutor Annotations",
      "icon": "$(comment)"
    }
  ],
  "menus": {
    "editor/title": [
      {
        "command": "code-tutor.annotate",
        "group": "navigation"
      }
    ]
  }
}

这将导致一个按钮出现在编辑器标题栏的导航区域(右侧)。该“图标”来自产品图标参考

用绿色箭头重新启动你的扩展,或者如果扩展尚未运行,则按 F5。现在你应该会看到一个评论图标,它将触发“Toggle Tutor Annotations”命令。

A comment icon appears in the title bar of the active tab in VS Code

后续步骤

在本教程中,你学习了如何创建 VS Code 扩展,利用语言模型 API 将 AI 集成到编辑器中。你使用了 VS Code 扩展 API 来获取当前标签页的代码,将其与自定义提示一起发送给模型,然后解析模型结果并使用装饰器直接在编辑器中显示。

接下来,你可以扩展你的代码辅导员扩展,使其也包含一个聊天参与者,这将允许用户通过 GitHub Copilot 聊天界面直接与你的扩展交互。你还可以探索 VS Code 中的全部 API,探索构建自定义 AI 体验的新方式。

你可以在vscode-extensions-sample 仓库中找到本教程的完整源代码。