Stories

Detail Return Return

實現一個 AI 編輯器 - 行內代碼生成篇 - Stories Detail

我們是袋鼠雲數棧 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

其核心問題是 最長公共子序列(LCS)。最長公共子序列(LCS )是指在兩個或多個序列中,找出一個最長的子序列,使得這個子序列在這些序列中都出現過。與子串不同,子序列不需要在原序列中佔用連續的位置。

如 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

  • 大數據分佈式任務調度系統——Taier
  • 輕量級的 Web IDE UI 框架——Molecule
  • 針對大數據領域的 SQL Parser 項目——dt-sql-parser
  • 袋鼠雲數棧前端團隊代碼評審工程實踐文檔——code-review-practices
  • 一個速度更快、配置更靈活、使用更簡單的模塊打包器——ko
  • 一個針對 antd 的組件測試工具庫——ant-design-testing
user avatar Leesz Avatar alibabawenyujishu Avatar haoqidewukong Avatar zaotalk Avatar nihaojob Avatar jingdongkeji Avatar kobe_fans_zxc Avatar aqiongbei Avatar huichangkudelingdai Avatar faurewu Avatar zero_dev Avatar febobo Avatar
Favorites 62 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.