450 lines
14 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
}
|