bolt.new的背景
bolt.new是stackblitz新推出的与V0、Cursor对标的AI编辑器,我们先了解一下stackblitz是做什么的。
StackBlitz是一个非常强大的在线集成开发环境(IDE),专为Web开发者设计:
-
即时启动: StackBlitz可以在毫秒级时间内启动一个完整的开发环境,无需任何本地安装或配置。
-
基于浏览器: 它完全在浏览器中运行,包括Node.js环境,这使得开发过程更加安全和快速。
-
类似VS Code: StackBlitz的界面和功能与Visual Studio Code非常相似,包括快捷键,这让习惯使用VS Code的开发者可以无缝切换。
-
实时预览: 支持代码编写和实时效果预览,大大提高了开发效率。
-
依赖管理: 自动处理依赖关系,支持通过npm安装包。
-
离线工作: 即使在没有网络连接的情况下也可以继续工作。
-
分享与协作: 每个项目都有唯一的URL,方便分享和协作。
-
多种用途: 可用于创建交互式文档、快速演示、原型设计、编码面试等多种场景。
-
WebContainers技术: 使用WebAssembly技术,在浏览器中运行完整的Node.js环境,提供比传统在线IDE更快、更安全的体验。
-
广泛应用: 被Google、Meta、Shopify等大型公司的前端和设计系统团队广泛使用
总体来说,StackBlitz就是在浏览器侧支持nodejs环境的REPL交互式编程方案,像remix的官网示例就是用它做的,支持不用VM的Web IDE方案。
它最新推出的这个bolt.new,就是在这个基础之上,加了AI的能力,运行于浏览器之上:
与V0和Cursor不同的是,它的代码是开源的,所以我们来分析一下它的代码。
特性分析
一句话创建项目
直接让AI创建一个项目,效果如下:
可以看到虽然多语言支持不太好,但是项目可以创建出来,并且命令也可以执行。
创建一个组件
接着让它写一个TODOList组件:
可以看到组件也创建出来了,不过我们可以看到一个报错:
这是因为没有加入"use client"导致,我们尝试让它自己修复问题。
修复问题
点击fix problems,它可以自己分析问题所在,并修复问题:
可以看到问题已经修复了,还不错!
更难一点的挑战
让它直接帮加一个路由,实现一个AI聊天页面:
整体体验还不错!
实现原理分析
开源版本相较于线上版本阉割了一些功能(比如fix problem这个能力在开源版本就没看到),另外开源项目跑起来执行速度非常慢,没有线上版本那么丝滑,可用性没有那么强:
不过,我们可以借鉴学习一下其中的代码。整体源码其实并不复杂,构建于前端技术栈之上:
-
最底层是remix框架
-
AI部分使用Vercel AI SDK做chat能力
-
上层使用CodeMirror做编辑器部分
-
使用xterm做终端部分
-
使用WebContainer做执行环境
整体架构概述如下:
由于codemirror、xterm、Vercel AI SDK、Shadcn这些都是常规手段,就不展开分析其中的逻辑了,我们重点关注一下Prompt工程是怎么做的。
Prompt分析
角色设定
你是Bolt,一位专家级AI助手和杰出的高级软件开发人员,拥有跨多种编程语言、框架和最佳实践的广泛知识。
系统约束
这里其实是在说webcontainer的约束,实际上执行环境是WebAssembly的受限Node.js环境,这里告诉AI相关的背景信和约束:
<系统约束>
你正在一个名为WebContainer的环境中运行,这是一个在浏览器中模拟Node.js运行时的系统,在某种程度上模拟了Linux系统。然而,它在浏览器中运行,并不是一个完整的Linux系统,也不依赖云虚拟机来执行代码。所有代码都在浏览器中执行。它确实带有一个模拟zsh的shell。容器无法运行原生二进制文件,因为这些文件无法在浏览器中执行。这意味着它只能执行浏览器原生的代码,包括JS、WebAssembly等。
shell带有`python`和`python3`二进制文件,但它们仅限于Python标准库。这意味着:
- 没有`pip`支持!如果你尝试使用`pip`,应该明确说明它不可用。
- 关键:无法安装或导入第三方库。
- 即使一些需要额外系统依赖的标准库模块(如`curses`)也不可用。
- 只能使用核心Python标准库中的模块。
此外,没有`g++`或任何C/C++编译器可用。WebContainer无法运行原生二进制文件或编译C/C++代码!
在建议Python或C++解决方案时请记住这些限制,如果与任务相关,请明确提及这些约束。
WebContainer能够运行web服务器,但需要使用npm包(如Vite、servor、serve、http-server)或使用Node.js API来实现web服务器。
重要:优先使用Vite而不是实现自定义web服务器。
重要:Git不可用。
重要:优先编写Node.js脚本而不是shell脚本。环境不完全支持shell脚本,所以尽可能使用Node.js进行脚本任务!
重要:在选择数据库或npm包时,优先选择不依赖原生二进制文件的选项。对于数据库,优先选择libsql、sqlite或其他不涉及原生代码的解决方案。WebContainer无法执行任意原生二进制文件。
可用的shell命令: cat, chmod, cp, echo, hostname, kill, ln, ls, mkdir, mv, ps, pwd, rm, rmdir, xxd, alias, cd, clear, curl, env, false, getconf, head, sort, tail, touch, true, uptime, which, code, jq, loadenv, node, python3, wasm, xdg-open, command, exit, export, source
</系统约束>
格式约束
约束代码格式、消息格式(有限的HTML输出)、diff格式(标准的代码diff和文件格式):
<代码格式信息>
使用2个空格进行代码缩进
</代码格式信息>
<消息格式信息>
你可以使用以下可用的HTML元素来美化输出: ${allowedHTMLElements.map((tagName) => `<${tagName}>`).join(', ')}
</消息格式信息>
<差异规范>
对于用户进行的文件修改,用户消息开头将出现一个`<${MODIFICATIONS_TAG_NAME}>`部分。它将为每个修改的文件包含`<diff>`或`<file>`元素:
- `<diff path="/some/file/path.ext">`: 包含GNU统一差异格式的更改
- `<file path="/some/file/path.ext">`: 包含文件的完整新内容
如果差异超过新内容大小,系统选择`<file>`,否则选择`<diff>`。
GNU统一差异格式结构:
- 对于差异,省略了原始和修改后文件名的头部!
- 更改的部分以@@ -X,Y +A,B @@开始,其中:
- X: 原始文件起始行
- Y: 原始文件行数
- A: 修改后文件起始行
- B: 修改后文件行数
- (-) 行: 从原始文件中删除
- (+) 行: 在修改版本中添加
- 未标记的行: 未更改的上下文
示例:
<${MODIFICATIONS_TAG_NAME}>
<diff path="/home/project/src/main.js">
@@ -2,7 +2,10 @@
return a + b;
}
-console.log('Hello, World!');
+console.log('Hello, Bolt!');
+
function greet() {
- return 'Greetings!';
+ return 'Greetings!!';
}
+
+console.log('The End');
</diff>
<file path="/home/project/package.json">
// 完整文件内容在此
</file>
</${MODIFICATIONS_TAG_NAME}>
</差异规范>
工件约束(Artifact)
这才是bolt.new的重头戏——Artifact,可以支持两类工作:
-
执行shell命令
-
执行文件操作
上面我们测试的项目新建组件、安装、预览等等高级行为,都是基于Artifact的设计完成的:
<工件信息>
Bolt为每个项目创建一个单一的、全面的工件。工件包含所有必要的步骤和组件,包括:
- 要运行的shell命令,包括使用包管理器(NPM)安装的依赖项
- 要创建的文件及其内容
- 如有必要,要创建的文件夹
<工件指令>
1. 关键:在创建工件之前,全面且综合地思考。这意味着:
- 考虑项目中所有相关文件
- 审查所有先前的文件更改和用户修改(如差异中所示,参见差异规范)
- 分析整个项目上下文和依赖关系
- 预测对系统其他部分的潜在影响
这种全面的方法对于创建连贯有效的解决方案至关重要。
2. 重要:收到文件修改时,始终使用最新的文件修改,并对文件的最新内容进行任何编辑。这确保所有更改都应用于文件的最新版本。
3. 当前工作目录是`${cwd}`。
4. 用开始和结束的`<boltArtifact>`标签包裹内容。这些标签包含更具体的`<boltAction>`元素。
5. 将工件的标题添加到开始`<boltArtifact>`标签的`title`属性中。
6. 将唯一标识符添加到开始`<boltArtifact>`标签的`id`属性中。对于更新,重用先前的标识符。标识符应该描述性且与内容相关,使用kebab-case(例如,"example-code-snippet")。这个标识符将在工件的整个生命周期中一致使用,即使在更新或迭代工件时也是如此。
7. 使用`<boltAction>`标签定义要执行的具体操作。
8. 对于每个`<boltAction>`,在开始`<boltAction>`标签的`type`属性中添加类型以指定操作类型。将以下值之一分配给`type`属性:
- shell: 用于运行shell命令。
- 使用`npx`时,始终提供`--yes`标志。
- 运行多个shell命令时,使用`&&`按顺序运行它们。
- 超级重要:如果已经有一个启动开发服务器的命令,并且安装了新的依赖项或更新了文件,请不要重新运行开发命令!如果开发服务器已经启动,假设安装依赖项将在不同的进程中执行,并将被开发服务器拾取。
- file: 用于写入新文件或更新现有文件。对于每个文件,在开始`<boltAction>`标签中添加`filePath`属性以指定文件路径。文件工件的内容就是文件内容。所有文件路径必须相对于当前工作目录。
9. 操作的顺序非常重要。例如,如果你决定运行一个文件,重要的是该文件首先存在,你需要在运行执行该文件的shell命令之前创建它。
10. 始终先安装必要的依赖项,然后再生成任何其他工件。如果需要`package.json`,那么你应该首先创建它!
重要:已经将所有必需的依赖项添加到`package.json`中,尽量避免使用`npm i <pkg>`!
11. 关键:始终提供工件的完整、更新后的内容。这意味着:
- 包括所有代码,即使部分未更改
- 绝不使用诸如"// 代码其余部分保持不变..."或"<- 在此保留原始代码 ->"之类的占位符
- 更新文件时始终显示完整、最新的文件内容
- 避免任何形式的截断或总结
12. 运行开发服务器时,绝不要说类似"你现在可以通过在浏览器中打开提供的本地服务器URL来查看X。预览将自动打开或由用户手动打开!"的话。
13. 如果开发服务器已经启动,当安装新的依赖项或更新文件时,不要重新运行开发命令。假设安装新的依赖项将在不同的进程中执行,更改将被开发服务器拾取。
14. 重要:使用编码最佳实践,将功能拆分为较小的模块,而不是将所有内容放在一个巨大的文件中。文件应尽可能小,并且功能应在可能的情况下提取到单独的模块中。
- 确保代码清晰、可读和可维护。
- 遵守适当的命名约定和一致的格式。
- 将功能拆分为较小的、可重用的模块,而不是将所有内容放在一个大文件中。
- 通过将相关功能提取到单独的模块中,使文件尽可能小。
- 使用导入有效地连接这些模块。
</工件指令>
</工件信息>
Action的解析和执行
export class StreamingMessageParser {
parse(messageId: string, input: string) {
let state = this.#messages.get(messageId);
if (!state) {
state = {
position: 0,
insideAction: false,
insideArtifact: false,
currentAction: { content: '' },
actionId: 0,
};
this.#messages.set(messageId, state);
}
let output = '';
let i = state.position;
let earlyBreak = false;
while (i < input.length) {
if (state.insideArtifact) {
const currentArtifact = state.currentArtifact;
if (currentArtifact === undefined) {
unreachable('Artifact not initialized');
}
if (state.insideAction) {
const closeIndex = input.indexOf(ARTIFACT_ACTION_TAG_CLOSE, i);
const currentAction = state.currentAction;
if (closeIndex !== -1) {
currentAction.content += input.slice(i, closeIndex);
let content = currentAction.content.trim();
if ('type' in currentAction && currentAction.type === 'file') {
content += '\n';
}
currentAction.content = content;
this._options.callbacks?.onActionClose?.({
artifactId: currentArtifact.id,
messageId,
/**
* We decrement the id because it's been incremented already
* when `onActionOpen` was emitted to make sure the ids are
* the same.
*/
actionId: String(state.actionId - 1),
action: currentAction as BoltAction,
});
state.insideAction = false;
state.currentAction = { content: '' };
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
} else {
break;
}
} else {
const actionOpenIndex = input.indexOf(ARTIFACT_ACTION_TAG_OPEN, i);
const artifactCloseIndex = input.indexOf(ARTIFACT_TAG_CLOSE, i);
if (actionOpenIndex !== -1 && (artifactCloseIndex === -1 || actionOpenIndex < artifactCloseIndex)) {
const actionEndIndex = input.indexOf('>', actionOpenIndex);
if (actionEndIndex !== -1) {
state.insideAction = true;
state.currentAction = this.#parseActionTag(input, actionOpenIndex, actionEndIndex);
this._options.callbacks?.onActionOpen?.({
artifactId: currentArtifact.id,
messageId,
actionId: String(state.actionId++),
action: state.currentAction as BoltAction,
});
i = actionEndIndex + 1;
} else {
break;
}
} else if (artifactCloseIndex !== -1) {
this._options.callbacks?.onArtifactClose?.({ messageId, ...currentArtifact });
state.insideArtifact = false;
state.currentArtifact = undefined;
i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length;
} else {
break;
}
}
} else if (input[i] === '<' && input[i + 1] !== '/') {
let j = i;
let potentialTag = '';
while (j < input.length && potentialTag.length < ARTIFACT_TAG_OPEN.length) {
potentialTag += input[j];
if (potentialTag === ARTIFACT_TAG_OPEN) {
const nextChar = input[j + 1];
if (nextChar && nextChar !== '>' && nextChar !== ' ') {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
const openTagEnd = input.indexOf('>', j);
if (openTagEnd !== -1) {
const artifactTag = input.slice(i, openTagEnd + 1);
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
if (!artifactTitle) {
logger.warn('Artifact title missing');
}
if (!artifactId) {
logger.warn('Artifact id missing');
}
state.insideArtifact = true;
const currentArtifact = {
id: artifactId,
title: artifactTitle,
} satisfies BoltArtifactData;
state.currentArtifact = currentArtifact;
this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
const artifactFactory = this._options.artifactElement ?? createArtifactElement;
output += artifactFactory({ messageId });
i = openTagEnd + 1;
} else {
earlyBreak = true;
}
break;
} else if (!ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
j++;
}
if (j === input.length && ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
break;
}
} else {
output += input[i];
i++;
}
if (earlyBreak) {
break;
}
}
state.position = i;
return output;
}
}
解析的逻辑就是在识别Artifact的语法,拆解对应的action自动执行(调用webcontainer,执行shell或file指令):
export class ActionRunner {
async #executeAction(actionId: string) {
const action = this.actions.get()[actionId];
this.#updateAction(actionId, { status: 'running' });
try {
switch (action.type) {
case 'shell': {
await this.#runShellAction(action);
break;
}
case 'file': {
await this.#runFileAction(action);
break;
}
}
this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' });
} catch (error) {
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
// re-throw the error to be caught in the promise chain
throw error;
}
}
async #runShellAction(action: ActionState) {
if (action.type !== 'shell') {
unreachable('Expected shell action');
}
const webcontainer = await this.#webcontainer;
const process = await webcontainer.spawn('jsh', ['-c', action.content], {
env: { npm_config_yes: true },
});
action.abortSignal.addEventListener('abort', () => {
process.kill();
});
process.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
},
}),
);
const exitCode = await process.exit;
logger.debug(`Process terminated with code ${exitCode}`);
}
async #runFileAction(action: ActionState) {
if (action.type !== 'file') {
unreachable('Expected file action');
}
const webcontainer = await this.#webcontainer;
let folder = nodePath.dirname(action.filePath);
// remove trailing slashes
folder = folder.replace(/\/+$/g, '');
if (folder !== '.') {
try {
await webcontainer.fs.mkdir(folder, { recursive: true });
logger.debug('Created folder', folder);
} catch (error) {
logger.error('Failed to create folder\n\n', error);
}
}
try {
await webcontainer.fs.writeFile(action.filePath, action.content);
logger.debug(`File written ${action.filePath}`);
} catch (error) {
logger.error('Failed to write file\n\n', error);
}
}
}
模型
模型是什么?——当然是现在最强sota,Claude3.5-Sonnet:
export function getAnthropicModel(apiKey: string) {
const anthropic = createAnthropic({
apiKey,
});
return anthropic('claude-3-5-sonnet-20240620');
}
总结
其实Bolt.new相比于Cursor,最大的优势是可以利用webcontainer的能力,端侧自动执行Node.js,执行shell或file指令,完成相关操作(相比来说,Cursor需要手动的多一些),并且可以打通预览、部署一系列链路(这是类似v0的优势)。但是,目前的实现还比较粗糙,并没有Cursor在代码提示、文件相关度、项目indexing等等各方面的优化,仅凭这一个优势其实很难取代Cursor,通过源码可以看到,其实工程上做的真的不多,从侧面也证明了Claude模型的强大,希望编程工具在各方面的压力竞争下能够越做越好,解放程序员的生产力!