Files
moreminimore-vibe/src/ipc/processors/response_processor.ts

450 lines
14 KiB
TypeScript

import { db } from "../../db";
import { chats, messages } from "../../db/schema";
import { and, eq } from "drizzle-orm";
import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths";
import path from "node:path";
import git from "isomorphic-git";
import { safeJoin } from "../utils/path_utils";
import log from "electron-log";
import { executeAddDependency } from "./executeAddDependency";
import {
deleteSupabaseFunction,
deploySupabaseFunctions,
executeSupabaseSql,
} from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils";
import { UserSettings } from "../../lib/schemas";
import { gitCommit } from "../utils/git_utils";
import { readSettings } from "@/main/settings";
import { writeMigrationFile } from "../utils/file_utils";
import {
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
getDyadAddDependencyTags,
getDyadExecuteSqlTags,
} from "../utils/dyad_tag_parser";
const readFile = fs.promises.readFile;
const logger = log.scope("response_processor");
interface Output {
message: string;
error: unknown;
}
function getFunctionNameFromPath(input: string): string {
return path.basename(path.extname(input) ? path.dirname(input) : input);
}
async function readFileFromFunctionPath(input: string): Promise<string> {
// Sometimes, the path given is a directory, sometimes it's the file itself.
if (path.extname(input) === "") {
return readFile(path.join(input, "index.ts"), "utf8");
}
return readFile(input, "utf8");
}
export async function processFullResponseActions(
fullResponse: string,
chatId: number,
{
chatSummary,
messageId,
}: { chatSummary: string | undefined; messageId: number },
): Promise<{
updatedFiles?: boolean;
error?: string;
extraFiles?: string[];
extraFilesError?: string;
}> {
logger.log("processFullResponseActions for chatId", chatId);
// Get the app associated with the chat
const chatWithApp = await db.query.chats.findFirst({
where: eq(chats.id, chatId),
with: {
app: true,
},
});
if (!chatWithApp || !chatWithApp.app) {
logger.error(`No app found for chat ID: ${chatId}`);
return {};
}
const settings: UserSettings = readSettings();
const appPath = getDyadAppPath(chatWithApp.app.path);
const writtenFiles: string[] = [];
const renamedFiles: string[] = [];
const deletedFiles: string[] = [];
let hasChanges = false;
const warnings: Output[] = [];
const errors: Output[] = [];
try {
// Extract all tags
const dyadWriteTags = getDyadWriteTags(fullResponse);
const dyadRenameTags = getDyadRenameTags(fullResponse);
const dyadDeletePaths = getDyadDeleteTags(fullResponse);
const dyadAddDependencyPackages = getDyadAddDependencyTags(fullResponse);
const dyadExecuteSqlQueries = chatWithApp.app.supabaseProjectId
? getDyadExecuteSqlTags(fullResponse)
: [];
let writtenSqlMigrationFiles = 0;
const message = await db.query.messages.findFirst({
where: and(
eq(messages.id, messageId),
eq(messages.role, "assistant"),
eq(messages.chatId, chatId),
),
});
if (!message) {
logger.error(`No message found for ID: ${messageId}`);
return {};
}
// Handle SQL execution tags
if (dyadExecuteSqlQueries.length > 0) {
for (const query of dyadExecuteSqlQueries) {
try {
await executeSupabaseSql({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
query: query.content,
});
// Only write migration file if SQL execution succeeded
if (settings.enableSupabaseWriteSqlMigration) {
try {
await writeMigrationFile(
appPath,
query.content,
query.description,
);
writtenSqlMigrationFiles++;
} catch (error) {
errors.push({
message: `Failed to write SQL migration file for: ${query.description}`,
error: error,
});
}
}
} catch (error) {
errors.push({
message: `Failed to execute SQL query: ${query.content}`,
error: error,
});
}
}
logger.log(`Executed ${dyadExecuteSqlQueries.length} SQL queries`);
}
// TODO: Handle add dependency tags
if (dyadAddDependencyPackages.length > 0) {
try {
await executeAddDependency({
packages: dyadAddDependencyPackages,
message: message,
appPath,
});
} catch (error) {
errors.push({
message: `Failed to add dependencies: ${dyadAddDependencyPackages.join(
", ",
)}`,
error: error,
});
}
writtenFiles.push("package.json");
const pnpmFilename = "pnpm-lock.yaml";
if (fs.existsSync(safeJoin(appPath, pnpmFilename))) {
writtenFiles.push(pnpmFilename);
}
const packageLockFilename = "package-lock.json";
if (fs.existsSync(safeJoin(appPath, packageLockFilename))) {
writtenFiles.push(packageLockFilename);
}
}
//////////////////////
// File operations //
// Do it in this order:
// 1. Deletes
// 2. Renames
// 3. Writes
//
// Why?
// - Deleting first avoids path conflicts before the other operations.
// - LLMs like to rename and then edit the same file.
//////////////////////
// Process all file deletions
for (const filePath of dyadDeletePaths) {
const fullFilePath = safeJoin(appPath, filePath);
// Delete the file if it exists
if (fs.existsSync(fullFilePath)) {
if (fs.lstatSync(fullFilePath).isDirectory()) {
fs.rmdirSync(fullFilePath, { recursive: true });
} else {
fs.unlinkSync(fullFilePath);
}
logger.log(`Successfully deleted file: ${fullFilePath}`);
deletedFiles.push(filePath);
// Remove the file from git
try {
await git.remove({
fs,
dir: appPath,
filepath: filePath,
});
} catch (error) {
logger.warn(`Failed to git remove deleted file ${filePath}:`, error);
// Continue even if remove fails as the file was still deleted
}
} else {
logger.warn(`File to delete does not exist: ${fullFilePath}`);
}
if (isServerFunction(filePath)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(filePath),
});
} catch (error) {
errors.push({
message: `Failed to delete Supabase function: ${filePath}`,
error: error,
});
}
}
}
// Process all file renames
for (const tag of dyadRenameTags) {
const fromPath = safeJoin(appPath, tag.from);
const toPath = safeJoin(appPath, tag.to);
// Ensure target directory exists
const dirPath = path.dirname(toPath);
fs.mkdirSync(dirPath, { recursive: true });
// Rename the file
if (fs.existsSync(fromPath)) {
fs.renameSync(fromPath, toPath);
logger.log(`Successfully renamed file: ${fromPath} -> ${toPath}`);
renamedFiles.push(tag.to);
// Add the new file and remove the old one from git
await git.add({
fs,
dir: appPath,
filepath: tag.to,
});
try {
await git.remove({
fs,
dir: appPath,
filepath: tag.from,
});
} catch (error) {
logger.warn(`Failed to git remove old file ${tag.from}:`, error);
// Continue even if remove fails as the file was still renamed
}
} else {
logger.warn(`Source file for rename does not exist: ${fromPath}`);
}
if (isServerFunction(tag.from)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.from),
});
} catch (error) {
warnings.push({
message: `Failed to delete Supabase function: ${tag.from} as part of renaming ${tag.from} to ${tag.to}`,
error: error,
});
}
}
if (isServerFunction(tag.to)) {
try {
await deploySupabaseFunctions({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.to),
content: await readFileFromFunctionPath(toPath),
});
} catch (error) {
errors.push({
message: `Failed to deploy Supabase function: ${tag.to} as part of renaming ${tag.from} to ${tag.to}`,
error: error,
});
}
}
}
// Process all file writes
for (const tag of dyadWriteTags) {
const filePath = tag.path;
const content = tag.content;
const fullFilePath = safeJoin(appPath, filePath);
// Ensure directory exists
const dirPath = path.dirname(fullFilePath);
fs.mkdirSync(dirPath, { recursive: true });
// Write file content
fs.writeFileSync(fullFilePath, content);
logger.log(`Successfully wrote file: ${fullFilePath}`);
writtenFiles.push(filePath);
if (isServerFunction(filePath)) {
try {
await deploySupabaseFunctions({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: path.basename(path.dirname(filePath)),
content: content,
});
} catch (error) {
errors.push({
message: `Failed to deploy Supabase function: ${filePath}`,
error: error,
});
}
}
}
// If we have any file changes, commit them all at once
hasChanges =
writtenFiles.length > 0 ||
renamedFiles.length > 0 ||
deletedFiles.length > 0 ||
dyadAddDependencyPackages.length > 0 ||
writtenSqlMigrationFiles > 0;
let uncommittedFiles: string[] = [];
let extraFilesError: string | undefined;
if (hasChanges) {
// Stage all written files
for (const file of writtenFiles) {
await git.add({
fs,
dir: appPath,
filepath: file,
});
}
// Create commit with details of all changes
const changes = [];
if (writtenFiles.length > 0)
changes.push(`wrote ${writtenFiles.length} file(s)`);
if (renamedFiles.length > 0)
changes.push(`renamed ${renamedFiles.length} file(s)`);
if (deletedFiles.length > 0)
changes.push(`deleted ${deletedFiles.length} file(s)`);
if (dyadAddDependencyPackages.length > 0)
changes.push(
`added ${dyadAddDependencyPackages.join(", ")} package(s)`,
);
if (dyadExecuteSqlQueries.length > 0)
changes.push(`executed ${dyadExecuteSqlQueries.length} SQL queries`);
let message = chatSummary
? `[dyad] ${chatSummary} - ${changes.join(", ")}`
: `[dyad] ${changes.join(", ")}`;
// Use chat summary, if provided, or default for commit message
let commitHash = await gitCommit({
path: appPath,
message,
});
logger.log(`Successfully committed changes: ${changes.join(", ")}`);
// Check for any uncommitted changes after the commit
const statusMatrix = await git.statusMatrix({ fs, dir: appPath });
uncommittedFiles = statusMatrix
.filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1)
.map((row) => row[0]); // Get just the file paths
if (uncommittedFiles.length > 0) {
// Stage all changes
await git.add({
fs,
dir: appPath,
filepath: ".",
});
try {
commitHash = await gitCommit({
path: appPath,
message: message + " + extra files edited outside of Dyad",
amend: true,
});
logger.log(
`Amend commit with changes outside of dyad: ${uncommittedFiles.join(", ")}`,
);
} catch (error) {
// Just log, but don't throw an error because the user can still
// commit these changes outside of Dyad if needed.
logger.error(
`Failed to commit changes outside of dyad: ${uncommittedFiles.join(
", ",
)}`,
);
extraFilesError = (error as any).toString();
}
}
// Save the commit hash to the message
await db
.update(messages)
.set({
commitHash: commitHash,
})
.where(eq(messages.id, messageId));
}
logger.log("mark as approved: hasChanges", hasChanges);
// Update the message to approved
await db
.update(messages)
.set({
approvalState: "approved",
})
.where(eq(messages.id, messageId));
return {
updatedFiles: hasChanges,
extraFiles: uncommittedFiles.length > 0 ? uncommittedFiles : undefined,
extraFilesError,
};
} catch (error: unknown) {
logger.error("Error processing files:", error);
return { error: (error as any).toString() };
} finally {
const appendedContent = `
${warnings
.map(
(warning) =>
`<dyad-output type="warning" message="${warning.message}">${warning.error}</dyad-output>`,
)
.join("\n")}
${errors
.map(
(error) =>
`<dyad-output type="error" message="${error.message}">${error.error}</dyad-output>`,
)
.join("\n")}
`;
if (appendedContent.length > 0) {
await db
.update(messages)
.set({
content: fullResponse + "\n\n" + appendedContent,
})
.where(eq(messages.id, messageId));
}
}
}