Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
nichochar authored Nov 1, 2024
2 parents 8e3c161 + d03eadc commit 09ae401
Show file tree
Hide file tree
Showing 13 changed files with 1,029 additions and 90 deletions.
42 changes: 26 additions & 16 deletions packages/api/ai/generate.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { generateText, type GenerateTextResult } from 'ai';
import { streamText, generateText, type GenerateTextResult } from 'ai';
import { getModel } from './config.mjs';
import {
type CodeLanguageType,
Expand All @@ -13,7 +13,7 @@ import Path from 'node:path';
import { PROMPTS_DIR } from '../constants.mjs';
import { encode, decodeCells } from '../srcmd.mjs';
import { buildProjectXml, type FileContent } from '../ai/app-parser.mjs';
import { type AppGenerationLog, logAppGeneration } from './logger.mjs';
import { logAppGeneration } from './logger.mjs';

const makeGenerateSrcbookSystemPrompt = () => {
return readFileSync(Path.join(PROMPTS_DIR, 'srcbook-generator.txt'), 'utf-8');
Expand Down Expand Up @@ -259,30 +259,40 @@ export async function generateApp(
return result.text;
}

export async function editApp(
export async function streamEditApp(
projectId: string,
files: FileContent[],
query: string,
appId: string,
planId: string,
): Promise<string> {
) {
const model = await getModel();

const systemPrompt = makeAppEditorSystemPrompt();
const userPrompt = makeAppEditorUserPrompt(projectId, files, query);
const result = await generateText({

let response = '';

const result = await streamText({
model,
system: systemPrompt,
prompt: userPrompt,
onChunk: (chunk) => {
if (chunk.chunk.type === 'text-delta') {
response += chunk.chunk.textDelta;
}
},
onFinish: () => {
if (process.env.SRCBOOK_DISABLE_ANALYTICS !== 'true') {
logAppGeneration({
appId,
planId,
llm_request: { model, system: systemPrompt, prompt: userPrompt },
llm_response: response,
});
}
},
});
const log: AppGenerationLog = {
appId,
planId,
llm_request: { model, system: systemPrompt, prompt: userPrompt },
llm_response: result,
};

if (process.env.SRCBOOK_DISABLE_ANALYTICS !== 'true') {
logAppGeneration(log);
}
return result.text;

return result.textStream;
}
115 changes: 115 additions & 0 deletions packages/api/ai/plan-parser.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { XMLParser } from 'fast-xml-parser';
import Path from 'node:path';
import { type App as DBAppType } from '../db/schema.mjs';
import { loadFile } from '../apps/disk.mjs';
import { StreamingXMLParser, TagType } from './stream-xml-parser.mjs';
import { ActionChunkType, DescriptionChunkType } from '@srcbook/shared';

// The ai proposes a plan that we expect to contain both files and commands
// Here is an example of a plan:
Expand Down Expand Up @@ -167,3 +169,116 @@ export function getPackagesToInstall(plan: Plan): string[] {
)
.flatMap((action) => action.packages);
}
export async function streamParsePlan(
stream: AsyncIterable<string>,
app: DBAppType,
_query: string,
planId: string,
) {
let parser: StreamingXMLParser;
const parsePromises: Promise<void>[] = [];

return new ReadableStream({
async pull(controller) {
if (parser === undefined) {
parser = new StreamingXMLParser({
async onTag(tag) {
if (tag.name === 'planDescription' || tag.name === 'action') {
const promise = (async () => {
const chunk = await toStreamingChunk(app, tag, planId);
if (chunk) {
controller.enqueue(JSON.stringify(chunk) + '\n');
}
})();
parsePromises.push(promise);
}
},
});
}

try {
for await (const chunk of stream) {
parser.parse(chunk);
}
// Wait for all pending parse operations to complete before closing
await Promise.all(parsePromises);
controller.close();
} catch (error) {
console.error(error);
controller.enqueue(
JSON.stringify({
type: 'error',
data: { content: 'Error while parsing streaming response' },
}) + '\n',
);
controller.error(error);
}
},
});
}

async function toStreamingChunk(
app: DBAppType,
tag: TagType,
planId: string,
): Promise<DescriptionChunkType | ActionChunkType | null> {
switch (tag.name) {
case 'planDescription':
return {
type: 'description',
planId: planId,
data: { content: tag.content },
} as DescriptionChunkType;
case 'action': {
const descriptionTag = tag.children.find((t) => t.name === 'description');
const description = descriptionTag?.content ?? '';
const type = tag.attributes.type;

if (type === 'file') {
const fileTag = tag.children.find((t) => t.name === 'file')!;

const filePath = fileTag.attributes.filename as string;
let originalContent = null;

try {
const fileContent = await loadFile(app, filePath);
originalContent = fileContent.source;
} catch (error) {
// If the file doesn't exist, it's likely that it's a new file.
}

return {
type: 'action',
planId: planId,
data: {
type: 'file',
description,
path: filePath,
dirname: Path.dirname(filePath),
basename: Path.basename(filePath),
modified: fileTag.content,
original: originalContent,
},
} as ActionChunkType;
} else if (type === 'command') {
const commandTag = tag.children.find((t) => t.name === 'commandType')!;
const packageTags = tag.children.filter((t) => t.name === 'package');

return {
type: 'action',
planId: planId,
data: {
type: 'command',
description,
command: commandTag.content,
packages: packageTags.map((t) => t.content),
},
} as ActionChunkType;
} else {
return null;
}
}
default:
return null;
}
}
Loading

0 comments on commit 09ae401

Please sign in to comment.