實現一個 AI 編輯器 - 行內代碼生成篇
我們是袋鼠云數棧 UED 團隊,致力于打造優秀的一站式數據中臺產品。我們始終保持工匠精神,探索前端道路,為社區積累并傳播經驗價值。
本文作者:佳嵐
什么是行內代碼生成?
通過一組快捷鍵(一般為cmd + k)在選中代碼塊或者光標處喚起 Prompt 命令彈窗,并且快速的應用生成的代碼。

提示詞系統
首先是完成一個簡易的提示詞系統,不同功能對應的提示詞與提供的上下文不同, 定義不同的功能場景:
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實現的。

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。

但我們咱不用 domNode 直接渲染我們的彈窗組件,而是通過 ViewZone 結合 OverlayWidget 的方式去添加我們要的元素。
OverlayWidget 的層級比可編輯區域的更高,無需考慮層級覆蓋問題。
其次,我們需要將 Overlay 的元素通過絕對定位移動到 ViewZone 上,這需要利用 ViewZone 的 onDomNodeTop來實時同步兩者的定位。

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 輸入框根據內容動態調整高度。

輸入框部分我們可以直接用 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 進行高亮標記。

首先需要實現 diff 計算出這些行的信息。 我們需要以最少的操作實現從原始代碼到目標代碼的轉化。

其核心問題是 最長公共子序列(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;
};

那么接下來我們只要根據計算出的 diffLines 對刪除行和新增行進行視覺展示即可。
我們封裝一個 applyDiffDisplay 方法用來展示 diffLines 。
有以下步驟:
- 清除之前的結果
- 直接將選區內容替換為生成內容
- 遍歷
diffLines中ADDED與DELETED的行:對于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;
}
);
}
最終實現效果:

未來優化方向:
- 實現流式生成:對于未選區的代碼生成,我們不需要應用diff,所以流式很好實現,但對于進行選區后進行的代碼修改,每次輸出一行就要執行一次diff計算與展示,diff結果可能不同,會產生視覺上的重繪,實現起來也相對比較麻煩。
![file]()
- 接收或者拒絕后能夠進行撤回,回到等待響應生成結果時的狀態
其他計劃
- [已完成] 行內補全
- [已完成] 代碼生成
- 行內補全的緩存設計
- 完善的上下文系統
- 實現 Agent 模式
在線預覽
https://jackwang032.github.io/monaco-sql-languages/
最后
歡迎關注【袋鼠云數棧UED團隊】~
袋鼠云數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star


浙公網安備 33010602011771號