feat: initial commit
This commit is contained in:
84
packages/bolt/app/lib/runtime/message-parser.spec.ts
Normal file
84
packages/bolt/app/lib/runtime/message-parser.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { StreamingMessageParser } from './message-parser';
|
||||
|
||||
describe('StreamingMessageParser', () => {
|
||||
it('should pass through normal text', () => {
|
||||
const parser = new StreamingMessageParser();
|
||||
expect(parser.parse('test_id', 'Hello, world!')).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('should allow normal HTML tags', () => {
|
||||
const parser = new StreamingMessageParser();
|
||||
expect(parser.parse('test_id', 'Hello <strong>world</strong>!')).toBe('Hello <strong>world</strong>!');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Foo bar', 'Foo bar'],
|
||||
['Foo bar <', 'Foo bar '],
|
||||
['Foo bar <p', 'Foo bar <p'],
|
||||
['Foo bar <b', 'Foo bar '],
|
||||
['Foo bar <ba', 'Foo bar <ba'],
|
||||
['Foo bar <bol', 'Foo bar '],
|
||||
['Foo bar <bolt', 'Foo bar '],
|
||||
['Foo bar <bolta', 'Foo bar <bolta'],
|
||||
['Foo bar <boltA', 'Foo bar '],
|
||||
['Some text before <boltArtifact>foo</boltArtifact> Some more text', 'Some text before Some more text'],
|
||||
[['Some text before <boltArti', 'fact>foo</boltArtifact> Some more text'], 'Some text before Some more text'],
|
||||
[['Some text before <boltArti', 'fac', 't>foo</boltArtifact> Some more text'], 'Some text before Some more text'],
|
||||
[['Some text before <boltArti', 'fact>fo', 'o</boltArtifact> Some more text'], 'Some text before Some more text'],
|
||||
[
|
||||
['Some text before <boltArti', 'fact>fo', 'o', '<', '/boltArtifact> Some more text'],
|
||||
'Some text before Some more text',
|
||||
],
|
||||
[
|
||||
['Some text before <boltArti', 'fact>fo', 'o<', '/boltArtifact> Some more text'],
|
||||
'Some text before Some more text',
|
||||
],
|
||||
['Before <oltArtfiact>foo</boltArtifact> After', 'Before <oltArtfiact>foo</boltArtifact> After'],
|
||||
['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 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 After',
|
||||
[
|
||||
{ type: 'shell', content: 'npm install' },
|
||||
{ type: 'file', path: '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 parser = new StreamingMessageParser({
|
||||
artifactElement: '',
|
||||
callbacks: {
|
||||
onAction: (id, action) => {
|
||||
expect(testId).toBe(id);
|
||||
expect(action).toEqual(expectedActions[actionCounter]);
|
||||
actionCounter++;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let message = '';
|
||||
|
||||
let result = '';
|
||||
|
||||
const chunks = Array.isArray(input) ? input : input.split('');
|
||||
|
||||
for (const chunk of chunks) {
|
||||
message += chunk;
|
||||
|
||||
result += parser.parse(testId, message);
|
||||
}
|
||||
|
||||
expect(actionCounter).toBe(expectedActions.length);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
172
packages/bolt/app/lib/runtime/message-parser.ts
Normal file
172
packages/bolt/app/lib/runtime/message-parser.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
onArtifactOpen?: ArtifactOpenCallback;
|
||||
onArtifactClose?: ArtifactCloseCallback;
|
||||
onAction?: ActionCallback;
|
||||
}
|
||||
|
||||
type ElementFactory = () => string;
|
||||
|
||||
interface StreamingMessageParserOptions {
|
||||
callbacks?: Callbacks;
|
||||
artifactElement?: string | ElementFactory;
|
||||
}
|
||||
|
||||
export class StreamingMessageParser {
|
||||
#lastPositions = new Map<string, number>();
|
||||
#insideArtifact = false;
|
||||
#insideAction = false;
|
||||
#currentAction: BoltActionData = { content: '' };
|
||||
|
||||
constructor(private _options: StreamingMessageParserOptions = {}) {}
|
||||
|
||||
parse(id: string, input: string) {
|
||||
let output = '';
|
||||
let i = this.#lastPositions.get(id) ?? 0;
|
||||
let earlyBreak = false;
|
||||
|
||||
while (i < input.length) {
|
||||
if (this.#insideArtifact) {
|
||||
if (this.#insideAction) {
|
||||
const closeIndex = input.indexOf(ARTIFACT_ACTION_TAG_CLOSE, i);
|
||||
|
||||
if (closeIndex !== -1) {
|
||||
this.#currentAction.content += input.slice(i, closeIndex);
|
||||
|
||||
let content = this.#currentAction.content.trim();
|
||||
|
||||
if (this.#currentAction.type === 'file') {
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
this.#currentAction.content = content;
|
||||
|
||||
this._options.callbacks?.onAction?.(id, this.#currentAction);
|
||||
|
||||
this.#insideAction = false;
|
||||
this.#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) {
|
||||
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;
|
||||
i = actionEndIndex + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (artifactCloseIndex !== -1) {
|
||||
this.#insideArtifact = false;
|
||||
|
||||
this._options.callbacks?.onArtifactClose?.(id);
|
||||
|
||||
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;
|
||||
|
||||
this.#insideArtifact = true;
|
||||
|
||||
this._options.callbacks?.onArtifactOpen?.(id, { title: artifactTitle });
|
||||
|
||||
output += this._options.artifactElement ?? `<div class="__boltArtifact__" data-message-id="${id}"></div>`;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this.#lastPositions.set(id, i);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#lastPositions.clear();
|
||||
this.#insideArtifact = false;
|
||||
this.#insideAction = false;
|
||||
this.#currentAction = { content: '' };
|
||||
}
|
||||
|
||||
#extractAttribute(tag: string, attributeName: string): string | undefined {
|
||||
const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user