找回密码
 立即注册
首页 业界区 业界 Monaco Editor 实现在线版 Copilot

Monaco Editor 实现在线版 Copilot

事确 前天 19:17
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:文长
引言

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

什么是 Copilot?

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

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

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

简版实现

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]:被展示的具体补全项。



如下图例子所示
2.png

具体实现

思路

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

  • 修改光标状态
  • 获取上下文内容,发送给 AI
  • 等待 AI 返回补全结果,将 AI 的结果进行返回。这里返回的格式(这里以 Monaco Editor@0.31.1 为例,@0.34 版本开始与此有些差别):
  1. interface InlineCompletion {
  2.     /**
  3.      * The text to insert.
  4.      * If the text contains a line break, the range must end at the end of a line.
  5.      * If existing text should be replaced, the existing text must be a prefix of the text to insert.
  6.     */
  7.     readonly text: string;
  8.     /**
  9.      * The range to replace.
  10.      * Must begin and end on the same line.
  11.     */
  12.     readonly range?: IRange;
  13.     readonly command?: Command;
  14. }
  15. interface InlineCompletions<TItem extends InlineCompletion = InlineCompletion> {
  16.     readonly items: readonly TItem[];
  17. }
  18. interface InlineCompletionsProvider<T extends InlineCompletions = InlineCompletions> {
  19.     provideInlineCompletions(model: editor.ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult<T>;
  20. }
复制代码
@0.34 及以上版本返回格式:
  1. interface InlineCompletion {
  2.                 /**
  3.                  * The text to insert.
  4.                  * If the text contains a line break, the range must end at the end of a line.
  5.                  * If existing text should be replaced, the existing text must be a prefix of the text to insert.
  6.                  *
  7.                  * The text can also be a snippet. In that case, a preview with default parameters is shown.
  8.                  * When accepting the suggestion, the full snippet is inserted.
  9.                 */
  10.                 readonly insertText: string | {
  11.                                 snippet: string;
  12.                 };
  13.                 /**
  14.                  * A text that is used to decide if this inline completion should be shown.
  15.                  * An inline completion is shown if the text to replace is a subword of the filter text.
  16.                  */
  17.                 readonly filterText?: string;
  18.                 /**
  19.                  * An optional array of additional text edits that are applied when
  20.                  * selecting this completion. Edits must not overlap with the main edit
  21.                  * nor with themselves.
  22.                  */
  23.                 readonly additionalTextEdits?: editor.ISingleEditOperation[];
  24.                 /**
  25.                  * The range to replace.
  26.                  * Must begin and end on the same line.
  27.                 */
  28.                 readonly range?: IRange;
  29.                 readonly command?: Command;
  30.                 /**
  31.                  * If set to `true`, unopened closing brackets are removed and unclosed opening brackets are closed.
  32.                  * Defaults to `false`.
  33.                 */
  34.                 readonly completeBracketPairs?: boolean;
  35. }
复制代码

  • 补全结束,恢复光标状态
过程


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

    通过操作 dom 的形式,使用 createPortal 方法,将 loading 组件渲染到该容器下,然后通过状态控制光标的状态切换。具体实现如下所示:
  1. class Editor extends React.Component {
  2.   ...
  3.   switchToLoadingCursor = () => {
  4.     const defaultCursor = document.querySelector('.cursors-layer .cursor') as HTMLDivElement;
  5.     const defaultCursorRect = defaultCursor.getBoundingClientRect();
  6.     const cursorLoadingRect = document
  7.       .querySelector('.cursors-layer .cursorLoading')
  8.       .getBoundingClientRect();
  9.     defaultCursor.style.display = 'none';
  10.     this.setState({
  11.       cursorLoading: {
  12.         left: defaultCursorRect.left - cursorLoadingRect.left + 2,
  13.         top: defaultCursorRect.top - cursorLoadingRect.top + 2,
  14.         visible: 'visible',
  15.       },
  16.     });
  17.   };
  18.   switchToDefaultCursor = () => {
  19.     clearTimeout(this.copilotTimer);
  20.     if (this.abortController && !this.abortController.signal.aborted) {
  21.       this.abortController.abort();
  22.     }
  23.    
  24.     const defaultCursor = document.querySelector('.cursors-layer .cursor') as HTMLDivElement;
  25.    
  26.     defaultCursor.style.display = 'block';
  27.     this.setState({
  28.       cursorLoading: {
  29.         left: 0,
  30.         top: 0,
  31.         visible: 'hidden',
  32.       },
  33.     });
  34.   };
  35.   render() {
  36.     const cursorLayer = document.querySelector('.monaco-editor .cursors-layer');
  37.     return <>
  38.       ...
  39.       {cursorLayer &&
  40.         ReactDOM.createPortal(
  41.           <Spin
  42.             className="cursorLoading"
  43.             style={{
  44.               position: 'absolute',
  45.               top: cursorLoading.top,
  46.               left: cursorLoading.left,
  47.               visibility:
  48.                 cursorLoading.visible as React.CSSProperties['visibility'],
  49.               zIndex: 999,
  50.             }}
  51.             indicator={<LoadingOutlined spin />}
  52.             size="small"
  53.             />,
  54.           cursorLayer
  55.         )}
  56.       ...
  57.     </>
  58.   }
  59. }
复制代码
效果如下所示:
4.gif


  • 获取上下文内容,发送 AI 补全,并将内容返回
    这一步这里做的比较简单,只是将内容获取,发送给 AI ,然后等待结果的返回,结果返回后,将补全内容返回,并切换光标状态。同时,在鼠标点到其他位置时,会取消补全。
    不过,这里没有做规则校验,去校验什么情况下才发起补全行为。
    注意: registerInlineCompletionsProvider 是只要内容变化就会触发,所以可能需要做一些优化(如防抖等),避免一直发送/取消请求。
  1. this.keyDownDisposable = this.editorInstance.onKeyDown(this.switchToDefaultCursor);
  2. this.mouseDownDisposable = this.editorInstance.onMouseDown(this.switchToDefaultCursor);
  3. this.inlineCompletionDispose = languages.registerInlineCompletionsProvider(language, {
  4.   provideInlineCompletions: (model, position, context, token) => {
  5.     return new Promise((resolve) => {
  6.       clearTimeout(this.copilotTimer);
  7.       if (this.abortController && !this.abortController.signal.aborted) {
  8.         this.abortController.abort();
  9.       }
  10.       this.copilotTimer = window.setTimeout(() => {
  11.         const codeBeforeCursor = model.getValueInRange({
  12.           startLineNumber: 1,
  13.           startColumn: 1,
  14.           endLineNumber: position.lineNumber,
  15.           endColumn: position.column,
  16.         });
  17.         const codeAfterCursor = model.getValueInRange({
  18.           startLineNumber: position.lineNumber,
  19.           startColumn: position.column,
  20.           endLineNumber: model.getLineCount(),
  21.           endColumn: model.getLineMaxColumn(model.getLineCount()),
  22.         });
  23.         let result = '';
  24.         this.switchToLoadingCursor();
  25.         this.abortController = new AbortController();
  26.         api.chatOneAIGC(
  27.           {
  28.             message: `你是一个${language}补全器,以下是我的上下文:\n上文内容如下:\n${codeBeforeCursor}\n,下文内容如下:\n${codeAfterCursor}\n请你帮我进行补全,只需要返回对应的代码,不需要进行解释。`,
  29.           },
  30.         ).then(({data, code}) => {
  31.           if (code === 1) {
  32.             resolve({
  33.               items: data?.map((content) => ({
  34.                 text: content,
  35.                 range: {
  36.                   startLineNumber: position.lineNumber,
  37.                   startColumn: position.column,
  38.                   endLineNumber: position.lineNumber,
  39.                   endColumn: content.length,
  40.                 },
  41.               }),
  42.                                });
  43.           } else {
  44.             resolve({ items: [] });
  45.           }
  46.           this.switchToDefaultCursor();
  47.         })
  48.       }, 500);
  49.     });
  50.   },
  51.   freeInlineCompletions(completions) {
  52.     console.log('wenchang freeInlineCompletions', completions);
  53.   },
  54.   handleItemDidShow(completions) {
  55.     console.log('wenchang handleItemDidShow', completions);
  56.   },
  57. } as languages.InlineCompletionsProvider);
复制代码
效果

5.gif

总结

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

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

  • 大数据分布式任务调度系统——Taier
  • 轻量级的 Web IDE UI 框架——Molecule
  • 针对大数据领域的 SQL Parser 项目——dt-sql-parser
  • 袋鼠云数栈前端团队代码评审工程实践文档——code-review-practices
  • 一个速度更快、配置更灵活、使用更简单的模块打包器——ko
  • 一个针对 antd 的组件测试工具库——ant-design-testing

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册