实现一个 AI 编辑器 - 行内代码生成篇

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

本文作者:佳岚

什么是行内代码生成?

通过一组快捷键(一般为cmd + k)在选中代码块或者光标处唤起 Prompt 命令弹窗,并且快速的应用生成的代码。

file

提示词系统

首先是完成一个简易的提示词系统,不同功能对应的提示词与提供的上下文不同, 定义不同的功能场景:

export enum PromptScenario {
    SYNTAX_COMPLETION = 'syntax_completion',    // 语法补全
    CODE_GENERATION = 'code_generation',        // 代码生成
    CODE_EXPLANATION = 'code_explanation',      // 代码解释
    CODE_OPTIMIZATION = 'code_optimization',    // 代码优化
    ERROR_FIXING = 'error_fixing',              // 错误修复
}

每种场景都有对应的系统 prompt 和用户 prompt 模板:

export const PROMPT_TEMPLATES: Record<PromptScenario, PromptTemplate> = {
    [PromptScenario.SYNTAX_COMPLETION]: {
        id: 'syntax_completion',
        scenario: PromptScenario.SYNTAX_COMPLETION,
        title: 'SQL语法补全',
        description: '基于上下文进行智能的SQL语法补全',
        systemPromptTemplate: ``,
        userPromptTemplate: `<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>`,
        temperature: 0.2,
        maxTokens: 256
    },

    [PromptScenario.CODE_GENERATION]: {
        id: 'code_generation',
        scenario: PromptScenario.CODE_GENERATION,
        title: 'SQL代码生成',
        description: '根据需求描述生成相应的SQL代码',
        systemPromptTemplate: `你是{languageName}数据库专家。根据用户需求生成高质量的{languageName}代码。

语言特性:{languageFeatures}

生成要求:
1. 严格遵循 {languageName} 语法规范
2. {syntaxNotes}
3. 生成完整、可执行的SQL语句
4. {performanceTips}
5. 考虑代码的可读性和维护性
6. 回答不要包含任何对话解释内容
7. 保持缩进与参考代码一致`,
        userPromptTemplate: `用户需求:{userPrompt}

参考代码:
\`\`\`sql
{selectedCode}
\`\`\`

请生成符合需求的{languageName}代码:`,
        temperature: 0.3,
        maxTokens: 512
    },
  // ...其他略
}

收集以下上下文信息并动态替换掉提示词模板的变量以生成最终传递给大模型的提示词:

/**
 * 上下文信息
 */
export interface PromptContext {
    /** 当前语言ID */
    languageId: string;
    /** 光标前的代码 */
    prefix?: string;
    /** 光标后的代码 */
    suffix?: string;
    /** 当前文件完整代码 */
    fullCode?: string;
    /** 当前打开的文件名 */
    activeFile?: string;
    /** 用户输入的提示 */
    userPrompt?: string;
    /** 选中的代码 */
    selectedCode?: string;
    /** 错误信息 */
    errorMessage?: string;
    /** 额外的上下文信息 */
    metadata?: Record<string, any>;
}

ViewZone

观察该 Widget 可以发现它是实际占据了一段代码行高度,撑开了上下代码,但没有行号,这是通过 ViewZone实现的。

file

monaco-editor 中的 viewZone 是一种可以在编辑器的文本行之间自定义插入可视区域的机制,不属于实际代码内容,但可以渲染任意自定义 DOM 内容或空白空间。

核心只有一个changeViewZones,必须使用其回调中的accessor来实现新增删除ViewZone操作

新增示例:

editor.changeViewZones(function (accessor) {
  accessor.addZone({
    afterLineNumber: 10,         // 插入在哪一行后(基于原始代码行号)
    heightInLines: 3,            // zone 的高度(按行数)
    heightInPx: 10,              // zone 的高度(按像素), 与heightInLines二选一
    domNode: document.createElement('div'), // 需要插入的 DOM 节点
  });
});

删除示例:

editor.changeViewZones(accessor => {
  if (zoneIdRef.current !== null) {
    accessor.removeZone(zoneIdRef.current);
  }
});

但需要注意的是,ViewZones 的视图层级是在可编辑区之下的,我们通过 domNode 创建弹窗后,无法响应点击,所以需要手动为 domNode 添加 z-Index。

file

