Monaco Editor 中使用在线版 Copilot

引言

现代软件开发中,代码编辑器的功能不断演进,以满足开发者对高效和智能化工具的需求。Monaco Editor 作为一种轻量级但功能强大的代码编辑器,广泛应用于多种开发环境中。在此背景下,Copilot,一款由 GitHub 开发的 AI 编程助手,凭借其智能代码补全和建议功能,迅速吸引了开发者的关注。

本文将探讨如何在 Monaco Editor 中实现在线版 Copilot 功能的代码续写,旨在为用户提供更加高效的编程体验。

Copilot

什么是 Copilot?

Copilot 是由 GitHub 开发的一款人工智能编程助手,它利用机器学习和自然语言处理技术,旨在帮助开发者更高效地编写代码。Copilot 通过分析大量的开源代码库和文档,能够理解开发者的意图并提供实时的代码建议和补全。当然,除了 Copilot ,还有很多类似的产品,如 Cursor、CodeWhisperer、CodeGeeX、通义灵码、iFlyCode …

工作原理

Copilot 基于 OpenAI 的 Codex 模型,该模型经过大量代码和自然语言数据的训练,能够生成符合语法和逻辑的代码。它通过分析开发者的输入和上下文,预测最可能的代码片段,并将其呈现给用户。

使用效果

Copilot 可以在当前光标处自动生成补全代码。如下图所示

file

简版实现

github copilot 提供了 vs code 的插件,支持在 vs code 中使用,那是否可以在 Web Editor 中也实现一个 Copilot 呢?通过查看 Monaco Editor 的 API ,可以看到是提供了这么一个 Provider 的。

registerInlineCompletionsProvider

registerInlineCompletionsProvider 是 Monaco Editor 中的一个方法,用于注册一个内联补全 Provider。这个功能允许开发者在代码编辑器中提供上下文相关的补全建议,提升用户的编码效率。

registerInlineCompletionsProvider 支持接收 2 个参数:

  • languageId:要给哪个 language 注册这个Provider。这个 Provider 只会在 Monaco Editor 的 language 设置为该 language 时,才会被触发。
  • provider:
    • provideInlineCompletions:该方法用于提供内联补全建议,它根据当前文本模型、光标位置和上下文信息生成适合的补全项,并返回给编辑器。
      • 参数
        • model: editor.ITextModel:当前编辑器的文本模型,包含用户正在编辑的文本。
        • position: Position:光标的当前位置,指示补全建议的上下文。
        • context: InlineCompletionContext:提供有关补全上下文的信息,例如用户输入状态和触发条件。
        • token: CancellationToken:用于取消操作的令牌,确保性能和可控性。
    • freeInlineCompletions:当补全列表不再使用且可以被垃圾回收时,该方法会被调用。允许开发者执行清理操作,释放资源。
      • 参数
        • completions: T:需要释放的补全项集合。
    • handleItemDidShow:当补全项被展示给用户时,该方法会被调用。允许开发者执行特定的逻辑,例如记录日志、更新UI或执行其他操作。
      • 参数
        • completions: T:当前的补全项集合。
        • item: T['items'][number]:被展示的具体补全项。

如下图例子所示

file

具体实现

思路

在编辑器中,每当内容发生变更时,都会触发 registerInlineCompletionsProvider 。在这个 Provider 中执行补全。整个补全的过程:

  1. 修改光标状态
  2. 获取上下文内容,发送给 AI
  3. 等待 AI 返回补全结果,将 AI 的结果进行返回。这里返回的格式(这里以 Monaco Editor@0.31.1 为例,@0.34 版本开始与此有些差别):
interface InlineCompletion {
    /**
     * The text to insert.
     * If the text contains a line break, the range must end at the end of a line.
     * If existing text should be replaced, the existing text must be a prefix of the text to insert.
    */
    readonly text: string;
    /**
     * The range to replace.
     * Must begin and end on the same line.
    */
    readonly range?: IRange;
    readonly command?: Command;
}
interface InlineCompletions<TItem extends InlineCompletion = InlineCompletion> {
    readonly items: readonly TItem[];
}
interface InlineCompletionsProvider<T extends InlineCompletions = InlineCompletions> {
    provideInlineCompletions(model: editor.ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult<T>;
}

@0.34 及以上版本返回格式:

interface InlineCompletion {
        /**
         * The text to insert.
         * If the text contains a line break, the range must end at the end of a line.
         * If existing text should be replaced, the existing text must be a prefix of the text to insert.
         *
         * The text can also be a snippet. In that case, a preview with default parameters is shown.
         * When accepting the suggestion, the full snippet is inserted.
        */
        readonly insertText: string | {
                snippet: string;
        };
        /**
         * A text that is used to decide if this inline completion should be shown.
         * An inline completion is shown if the text to replace is a subword of the filter text.
         */
        readonly filterText?: string;
        /**
         * An optional array of additional text edits that are applied when
         * selecting this completion. Edits must not overlap with the main edit
         * nor with themselves.
         */
        readonly additionalTextEdits?: editor.ISingleEditOperation[];
        /**
         * The range to replace.
         * Must begin and end on the same line.
        */
        readonly range?: IRange;
        readonly command?: Command;
        /**
         * If set to `true`, unopened closing brackets are removed and unclosed opening brackets are closed.
         * Defaults to `false`.
        */
        readonly completeBracketPairs?: boolean;
}
  1. 补全结束,恢复光标状态

过程

