Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reintroduce streaming. New streaming parser #432

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 91 additions & 22 deletions packages/api/ai/stream-xml-parser.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
export type NodeSchema = {
isContentNode?: boolean;
hasCdata?: boolean;
allowedChildren?: string[];
};

export const xmlSchema: Record<string, NodeSchema> = {
plan: { isContentNode: false, hasCdata: false },
action: { isContentNode: false, hasCdata: false },
description: { isContentNode: true, hasCdata: true },
file: { isContentNode: false, hasCdata: true },
commandType: { isContentNode: true, hasCdata: false },
package: { isContentNode: true, hasCdata: false },
planDescription: { isContentNode: true, hasCdata: true },
};

export type TagType = {
name: string;
attributes: Record<string, string>;
Expand All @@ -13,6 +29,7 @@ export class StreamingXMLParser {
private tagStack: TagType[] = [];
private isInCDATA = false;
private cdataBuffer = '';
private textBuffer = '';
private onTag: TagCallbackType;

constructor({ onTag }: { onTag: TagCallbackType }) {
Expand All @@ -34,6 +51,12 @@ export class StreamingXMLParser {
}

private handleOpenTag(tagContent: string) {
// First, save any accumulated text content to the current tag
if (this.currentTag && this.textBuffer.trim()) {
this.currentTag.content = this.textBuffer.trim();
}
this.textBuffer = '';

const spaceIndex = tagContent.indexOf(' ');
const tagName = spaceIndex === -1 ? tagContent : tagContent.substring(0, spaceIndex);
const attributeString = spaceIndex === -1 ? '' : tagContent.substring(spaceIndex + 1);
Expand All @@ -46,6 +69,7 @@ export class StreamingXMLParser {
};

if (this.currentTag) {
// Push current tag to stack before moving to new tag
this.tagStack.push(this.currentTag);
this.currentTag.children.push(newTag);
}
Expand All @@ -54,17 +78,51 @@ export class StreamingXMLParser {
}

private handleCloseTag(tagName: string) {
if (!this.currentTag) return;
if (!this.currentTag) {
console.warn('Attempted to handle close tag with no current tag');
return;
}

if (this.currentTag.name === tagName) {
this.onTag(this.currentTag);
// Save any remaining text content before closing
// Don't overwrite CDATA content, it's already been written
const schema = xmlSchema[this.currentTag.name];
const isCdataNode = schema ? schema.hasCdata : false;
if (!isCdataNode) {
this.currentTag.content = this.textBuffer.trim();
}
this.textBuffer = '';

if (this.tagStack.length > 0) {
this.currentTag = this.tagStack.pop()!;
} else {
this.currentTag = null;
}
if (this.currentTag.name !== tagName) {
return;
}

// Clean and emit the completed tag
this.currentTag = this.cleanNode(this.currentTag);
this.onTag(this.currentTag);

// Pop the parent tag from the stack
if (this.tagStack.length > 0) {
this.currentTag = this.tagStack.pop()!;
} else {
this.currentTag = null;
}
}

private cleanNode(node: TagType): TagType {
const schema = xmlSchema[node.name];

// If it's not in the schema, default to treating it as a content node
const isContentNode = schema ? schema.isContentNode : true;

// If it's not a content node and has children, remove its content
if (!isContentNode && node.children.length > 0) {
node.content = '';
}

// Recursively clean children
node.children = node.children.map((child) => this.cleanNode(child));

return node;
}

parse(chunk: string) {
Expand All @@ -75,42 +133,57 @@ export class StreamingXMLParser {
if (this.isInCDATA) {
const cdataEndIndex = this.cdataBuffer.indexOf(']]>');
if (cdataEndIndex === -1) {
this.cdataBuffer += chunk;
this.cdataBuffer += this.buffer;
// Sometimes ]]> is in the next chunk, and we don't want to lose what's behind it
const nextCdataEnd = this.cdataBuffer.indexOf(']]>');
if (nextCdataEnd !== -1) {
this.buffer = this.cdataBuffer.substring(nextCdataEnd);
} else {
this.buffer = '';
}
return;
}

this.cdataBuffer = this.cdataBuffer.substring(0, cdataEndIndex);
if (this.currentTag) {
this.currentTag.content = this.cdataBuffer;
this.currentTag.content = this.cdataBuffer.trim();
}
this.isInCDATA = false;
this.buffer = this.cdataBuffer.substring(cdataEndIndex + 3) + this.buffer;
this.cdataBuffer = '';
this.buffer = this.buffer.substring(cdataEndIndex + 3);
continue;
}

// Start of an opening tag?
// Look for the next tag
const openTagStartIdx = this.buffer.indexOf('<');
if (openTagStartIdx === -1) {
// No more tags in this chunk, save the rest as potential content
this.textBuffer += this.buffer;
this.buffer = '';
return;
}

// If this opening tag is CDATA, handle it differently than XML tags
if (this.sequenceExistsAt('<![CDATA[', openTagStartIdx)) {
// Save any text content before this tag
if (openTagStartIdx > 0) {
this.textBuffer += this.buffer.substring(0, openTagStartIdx);
this.buffer = this.buffer.substring(openTagStartIdx);
}

// Check for CDATA
if (this.sequenceExistsAt('<![CDATA[', 0)) {
this.isInCDATA = true;
const cdataStart = this.buffer.substring(openTagStartIdx + 9);
this.buffer = cdataStart;
const cdataStart = this.buffer.substring(9);
this.cdataBuffer = cdataStart;
this.buffer = '';
return;
}

const openTagEndIdx = this.buffer.indexOf('>', openTagStartIdx);
const openTagEndIdx = this.buffer.indexOf('>');
if (openTagEndIdx === -1) {
return;
}

const tagContent = this.buffer.substring(openTagStartIdx + 1, openTagEndIdx);
const tagContent = this.buffer.substring(1, openTagEndIdx);
this.buffer = this.buffer.substring(openTagEndIdx + 1);

if (tagContent.startsWith('/')) {
Expand All @@ -123,16 +196,12 @@ export class StreamingXMLParser {
}
}

/**
* Does the sequence exist starting at the given index in the buffer?
*/
private sequenceExistsAt(sequence: string, idx: number, buffer: string = this.buffer) {
for (let i = 0; i < sequence.length; i++) {
if (buffer[idx + i] !== sequence[i]) {
return false;
}
}

return true;
}
}
1 change: 1 addition & 0 deletions packages/api/server/http.mts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ router.options('/apps/:id/edit', cors());
router.post('/apps/:id/edit', cors(), async (req, res) => {
const { id } = req.params;
const { query, planId } = req.body;
console.log('query: ', query);
posthog.capture({ event: 'user edited app with ai' });
try {
const app = await loadApp(id);
Expand Down
2 changes: 1 addition & 1 deletion packages/api/test/app-parser.test.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { parseProjectXML } from '../ai/app-parser.mjs';

describe('parseProjectXML', () => {
describe.skip('parseProjectXML', () => {
it('should correctly parse XML and return a Project object', () => {
const testXML = `
<project id="test-project">
Expand Down
13 changes: 12 additions & 1 deletion packages/api/test/plan-chunks-2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,16 @@
{"chunk":" icon: '🌊' },\n];"}
{"chunk":"\n ]]>\n </"}
{"chunk":"file>\n </"}
{"chunk":"action>\n</plan"}
{"chunk":"action>\n"}
{"chunk":"<action type=\"command\">\n"}
{"chunk":"<description>\n <![CDATA["}
{"chunk":"Install react-router"}
{"chunk":"\n ]]>"}
{"chunk":"</description> \n"}
{"chunk":"<commandType>npm install\n<"}
{"chunk": "/commandType>"}
{"chunk":"<package>react-router\n"}
{"chunk":" </package>\n "}
{"chunk":"</action> \n"}
{"chunk": "</plan"}
{"chunk":">"}
39 changes: 31 additions & 8 deletions packages/api/test/streaming-xml-parser.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('parsePlan', () => {
name: 'planDescription',
attributes: {},
content:
"\nUpdate the mock data to include classic rock bands in the trending albums section. I'll modify the albums data to include The Beatles, Talking Heads, Grateful Dead, and Radiohead with their iconic albums.\n ",
"Update the mock data to include classic rock bands in the trending albums section. I'll modify the albums data to include The Beatles, Talking Heads, Grateful Dead, and Radiohead with their iconic albums.",
children: [],
},
{
Expand All @@ -39,7 +39,7 @@ describe('parsePlan', () => {
{
name: 'description',
attributes: {},
content: '\nUpdate mock data with classic rock albums for the trending section\n ',
content: 'Update mock data with classic rock albums for the trending section',
children: [],
},
{
Expand Down Expand Up @@ -92,8 +92,7 @@ export const playlists: PlaylistItem[] = [
{ id: '2', name: 'Your Episodes', icon: '🎙️' },
{ id: '3', name: 'Rock Classics', icon: '🎸' },
{ id: '4', name: 'Chill Vibes', icon: '🌊' },
];
`,
];`.trim(),
children: [],
},
],
Expand All @@ -116,7 +115,7 @@ export const playlists: PlaylistItem[] = [
name: 'planDescription',
attributes: {},
content:
"\nI'll update the mock data to include Phish albums instead of the current albums. I'll use real Phish album covers and titles to make it more authentic.\n ",
"I'll update the mock data to include Phish albums instead of the current albums. I'll use real Phish album covers and titles to make it more authentic.",
children: [],
},
{
Expand All @@ -127,8 +126,7 @@ export const playlists: PlaylistItem[] = [
{
name: 'description',
attributes: {},
content:
'\nUpdate mockData.ts to include Phish albums with real album information\n ',
content: 'Update mockData.ts to include Phish albums with real album information',
children: [],
},
{
Expand Down Expand Up @@ -182,7 +180,32 @@ export const playlists: PlaylistItem[] = [
{ id: '3', name: 'Rock Classics', icon: '🎸' },
{ id: '4', name: 'Chill Vibes', icon: '🌊' },
];
`,
`.trim(),
children: [],
},
],
},
{
name: 'action',
attributes: { type: 'command' },
content: '',
children: [
{
name: 'description',
attributes: {},
content: 'Install react-router',
children: [],
},
{
name: 'commandType',
attributes: {},
content: 'npm install',
children: [],
},
{
name: 'package',
attributes: {},
content: 'react-router',
children: [],
},
],
Expand Down
52 changes: 27 additions & 25 deletions packages/web/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -553,34 +553,36 @@ export function ChatPanel(props: PropsType): React.JSX.Element {
}
}

// Write the changes
for (const update of fileUpdates) {
createFile(update.dirname, update.basename, update.modified);
}
if (fileUpdates.length > 0) {
// Write the changes
for (const update of fileUpdates) {
createFile(update.dirname, update.basename, update.modified);
}

// Create a new version
const version = await createVersion(`Changes for planId: ${planId}`);

const fileDiffs: FileDiffType[] = fileUpdates.map((file: FileType) => {
const { additions, deletions } = diffFiles(file.original ?? '', file.modified);
return {
modified: file.modified,
original: file.original,
basename: file.basename,
dirname: file.dirname,
path: file.path,
additions,
deletions,
type: file.original ? 'edit' : ('create' as 'edit' | 'create'),
};
});
// Create a new version
const version = await createVersion(`Changes for planId: ${planId}`);

const diffMessage = { type: 'diff', diff: fileDiffs, planId, version } as DiffMessageType;
setHistory((prevHistory) => [...prevHistory, diffMessage]);
appendToHistory(app.id, diffMessage);
const fileDiffs: FileDiffType[] = fileUpdates.map((file: FileType) => {
const { additions, deletions } = diffFiles(file.original ?? '', file.modified);
return {
modified: file.modified,
original: file.original,
basename: file.basename,
dirname: file.dirname,
path: file.path,
additions,
deletions,
type: file.original ? 'edit' : ('create' as 'edit' | 'create'),
};
});

setFileDiffs(fileDiffs);
setDiffApplied(true);
const diffMessage = { type: 'diff', diff: fileDiffs, planId, version } as DiffMessageType;
setHistory((prevHistory) => [...prevHistory, diffMessage]);
appendToHistory(app.id, diffMessage);

setFileDiffs(fileDiffs);
setDiffApplied(true);
}
setLoading(null);
};

Expand Down