feat: add first version of workbench, increase token limit, improve system prompt

This commit is contained in:
Dominic Elm
2024-07-17 20:54:46 +02:00
parent b4420a22bb
commit 621b8804d8
50 changed files with 2979 additions and 423 deletions

View 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}`);
}
}
}
}

View File

@@ -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);

View File

@@ -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 {