  • 设置光标
    在发起补全时,需要将光标变为 loading 状态,但是 monaco editor 自身的配置不满足想要的样式(只支持:'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin')。
    monaco editor 的光标并不是原生输入框自带的,也是自行实现的
    file

    通过操作 dom 的形式,使用 createPortal 方法,将 loading 组件渲染到该容器下,然后通过状态控制光标的状态切换。具体实现如下所示:
class Editor extends React.Component {
  ...

  switchToLoadingCursor = () => {
    const defaultCursor = document.querySelector('.cursors-layer .cursor') as HTMLDivElement;
    const defaultCursorRect = defaultCursor.getBoundingClientRect();
    const cursorLoadingRect = document
      .querySelector('.cursors-layer .cursorLoading')
      .getBoundingClientRect();

    defaultCursor.style.display = 'none';
    this.setState({
      cursorLoading: {
        left: defaultCursorRect.left - cursorLoadingRect.left + 2,
        top: defaultCursorRect.top - cursorLoadingRect.top + 2,
        visible: 'visible',
      },
    });
  };

  switchToDefaultCursor = () => {
    clearTimeout(this.copilotTimer);
    if (this.abortController && !this.abortController.signal.aborted) {
      this.abortController.abort();
    }
    
    const defaultCursor = document.querySelector('.cursors-layer .cursor') as HTMLDivElement;
    
    defaultCursor.style.display = 'block';
    this.setState({
      cursorLoading: {
        left: 0,
        top: 0,
        visible: 'hidden',
      },
    });
  };

  render() {
    const cursorLayer = document.querySelector('.monaco-editor .cursors-layer');

    return <>
      ...
      {cursorLayer &&
        ReactDOM.createPortal(
          <Spin
            className="cursorLoading"
            style={{
              position: 'absolute',
              top: cursorLoading.top,
              left: cursorLoading.left,
              visibility:
                cursorLoading.visible as React.CSSProperties['visibility'],
              zIndex: 999,
            }}
            indicator={<LoadingOutlined spin />}
            size="small"
            />,
          cursorLayer
        )}
      ...
    </>
  }

}

效果如下所示:

file
  • 获取上下文内容,发送 AI 补全,并将内容返回
    这一步这里做的比较简单,只是将内容获取,发送给 AI ,然后等待结果的返回,结果返回后,将补全内容返回,并切换光标状态。同时,在鼠标点到其他位置时,会取消补全。
    不过,这里没有做规则校验,去校验什么情况下才发起补全行为。
    注意registerInlineCompletionsProvider 是只要内容变化就会触发,所以可能需要做一些优化(如防抖等),避免一直发送/取消请求。
this.keyDownDisposable = this.editorInstance.onKeyDown(this.switchToDefaultCursor);
this.mouseDownDisposable = this.editorInstance.onMouseDown(this.switchToDefaultCursor);
this.inlineCompletionDispose = languages.registerInlineCompletionsProvider(language, {
  provideInlineCompletions: (model, position, context, token) => {
    return new Promise((resolve) => {
      clearTimeout(this.copilotTimer);
      if (this.abortController && !this.abortController.signal.aborted) {
        this.abortController.abort();
      }

      this.copilotTimer = window.setTimeout(() => {
        const codeBeforeCursor = model.getValueInRange({
          startLineNumber: 1,
          startColumn: 1,
          endLineNumber: position.lineNumber,
          endColumn: position.column,
        });
        const codeAfterCursor = model.getValueInRange({
          startLineNumber: position.lineNumber,
          startColumn: position.column,
          endLineNumber: model.getLineCount(),
          endColumn: model.getLineMaxColumn(model.getLineCount()),
        });
        let result = '';
        this.switchToLoadingCursor();

        this.abortController = new AbortController();
        api.chatOneAIGC(
          {
            message: `你是一个${language}补全器,以下是我的上下文:\n上文内容如下:\n${codeBeforeCursor}\n,下文内容如下:\n${codeAfterCursor}\n请你帮我进行补全,只需要返回对应的代码,不需要进行解释。`,
          },
        ).then(({data, code}) => {
          if (code === 1) {
            resolve({
              items: data?.map((content) => ({
                text: content,
                range: {
                  startLineNumber: position.lineNumber,
                  startColumn: position.column,
                  endLineNumber: position.lineNumber,
                  endColumn: content.length,
                },
              }),
                               });
          } else {
            resolve({ items: [] });
          }
          this.switchToDefaultCursor();
        })
      }, 500);
    });
  },
  freeInlineCompletions(completions) {
    console.log('wenchang freeInlineCompletions', completions);
  },
  handleItemDidShow(completions) {
    console.log('wenchang handleItemDidShow', completions);
  },
} as languages.InlineCompletionsProvider);

效果

效果图

总结

上述例子只是介绍了如何在 Monaco Editor 中实现类似 Copilot 的代码智能补全功能,但是,我们可以发现,只要内容发生变动,都会触发 Provider ,实际上有些场景下,是不应该触发的,这里还需要写相应的判断条件,而非像例子中所示,任何情况下都进行补全。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,063评论 6 510
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,805评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,403评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,110评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,130评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,877评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,533评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,429评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,947评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,078评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,204评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,894评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,546评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,086评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,195评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,519评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,198评论 2 357

推荐阅读更多精彩内容