但我们咱不用 domNode 直接渲染我们的弹窗组件,而是通过 ViewZone 结合 OverlayWidget 的方式去添加我们要的元素。

OverlayWidget 的层级比可编辑区域的更高,无需考虑层级覆盖问题。

其次,我们需要将 Overlay 的元素通过绝对定位移动到 ViewZone 上,这需要利用 ViewZone 的 onDomNodeTop来实时同步两者的定位。

file

monaco-editor 中的代码行与 ViewZone 使用了虚拟列表,它们的 top 在滚动时会随着可见性不断变化,所以需要随时同步 ,onDomNodeTop会在每次 ViewZone 的top属性变化时执行。

此外,OverlayWidget 是以整个编辑器最左边为基准的,计算时需要考虑上

editorInstance.changeViewZones((changeAccessor) => {
        viewZoneId = changeAccessor.addZone({
            // ...略
            onDomNodeTop: (top) => {
        // 这里的domNode为overlayWidget所绑定创建的节点
                if (domNode) {
                    // 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
                    const layoutInfo = editorInstance.getLayoutInfo();
                    const leftOffset = layoutInfo.contentLeft;

                    domNode.style.top = `${top}px`;
                    domNode.style.left = `${leftOffset}px`;
                    domNode.style.width = `${layoutInfo.contentWidth}px`;
                }
            }
        });
    });

创建 OverlayWidget :

let overlayWidget: editor.IOverlayWidget | null = null;
let domNode: HTMLDivElement | null = null;
let reactRoot: any = null;

domNode = document.createElement('div');
domNode.className = 'code-generation-overlay-widget';
domNode.style.position = 'absolute';

reactRoot = createRoot(domNode);
reactRoot.render(<CodeGenerationWidget />)

overlayWidget = {
  getId: () => `code-generation-overlay-${position.lineNumber}-${Date.now()}`,
  getDomNode: () => domNode!,
  getPosition: () => null
};

editorInstance.addOverlayWidget(overlayWidget);

// 唤起时,将 widget 滚动到视口
editorInstance.revealLineInCenter(targetLineNumber);

CodeGenerationWidget 动态高度

接下来我们实现 Prompt 输入框根据内容动态调整高度。

file

输入框部分我们可以直接用 rc-textarea 组件来实现回车自动新增高度。

监听整个容器高度变化触发 onHeightChange 以通知 ViewZone

    useEffect(() => {
        if (!containerRef.current) return;
        const observer = new ResizeObserver(() => {
            onHeightChange?.();
        });
        observer.observe(containerRef.current);

        return () => {
            observer.disconnect();
        };
    }, [containerRef]);

注意 ViewZone 只能增或删,不能手动改变其高度,所以需要重新创建一个:

reactRoot.render(
        <CodeGenerationWidget
            editorInstance={editorInstance}
            initialPosition={position}
            initialSelection={selection}
            widgetWidth={widgetWidth}
            onClose={() => dispose()}
            onHeightChange={() => {
                // 高度变化时需要更新ViewZone
                if (viewZoneId && domNode) {
                    const actualHeight = domNode.clientHeight;
                    editorInstance.changeViewZones((changeAccessor) => {
                        changeAccessor.removeZone(viewZoneId!);
                        viewZoneId = changeAccessor.addZone({
                            afterLineNumber: Math.max(0, targetLineNumber - 1),
                            heightInPx: actualHeight + 8,
                            domNode: document.createElement('div'),
                            onDomNodeTop: (top) => {
                                if (domNode) {
                                    // 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
                                    const layoutInfo = editorInstance.getLayoutInfo();
                                    const leftOffset = layoutInfo.contentLeft;

                                    domNode.style.top = `${top}px`;
                                    domNode.style.left = `${leftOffset}px`;
                                }
                            }
                        });
                    });
                }
            }}
        />
    );

这里如果使用 ViewZone 的 domNode 来渲染组件的方法的话,由于每次高度变化创建新的 ViewZone , 其 domNode 会被重新挂载,那么就会导致每次高度变化时输入框都会失焦。

生成代码 diff 展示

对于选择了代码行后生成,会对原始代码进行编辑修改,我们需要配合行 diff 进行编辑应用结果的展示。对于删除的行使用 ViewZone 进行插入,对于新增的行使用 Decoration 进行高亮标记。

file

