实现一个 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结果可能不同,会产生视觉上的重绘,实现起来也相对比较麻烦。
![]()
- 接收或者拒绝后能够进行撤回,回到等待响应生成结果时的状态
其他计划
- [已完成] 行内补全
- [已完成] 代码生成
- 行内补全的缓存设计
- 完善的上下文系统
- 实现 Agent 模式
在线预览
https://jackwang032.github.io/monaco-sql-languages/
最后
欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star
- 大数据分布式任务调度系统——Taier
- 轻量级的 Web IDE UI 框架——Molecule
- 针对大数据领域的 SQL Parser 项目——dt-sql-parser
- 袋鼠云数栈前端团队代码评审工程实践文档——code-review-practices
- 一个速度更快、配置更灵活、使用更简单的模块打包器——ko
- 一个针对 antd 的组件测试工具库——ant-design-testing
实现一个 AI 编辑器 - 行内代码生成篇的更多相关文章
- IT行业有前景么?一个10年行内人的6点看法
本人毕业快11年了. 大学读的建筑专业,却在IT行业干了10年. 真心来讲,我非常感谢好兄弟老唐,是他在我迷茫的那两年,领着我踏入了IT行业,也找到了自己的兴趣爱好. 这些年我经常在知乎.博客等地方发 ...
- HTML行内元素与块级元素有哪些及区别详解
转自 https://www.jb51.net/web/724286.html 这篇文章主要介绍了HTML行内元素与块级元素有哪些及区别详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具 ...
- [Web 前端] inline-block元素设置overflow:hidden属性导致相邻行内元素向下偏移
cp from : https://blog.csdn.net/iefreer/article/details/50421025 在表单修改界面中常会使用一个标签.一个内容加一个修改按钮来组成单行界面 ...
- 行内元素与块级元素的区别,行内块级元素在IE8-的兼容性
行内元素与块级元素的区别 行内元素最好不要包裹块级元素,但是块级元素可以任意的包裹行内元素 行内元素如果其上一个元素也是行内元素,则他们会分布在统一水平线上,即在一行上排列,块级元素不论上一个元素是行 ...
- 深入理解脚本化CSS系列第一篇——脚本化行内样式
× 目录 [1]用法 [2]属性 [3]方法 前面的话 脚本化CSS,通俗点说,就是使用javascript来操作CSS.引入CSS有3种方式:外部样式,内部样式和行间样式.本文将主要介绍脚本化行间样 ...
- pyqt 8行内就可以跑一个浏览器
pyqt 8行内就可以跑一个浏览器 from PyQt4.QtCore import * from PyQt4.QtGui import * from PyQt4.QtWebKit import * ...
- ASP.NET Aries 高级开发教程:行内编辑事件怎么新增数据到后台(番外篇)
前提: 今天又网友又提出了一个问题,说行内编辑保存之前,怎么新增一些数据提交到后台? 对方说看了源码,也没找到怎么处理,这里就写文给解答一下. 解答: 于是我看了一眼源码,只能说你没找到地方: 第12 ...
- 文本编辑器vim——三种模式、显示行号、插入命令、行快速定位、行内定位
1.vim的三种工作模式: (1)利用vim命令新建文件: 点击entre键执行命令后,开始向文本中输入想要写入的内容: (2)命令行模式(ESC): 不管用户处于何种模式,只要单击Esc键,即可进入 ...
- 从软件开发到 AI 领域工程师:模型训练篇
前言 4 月热播的韩剧<王国>,不知道大家有没有看?我一集不落地看完了.王子元子出生时,正逢宫内僵尸作乱,元子也被咬了一口,但是由于大脑神经元尚未形成,寄生虫无法控制神经元,所以医女在做了 ...
- ASP.NET Aries 入门开发教程6:列表数据表格的格式化处理及行内编辑
前言: 为了赶进度,周末也写文了! 前几篇讲完查询框和工具栏,这节讲表格数据相关的操作. 先看一下列表: 接下来我们有很多事情可以做. 1:格式化 - 键值的翻译 对于“启用”列,已经配置了格式化 # ...
随机推荐
- bigdecimal去除末尾多余的0 ,stripTrailingZeros()科学计数法解决
BigDecimal是处理高精度的浮点数运算的常用的一个类 当需要将BigDecimal中保存的浮点数值打印出来,特别是在页面上显示的时候,就有可能遇到预想之外的科学技术法表示的问题. 一般直接使用 ...
- Elastic学习之旅 (6) Query DSL
大家好,我是Edison.首先说声抱歉,这个ES学习系列很久没更新了,现在继续吧. 上一篇:ES的倒排索引和Analyzer 什么是Query DSL DSL是Domain Specific Lang ...
- 多线程下的调用上下文 : CallContext
最近在分析现在团队的项目代码(基于.NET Framework 4.5),经常发现一个CallContext的调用,记得多年前的时候用到了它,但是印象已经不深刻了,于是现在来复习一下. 1 CallC ...
- C# 注释 各个关键字段 使用说明
https://www.cnblogs.com/xdot/p/6632313.html#:~:text=%E5%9C%A8C%23%E6%99%BA%E8%83%BD%E6%B3%A8%E9%87%8 ...
- DataGridView绑定BindingList 中的 DataGridViewCheckBoxColumn 无法点击排序问题
参考文档 DataGridView绑定BindingList<T>带数据排序的类 - 腾讯云开发者社区-腾讯云 (tencent.com) DataGridView使用技巧十三:点击列头实 ...
- 4G DTU在废品智能回收系统中的应用
1.概述 废品智能回收系统要实现的功能描述: 可回收物:可投放到智能垃圾分类回收箱的大致有这五大品类:纸类.纺织物.金属.塑料.玻璃.回收柜上各品类是分开箱体的,需要把对应的可回收物投放到对应的箱体内 ...
- 简单的sqlHelper类
public class SQLHelper { //连接数据库 static string connStr = ConfigurationManager.Conn ...
- 前端开发系列018-基础篇之JavaScript原型链
本文旨在花很少的篇幅讲清楚JavaScript语言中的原型链结构,很多朋友认为JavaScript中的原型链复杂难懂,其实不然,它们就像树上的一串猴子. 一.理解原型链 JavaScript中几乎所有 ...
- Codeforces Round #685 (Div. 2) C. String Equality 思维
传送门 题意:给你一个原串和模式串,问你能否通过两种操作把原串变成模式串. 操作方法: 1.交换任意相邻字符. 2.将k长度的相同字符子串全+1. 思路: 对于操作1,相当于我们可以任意排序原串. 结 ...
- 为什么 战舰stm32f103开发板32.768k晶振没有接电容
主要是方便起振,ST的RTC晶振特难振,不焊接这两个电容,起振率高一点 转自: http://www.openedv.com/forum.php?mod=viewthread&tid=737 ...
