feat: add first version of workbench, increase token limit, improve system prompt
This commit is contained in:
68
packages/bolt/app/lib/runtime/action-runner.ts
Normal file
68
packages/bolt/app/lib/runtime/action-runner.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { WebContainer } from '@webcontainer/api';
|
||||
import * as nodePath from 'node:path';
|
||||
import { createScopedLogger } from '../../utils/logger';
|
||||
import type { ActionCallbackData } from './message-parser';
|
||||
|
||||
const logger = createScopedLogger('ActionRunner');
|
||||
|
||||
export class ActionRunner {
|
||||
#webcontainer: Promise<WebContainer>;
|
||||
|
||||
constructor(webcontainerPromise: Promise<WebContainer>) {
|
||||
this.#webcontainer = webcontainerPromise;
|
||||
}
|
||||
|
||||
async runAction({ action }: ActionCallbackData, abortSignal?: AbortSignal) {
|
||||
logger.trace('Running action', action);
|
||||
|
||||
const { content } = action;
|
||||
|
||||
const webcontainer = await this.#webcontainer;
|
||||
|
||||
switch (action.type) {
|
||||
case 'file': {
|
||||
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', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await webcontainer.fs.writeFile(action.filePath, content);
|
||||
logger.debug(`File written ${action.filePath}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to write file\n', error);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'shell': {
|
||||
const process = await webcontainer.spawn('jsh', ['-c', content]);
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,28 +38,30 @@ describe('StreamingMessageParser', () => {
|
||||
['Before <boltArtifactt>foo</boltArtifact> After', 'Before <boltArtifactt>foo</boltArtifact> After'],
|
||||
['Before <boltArtifact title="Some title">foo</boltArtifact> After', 'Before After'],
|
||||
[
|
||||
'Before <boltArtifact title="Some title"><boltAction type="shell">npm install</boltAction></boltArtifact> After',
|
||||
'Before <boltArtifact title="Some title" id="artifact_1"><boltAction type="shell">npm install</boltAction></boltArtifact> After',
|
||||
'Before After',
|
||||
[{ type: 'shell', content: 'npm install' }],
|
||||
],
|
||||
[
|
||||
'Before <boltArtifact title="Some title"><boltAction type="shell">npm install</boltAction><boltAction type="file" path="index.js">some content</boltAction></boltArtifact> After',
|
||||
'Before <boltArtifact title="Some title" id="artifact_1"><boltAction type="shell">npm install</boltAction><boltAction type="file" filePath="index.js">some content</boltAction></boltArtifact> After',
|
||||
'Before After',
|
||||
[
|
||||
{ type: 'shell', content: 'npm install' },
|
||||
{ type: 'file', path: 'index.js', content: 'some content\n' },
|
||||
{ type: 'file', filePath: 'index.js', content: 'some content\n' },
|
||||
],
|
||||
],
|
||||
])('should correctly parse chunks and strip out bolt artifacts', (input, expected, expectedActions = []) => {
|
||||
let actionCounter = 0;
|
||||
|
||||
const testId = 'test_id';
|
||||
const expectedArtifactId = 'artifact_1';
|
||||
const expectedMessageId = 'message_1';
|
||||
|
||||
const parser = new StreamingMessageParser({
|
||||
artifactElement: '',
|
||||
callbacks: {
|
||||
onAction: (id, action) => {
|
||||
expect(testId).toBe(id);
|
||||
onAction: ({ artifactId, messageId, action }) => {
|
||||
expect(artifactId).toBe(expectedArtifactId);
|
||||
expect(messageId).toBe(expectedMessageId);
|
||||
expect(action).toEqual(expectedActions[actionCounter]);
|
||||
actionCounter++;
|
||||
},
|
||||
@@ -75,7 +77,7 @@ describe('StreamingMessageParser', () => {
|
||||
for (const chunk of chunks) {
|
||||
message += chunk;
|
||||
|
||||
result += parser.parse(testId, message);
|
||||
result += parser.parse(expectedMessageId, message);
|
||||
}
|
||||
|
||||
expect(actionCounter).toBe(expectedActions.length);
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction } from '../../types/actions';
|
||||
import type { BoltArtifactData } from '../../types/artifact';
|
||||
import { createScopedLogger } from '../../utils/logger';
|
||||
import { unreachable } from '../../utils/unreachable';
|
||||
|
||||
const ARTIFACT_TAG_OPEN = '<boltArtifact';
|
||||
const ARTIFACT_TAG_CLOSE = '</boltArtifact>';
|
||||
const ARTIFACT_ACTION_TAG_OPEN = '<boltAction';
|
||||
const ARTIFACT_ACTION_TAG_CLOSE = '</boltAction>';
|
||||
|
||||
interface BoltArtifact {
|
||||
title: string;
|
||||
const logger = createScopedLogger('MessageParser');
|
||||
|
||||
export interface ArtifactCallbackData extends BoltArtifactData {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
type ArtifactOpenCallback = (messageId: string, artifact: BoltArtifact) => void;
|
||||
type ArtifactCloseCallback = (messageId: string) => void;
|
||||
type ActionCallback = (messageId: string, action: BoltActionData) => void;
|
||||
|
||||
type ActionType = 'file' | 'shell';
|
||||
|
||||
export interface BoltActionData {
|
||||
type?: ActionType;
|
||||
path?: string;
|
||||
content: string;
|
||||
export interface ActionCallbackData {
|
||||
artifactId: string;
|
||||
messageId: string;
|
||||
actionId: string;
|
||||
action: BoltAction;
|
||||
}
|
||||
|
||||
type ArtifactOpenCallback = (data: ArtifactCallbackData) => void;
|
||||
type ArtifactCloseCallback = (data: ArtifactCallbackData) => void;
|
||||
type ActionCallback = (data: ActionCallbackData) => void;
|
||||
|
||||
interface Callbacks {
|
||||
onArtifactOpen?: ArtifactOpenCallback;
|
||||
onArtifactClose?: ArtifactCloseCallback;
|
||||
@@ -32,39 +38,72 @@ interface StreamingMessageParserOptions {
|
||||
artifactElement?: string | ElementFactory;
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
position: number;
|
||||
insideArtifact: boolean;
|
||||
insideAction: boolean;
|
||||
currentArtifact?: BoltArtifactData;
|
||||
currentAction: BoltActionData;
|
||||
actionId: number;
|
||||
}
|
||||
|
||||
export class StreamingMessageParser {
|
||||
#lastPositions = new Map<string, number>();
|
||||
#insideArtifact = false;
|
||||
#insideAction = false;
|
||||
#currentAction: BoltActionData = { content: '' };
|
||||
#messages = new Map<string, MessageState>();
|
||||
|
||||
constructor(private _options: StreamingMessageParserOptions = {}) {}
|
||||
|
||||
parse(id: string, input: string) {
|
||||
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 = this.#lastPositions.get(id) ?? 0;
|
||||
let i = state.position;
|
||||
let earlyBreak = false;
|
||||
|
||||
while (i < input.length) {
|
||||
if (this.#insideArtifact) {
|
||||
if (this.#insideAction) {
|
||||
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) {
|
||||
this.#currentAction.content += input.slice(i, closeIndex);
|
||||
currentAction.content += input.slice(i, closeIndex);
|
||||
|
||||
let content = this.#currentAction.content.trim();
|
||||
let content = currentAction.content.trim();
|
||||
|
||||
if (this.#currentAction.type === 'file') {
|
||||
if ('type' in currentAction && currentAction.type === 'file') {
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
this.#currentAction.content = content;
|
||||
currentAction.content = content;
|
||||
|
||||
this._options.callbacks?.onAction?.(id, this.#currentAction);
|
||||
this._options.callbacks?.onAction?.({
|
||||
artifactId: currentArtifact.id,
|
||||
messageId,
|
||||
actionId: String(state.actionId++),
|
||||
action: currentAction as BoltAction,
|
||||
});
|
||||
|
||||
this.#insideAction = false;
|
||||
this.#currentAction = { content: '' };
|
||||
state.insideAction = false;
|
||||
state.currentAction = { content: '' };
|
||||
|
||||
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
|
||||
} else {
|
||||
@@ -79,17 +118,39 @@ export class StreamingMessageParser {
|
||||
|
||||
if (actionEndIndex !== -1) {
|
||||
const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
|
||||
this.#currentAction.type = this.#extractAttribute(actionTag, 'type') as ActionType;
|
||||
this.#currentAction.path = this.#extractAttribute(actionTag, 'path');
|
||||
this.#insideAction = true;
|
||||
|
||||
const actionType = this.#extractAttribute(actionTag, 'type') as ActionType;
|
||||
|
||||
const actionAttributes = {
|
||||
type: actionType,
|
||||
content: '',
|
||||
};
|
||||
|
||||
if (actionType === 'file') {
|
||||
const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
|
||||
|
||||
if (!filePath) {
|
||||
logger.debug('File path not specified');
|
||||
}
|
||||
|
||||
(actionAttributes as FileAction).filePath = filePath;
|
||||
} else if (actionType !== 'shell') {
|
||||
logger.warn(`Unknown action type '${actionType}'`);
|
||||
}
|
||||
|
||||
state.currentAction = actionAttributes as FileAction | ShellAction;
|
||||
|
||||
state.insideAction = true;
|
||||
|
||||
i = actionEndIndex + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (artifactCloseIndex !== -1) {
|
||||
this.#insideArtifact = false;
|
||||
this._options.callbacks?.onArtifactClose?.({ messageId, ...currentArtifact });
|
||||
|
||||
this._options.callbacks?.onArtifactClose?.(id);
|
||||
state.insideArtifact = false;
|
||||
state.currentArtifact = undefined;
|
||||
|
||||
i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length;
|
||||
} else {
|
||||
@@ -118,12 +179,30 @@ export class StreamingMessageParser {
|
||||
const artifactTag = input.slice(i, openTagEnd + 1);
|
||||
|
||||
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
|
||||
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
|
||||
|
||||
this.#insideArtifact = true;
|
||||
if (!artifactTitle) {
|
||||
logger.warn('Artifact title missing');
|
||||
}
|
||||
|
||||
this._options.callbacks?.onArtifactOpen?.(id, { title: artifactTitle });
|
||||
if (!artifactId) {
|
||||
logger.warn('Artifact id missing');
|
||||
}
|
||||
|
||||
output += this._options.artifactElement ?? `<div class="__boltArtifact__" data-message-id="${id}"></div>`;
|
||||
state.insideArtifact = true;
|
||||
|
||||
const currentArtifact = {
|
||||
id: artifactId,
|
||||
title: artifactTitle,
|
||||
} satisfies BoltArtifactData;
|
||||
|
||||
state.currentArtifact = currentArtifact;
|
||||
|
||||
this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
|
||||
|
||||
output +=
|
||||
this._options.artifactElement ??
|
||||
`<div class="__boltArtifact__" data-artifact-id="${artifactId}" data-message-id="${messageId}"></div>`;
|
||||
|
||||
i = openTagEnd + 1;
|
||||
} else {
|
||||
@@ -153,16 +232,13 @@ export class StreamingMessageParser {
|
||||
}
|
||||
}
|
||||
|
||||
this.#lastPositions.set(id, i);
|
||||
state.position = i;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#lastPositions.clear();
|
||||
this.#insideArtifact = false;
|
||||
this.#insideAction = false;
|
||||
this.#currentAction = { content: '' };
|
||||
this.#messages.clear();
|
||||
}
|
||||
|
||||
#extractAttribute(tag: string, attributeName: string): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user