首先需要实现 diff 计算出这些行的信息。 我们需要以最少的操作实现从原始代码到目标代码的转化。

file

<font style="color:rgb(51, 51, 51);">其核心问题是 </font>最长公共子序列(LCS)。<font style="color:rgb(51, 51, 51);">最长公共子序列(LCS )是指在两个或多个序列中,找出一个最长的子序列,使得这个子序列在这些序列中都出现过。与子串不同,子序列不需要在原序列中占用连续的位置。</font>

如 ABCDEF 至 ACEFG , 那么它们的最长公共子序列是 ACEF 。

其算法可以参考 https://cloud.tencent.com/developer/article/2367282 学习,这里我们直接就使用现成的库jsdiff 去实现了。

完整实现:

export enum DiffLineType {
    UNCHANGED = 'unchanged',
    ADDED = 'added',
    DELETED = 'deleted'
}

export interface DiffLine {
    type: DiffLineType;
    originalLineNumber?: number; // 原始行号
    newLineNumber?: number; // 新行号
    content: string; // 行内容
}

/**
 * 计算两个字符串数组的diff
 */
export const calculateDiff = (originalLines: string[], newLines: string[]): DiffLine[] => {
    const result: DiffLine[] = [];

    // 将字符串数组转换为字符串
    const originalText = originalLines.join('\n');
    const newText = newLines.join('\n');

    // 使用 diff 库计算差异
    const diffs = diffLines(originalText, newText);

    let originalLineNumber = 1;
    let newLineNumber = 1;

    diffs.forEach(diff => {
        if (diff.added) {
            // 添加的行
            const lines = diff.value.split('\n').filter((line, index, arr) =>
                // 过滤掉最后一个空行(如果存在)
                !(index === arr.length - 1 && line === '')
            );

            lines.forEach(line => {
                result.push({
                    type: DiffLineType.ADDED,
                    newLineNumber: newLineNumber++,
                    content: line
                });
            });
        } else if (diff.removed) {
            // 删除的行
            const lines = diff.value.split('\n').filter((line, index, arr) =>
                // 过滤掉最后一个空行(如果存在)
                !(index === arr.length - 1 && line === '')
            );

            lines.forEach(line => {
                result.push({
                    type: DiffLineType.DELETED,
                    originalLineNumber: originalLineNumber++,
                    content: line
                });
            });
        } else {
            // 未变化的行
            const lines = diff.value.split('\n').filter((line, index, arr) =>
                // 过滤掉最后一个空行(如果存在)
                !(index === arr.length - 1 && line === '')
            );

            lines.forEach(line => {
                result.push({
                    type: DiffLineType.UNCHANGED,
                    originalLineNumber: originalLineNumber++,
                    newLineNumber: newLineNumber++,
                    content: line
                });
            });
        }
    });

    return result;
};
file

那么接下来我们只要根据计算出的 diffLines 对删除行和新增行进行视觉展示即可。

我们封装一个 applyDiffDisplay 方法用来展示 diffLines

有以下步骤:

  1. 清除之前的结果
  2. 直接将选区内容替换为生成内容
  3. 遍历 diffLinesADDEDDELETED 的行:对于 DELETED 的行,可以多个连续行组成一个 ViewZone 创建以优化性能;对于ADDED的行,通过 deltaDecorations 添加背景装饰
