| | 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>'; |
| |
|
| | const logger = createScopedLogger('MessageParser'); |
| |
|
| | export interface ArtifactCallbackData extends BoltArtifactData { |
| | messageId: string; |
| | } |
| |
|
| | export interface ActionCallbackData { |
| | artifactId: string; |
| | messageId: string; |
| | actionId: string; |
| | action: BoltAction; |
| | } |
| |
|
| | export type ArtifactCallback = (data: ArtifactCallbackData) => void; |
| | export type ActionCallback = (data: ActionCallbackData) => void; |
| |
|
| | export interface ParserCallbacks { |
| | onArtifactOpen?: ArtifactCallback; |
| | onArtifactClose?: ArtifactCallback; |
| | onActionOpen?: ActionCallback; |
| | onActionStream?: ActionCallback; |
| | onActionClose?: ActionCallback; |
| | } |
| |
|
| | interface ElementFactoryProps { |
| | messageId: string; |
| | } |
| |
|
| | type ElementFactory = (props: ElementFactoryProps) => string; |
| |
|
| | export interface StreamingMessageParserOptions { |
| | callbacks?: ParserCallbacks; |
| | artifactElement?: ElementFactory; |
| | } |
| |
|
| | interface MessageState { |
| | position: number; |
| | insideArtifact: boolean; |
| | insideAction: boolean; |
| | currentArtifact?: BoltArtifactData; |
| | currentAction: BoltActionData; |
| | actionId: number; |
| | } |
| |
|
| | function cleanoutMarkdownSyntax(content: string) { |
| | const codeBlockRegex = /^\s*```\w*\n([\s\S]*?)\n\s*```\s*$/; |
| | const match = content.match(codeBlockRegex); |
| |
|
| | |
| |
|
| | if (match) { |
| | return match[1]; |
| | } else { |
| | return content; |
| | } |
| | } |
| |
|
| | function cleanEscapedTags(content: string) { |
| | return content.replace(/</g, '<').replace(/>/g, '>'); |
| | } |
| | export class StreamingMessageParser { |
| | #messages = new Map<string, MessageState>(); |
| |
|
| | constructor(private _options: StreamingMessageParserOptions = {}) {} |
| |
|
| | 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') { |
| | |
| | if (!currentAction.filePath.endsWith('.md')) { |
| | content = cleanoutMarkdownSyntax(content); |
| | content = cleanEscapedTags(content); |
| | } |
| |
|
| | content += '\n'; |
| | } |
| |
|
| | currentAction.content = content; |
| |
|
| | this._options.callbacks?.onActionClose?.({ |
| | artifactId: currentArtifact.id, |
| | messageId, |
| |
|
| | |
| | |
| | |
| | |
| | |
| | actionId: String(state.actionId - 1), |
| |
|
| | action: currentAction as BoltAction, |
| | }); |
| |
|
| | state.insideAction = false; |
| | state.currentAction = { content: '' }; |
| |
|
| | i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length; |
| | } else { |
| | if ('type' in currentAction && currentAction.type === 'file') { |
| | let content = input.slice(i); |
| |
|
| | if (!currentAction.filePath.endsWith('.md')) { |
| | content = cleanoutMarkdownSyntax(content); |
| | content = cleanEscapedTags(content); |
| | } |
| |
|
| | this._options.callbacks?.onActionStream?.({ |
| | artifactId: currentArtifact.id, |
| | messageId, |
| | actionId: String(state.actionId - 1), |
| | action: { |
| | ...(currentAction as FileAction), |
| | content, |
| | filePath: currentAction.filePath, |
| | }, |
| | }); |
| | } |
| |
|
| | 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 type = this.#extractAttribute(artifactTag, 'type') 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, |
| | type, |
| | } 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; |
| | } |
| |
|
| | reset() { |
| | this.#messages.clear(); |
| | } |
| |
|
| | #parseActionTag(input: string, actionOpenIndex: number, actionEndIndex: number) { |
| | const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1); |
| |
|
| | 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 (!['shell', 'start'].includes(actionType)) { |
| | logger.warn(`Unknown action type '${actionType}'`); |
| | } |
| |
|
| | return actionAttributes as FileAction | ShellAction; |
| | } |
| |
|
| | #extractAttribute(tag: string, attributeName: string): string | undefined { |
| | const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i')); |
| | return match ? match[1] : undefined; |
| | } |
| | } |
| |
|
| | const createArtifactElement: ElementFactory = (props) => { |
| | const elementProps = [ |
| | 'class="__boltArtifact__"', |
| | ...Object.entries(props).map(([key, value]) => { |
| | return `data-${camelToDashCase(key)}=${JSON.stringify(value)}`; |
| | }), |
| | ]; |
| |
|
| | return `<div ${elementProps.join(' ')}></div>`; |
| | }; |
| |
|
| | function camelToDashCase(input: string) { |
| | return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); |
| | } |
| |
|