const applyDiffDisplay =
  (diffLines: DiffLine[]) => {
    // 先清除之前的展示
    clearDecorations();
    clearDiffOverlays();

    if (!initialSelection) return;

    const model = editorInstance.getModel();
    if (!model) return;

    // 获取语言ID用于语法高亮
    const languageId = getLanguageId();

    // 首先替换原始内容为新内容(包含unchanged的行)
    const newLines = diffLines
      .filter((line) => line.type !== DiffLineType.DELETED)
      .map((line) => line.content);
    const newContent = newLines.join('\n');

    // 执行替换
    editorInstance.executeEdits('ai-code-generation-diff', [
      {
        range: initialSelection,
        text: newContent,
        forceMoveMarkers: true
      }
    ]);

    // 计算新内容的范围
    const resultRange = new Range(
      initialSelection.startLineNumber,
      initialSelection.startColumn,
      initialSelection.startLineNumber + newLines.length - 1,
      newLines.length === 1
      ? initialSelection.startColumn + newContent.length
      : newLines[newLines.length - 1].length + 1
    );

    let currentLineNumber = initialSelection.startLineNumber;
    let deletedLinesGroup: DiffLine[] = [];

    for (const diffLine of diffLines) {
      if (diffLine.type === DiffLineType.DELETED) {
        // 收集连续的删除行
        deletedLinesGroup.push(diffLine);
      } else {
        if (deletedLinesGroup.length > 0) {
          addDeletedLinesViewZone(deletedLinesGroup, currentLineNumber - 1, languageId);
          deletedLinesGroup = [];
        }

        if (diffLine.type === DiffLineType.ADDED) {
          // 添加绿色背景色
          const addedDecorations = editorInstance.deltaDecorations(
            [],
            [
              {
                range: new Range(
                  currentLineNumber,
                  1,
                  currentLineNumber,
                  model.getLineContent(currentLineNumber).length + 1
                ),
                options: {
                  className: 'added-line-decoration',
                  isWholeLine: true
                }
              }
            ]
          );
          decorationsRef.current.push(...addedDecorations);
        }

        currentLineNumber++;
      }
    }

    // 处理最后的删除行组
    if (deletedLinesGroup.length > 0) {
      addDeletedLinesViewZone(deletedLinesGroup, currentLineNumber - 1, languageId);
    }

    return resultRange;
  }


删除行的视觉呈现

删除行使用 ViewZone 插入到 originalLineNumber - 1 的位置, 对于删除行直接使用 ViewZone 自身的 domNode 进行展示了,因为不太需要考虑层级问题。

export const createDeletedLinesOverlayWidget = (
    editorInstance: editor.IStandaloneCodeEditor,
    deletedLines: DiffLine[],
    afterLineNumber: number,
    languageId: string,
    onDispose?: () => void
): { dispose: () => void } => {
    let domNode: HTMLDivElement | null = null;
    let reactRoot: any = null;
    let viewZoneId: string | null = null;

    domNode = document.createElement('div');
    domNode.className = 'deleted-lines-view-zone-container';

    reactRoot = createRoot(domNode);

    reactRoot.render(<DeletedLineViewZone lines={deletedLines} languageId={languageId} />);

    const heightInLines = Math.max(1, deletedLines.length);
    editorInstance.changeViewZones((changeAccessor) => {
        viewZoneId = changeAccessor.addZone({
            afterLineNumber,
            heightInLines,
            domNode: domNode!
        });
    });

    const dispose = () => {
        // 清除
    };

    return { dispose };
};

添加命令快捷键

使用 cmd + k 唤起弹窗

editorInstance.onKeyDown((e) => {
  if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyK) {
    e.preventDefault();
    e.stopPropagation();

    const selection = editorInstance.getSelection();
    const position = selection ? selection.getPosition() : editorInstance.getPosition();

    if (!position) return;

    // 如果有选择范围,则将其传递给widget供后续替换使用
    const selectionRange = selection && !selection.isEmpty() ? selection : null;

    // 如果已经有viewZone,先清理
    if (activeCodeGenerationViewZone) {
      activeCodeGenerationViewZone.dispose();
      activeCodeGenerationViewZone = null;
    }

    // 创建新的ViewZone
    activeCodeGenerationViewZone = createCodeGenerationOverlayWidget(
      editorInstance,
      position,
      selectionRange,
      undefined, // widgetWidth
      () => {
        // 当viewZone被dispose时清理全局状态
        activeCodeGenerationViewZone = null;
      }
    );
  }

最终实现效果:

file

未来优化方向:

  1. 实现流式生成:对于未选区的代码生成,我们不需要应用diff,所以流式很好实现,但对于进行选区后进行的代码修改,每次输出一行就要执行一次diff计算与展示,diff结果可能不同,会产生视觉上的重绘,实现起来也相对比较麻烦。
    file
  2. 接收或者拒绝后能够进行撤回,回到等待响应生成结果时的状态

其他计划

  • [已完成] 行内补全
  • [已完成] 代码生成
  • 行内补全的缓存设计
  • 完善的上下文系统
  • 实现 Agent 模式

在线预览

https://jackwang032.github.io/monaco-sql-languages/

仓库代码:https://github1s.com/JackWang032/monaco-sql-languages/blob/feat/demos/website/src/extensions/workbench/codeGenerationWidget.tsx

最后

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

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容