Initial open-source release
This commit is contained in:
915
src/ipc/handlers/app_handlers.ts
Normal file
915
src/ipc/handlers/app_handlers.ts
Normal file
@@ -0,0 +1,915 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { db, getDatabasePath } from "../../db";
|
||||
import { apps, chats } from "../../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { App, CreateAppParams, Version } from "../ipc_types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
|
||||
import { spawn } from "node:child_process";
|
||||
import git from "isomorphic-git";
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
import { extractCodebase } from "../../utils/codebase";
|
||||
// Import our utility modules
|
||||
import { withLock } from "../utils/lock_utils";
|
||||
import {
|
||||
copyDirectoryRecursive,
|
||||
getFilesRecursively,
|
||||
} from "../utils/file_utils";
|
||||
import {
|
||||
runningApps,
|
||||
processCounter,
|
||||
killProcess,
|
||||
removeAppIfCurrentProcess,
|
||||
RunningAppInfo,
|
||||
} from "../utils/process_manager";
|
||||
import { ALLOWED_ENV_VARS } from "../../constants/models";
|
||||
|
||||
export function registerAppHandlers() {
|
||||
ipcMain.handle("create-app", async (_, params: CreateAppParams) => {
|
||||
const appPath = params.name;
|
||||
const fullAppPath = getDyadAppPath(appPath);
|
||||
if (fs.existsSync(fullAppPath)) {
|
||||
throw new Error(`App already exists at: ${fullAppPath}`);
|
||||
}
|
||||
// Create a new app
|
||||
const [app] = await db
|
||||
.insert(apps)
|
||||
.values({
|
||||
name: params.name,
|
||||
// Use the name as the path for now
|
||||
path: appPath,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create an initial chat for this app
|
||||
const [chat] = await db
|
||||
.insert(chats)
|
||||
.values({
|
||||
appId: app.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Start async operations in background
|
||||
try {
|
||||
// Copy scaffold asynchronously
|
||||
await copyDirectoryRecursive(
|
||||
path.join(__dirname, "..", "..", "scaffold"),
|
||||
fullAppPath
|
||||
);
|
||||
// Initialize git repo and create first commit
|
||||
await git.init({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
defaultBranch: "main",
|
||||
});
|
||||
|
||||
// Stage all files
|
||||
await git.add({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
filepath: ".",
|
||||
});
|
||||
|
||||
// Create initial commit
|
||||
await git.commit({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
message: "Init from react vite template",
|
||||
author: {
|
||||
name: "Dyad",
|
||||
email: "dyad@example.com",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in background app initialization:", error);
|
||||
}
|
||||
// })();
|
||||
|
||||
return { app, chatId: chat.id };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-app", async (_, appId: number): Promise<App> => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
// Get app files
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
let files: string[] = [];
|
||||
|
||||
try {
|
||||
files = getFilesRecursively(appPath, appPath);
|
||||
} catch (error) {
|
||||
console.error(`Error reading files for app ${appId}:`, error);
|
||||
// Return app even if files couldn't be read
|
||||
}
|
||||
|
||||
return {
|
||||
...app,
|
||||
files,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("list-apps", async () => {
|
||||
const allApps = await db.query.apps.findMany({
|
||||
orderBy: [desc(apps.createdAt)],
|
||||
});
|
||||
return {
|
||||
apps: allApps,
|
||||
appBasePath: getDyadAppPath("$APP_BASE_PATH"),
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"read-app-file",
|
||||
async (_, { appId, filePath }: { appId: number; filePath: string }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const fullPath = path.join(appPath, filePath);
|
||||
|
||||
// Check if the path is within the app directory (security check)
|
||||
if (!fullPath.startsWith(appPath)) {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = fs.readFileSync(fullPath, "utf-8");
|
||||
return contents;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error reading file ${filePath} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error("Failed to read file");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("get-env-vars", async () => {
|
||||
const envVars: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
envVars[key] = process.env[key];
|
||||
}
|
||||
return envVars;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"run-app",
|
||||
async (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
{ appId }: { appId: number }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
// Check if app is already running
|
||||
if (runningApps.has(appId)) {
|
||||
console.debug(`App ${appId} is already running.`);
|
||||
// Potentially return the existing process info or confirm status
|
||||
return { success: true, message: "App already running." };
|
||||
}
|
||||
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
console.debug(`Starting app ${appId} in path ${app.path}`);
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
console.log("appPath-CWD", appPath);
|
||||
try {
|
||||
const process = spawn("npm install && npm run dev", [], {
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
stdio: "pipe", // Ensure stdio is piped so we can capture output/errors and detect close
|
||||
detached: false, // Ensure child process is attached to the main process lifecycle unless explicitly backgrounded
|
||||
});
|
||||
|
||||
// Check if process spawned correctly
|
||||
if (!process.pid) {
|
||||
// Attempt to capture any immediate errors if possible
|
||||
let errorOutput = "";
|
||||
process.stderr?.on("data", (data) => (errorOutput += data));
|
||||
await new Promise((resolve) => process.on("error", resolve)); // Wait for error event
|
||||
throw new Error(
|
||||
`Failed to spawn process for app ${appId}. Error: ${
|
||||
errorOutput || "Unknown spawn error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Increment the counter and store the process reference with its ID
|
||||
const currentProcessId = processCounter.increment();
|
||||
runningApps.set(appId, { process, processId: currentProcessId });
|
||||
|
||||
// Log output
|
||||
process.stdout?.on("data", (data) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) stdout: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stdout",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
console.error(
|
||||
`App ${appId} (PID: ${process.pid}) stderr: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stderr",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process exit/close
|
||||
process.on("close", (code, signal) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
});
|
||||
|
||||
// Handle errors during process lifecycle (e.g., command not found)
|
||||
process.on("error", (err) => {
|
||||
console.error(
|
||||
`Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
// Note: We don't throw here as the error is asynchronous. The caller got a success response already.
|
||||
// Consider adding ipcRenderer event emission to notify UI of the error.
|
||||
});
|
||||
|
||||
return { success: true, processId: currentProcessId };
|
||||
} catch (error: any) {
|
||||
console.error(`Error running app ${appId}:`, error);
|
||||
// Ensure cleanup if error happens during setup but before process events are handled
|
||||
if (
|
||||
runningApps.has(appId) &&
|
||||
runningApps.get(appId)?.processId === processCounter.value
|
||||
) {
|
||||
runningApps.delete(appId);
|
||||
}
|
||||
throw new Error(`Failed to run app ${appId}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("stop-app", async (_, { appId }: { appId: number }) => {
|
||||
console.log(
|
||||
`Attempting to stop app ${appId}. Current running apps: ${runningApps.size}`
|
||||
);
|
||||
// Use withLock to ensure atomicity of the stop operation
|
||||
return withLock(appId, async () => {
|
||||
const appInfo = runningApps.get(appId);
|
||||
|
||||
if (!appInfo) {
|
||||
console.log(
|
||||
`App ${appId} not found in running apps map. Assuming already stopped.`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "App not running or already stopped.",
|
||||
};
|
||||
}
|
||||
|
||||
const { process, processId } = appInfo;
|
||||
console.log(
|
||||
`Found running app ${appId} with processId ${processId} (PID: ${process.pid}). Attempting to stop.`
|
||||
);
|
||||
|
||||
// Check if the process is already exited or closed
|
||||
if (process.exitCode !== null || process.signalCode !== null) {
|
||||
console.log(
|
||||
`Process for app ${appId} (PID: ${process.pid}) already exited (code: ${process.exitCode}, signal: ${process.signalCode}). Cleaning up map.`
|
||||
);
|
||||
runningApps.delete(appId); // Ensure cleanup if somehow missed
|
||||
return { success: true, message: "Process already exited." };
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the killProcess utility to stop the process
|
||||
await killProcess(process);
|
||||
|
||||
// Now, safely remove the app from the map *after* confirming closure
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error stopping app ${appId} (PID: ${process.pid}, processId: ${processId}):`,
|
||||
error
|
||||
);
|
||||
// Attempt cleanup even if an error occurred during the stop process
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
throw new Error(`Failed to stop app ${appId}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"restart-app",
|
||||
async (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
{ appId }: { appId: number }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
try {
|
||||
// First stop the app if it's running
|
||||
const appInfo = runningApps.get(appId);
|
||||
if (appInfo) {
|
||||
const { process, processId } = appInfo;
|
||||
console.log(
|
||||
`Stopping app ${appId} (processId ${processId}) before restart`
|
||||
);
|
||||
|
||||
// Use the killProcess utility to stop the process
|
||||
await killProcess(process);
|
||||
|
||||
// Remove from running apps
|
||||
runningApps.delete(appId);
|
||||
}
|
||||
|
||||
// Now start the app again
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
console.debug(`Starting app ${appId} in path ${app.path}`);
|
||||
|
||||
const process = spawn("npm install && npm run dev", [], {
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
stdio: "pipe",
|
||||
detached: false,
|
||||
});
|
||||
|
||||
if (!process.pid) {
|
||||
let errorOutput = "";
|
||||
process.stderr?.on("data", (data) => (errorOutput += data));
|
||||
await new Promise((resolve) => process.on("error", resolve));
|
||||
throw new Error(
|
||||
`Failed to spawn process for app ${appId}. Error: ${
|
||||
errorOutput || "Unknown spawn error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const currentProcessId = processCounter.increment();
|
||||
runningApps.set(appId, { process, processId: currentProcessId });
|
||||
|
||||
// Set up output handlers
|
||||
process.stdout?.on("data", (data) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) stdout: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stdout",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
console.error(
|
||||
`App ${appId} (PID: ${process.pid}) stderr: ${data
|
||||
.toString()
|
||||
.trim()}`
|
||||
);
|
||||
event.sender.send("app:output", {
|
||||
type: "stderr",
|
||||
message: data.toString().trim(),
|
||||
appId: appId,
|
||||
});
|
||||
});
|
||||
|
||||
process.on("close", (code, signal) => {
|
||||
console.log(
|
||||
`App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
});
|
||||
|
||||
process.on("error", (err) => {
|
||||
console.error(
|
||||
`Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
});
|
||||
|
||||
return { success: true, processId: currentProcessId };
|
||||
} catch (error) {
|
||||
console.error(`Error restarting app ${appId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("list-versions", async (_, { appId }: { appId: number }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
// Just return an empty array if the app is not a git repo.
|
||||
if (!fs.existsSync(path.join(appPath, ".git"))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const commits = await git.log({
|
||||
fs,
|
||||
dir: appPath,
|
||||
depth: 1000, // Limit to last 1000 commits for performance
|
||||
});
|
||||
|
||||
return commits.map((commit) => ({
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message,
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
})) satisfies Version[];
|
||||
} catch (error: any) {
|
||||
console.error(`Error listing versions for app ${appId}:`, error);
|
||||
throw new Error(`Failed to list versions: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"revert-version",
|
||||
async (
|
||||
_,
|
||||
{ appId, previousVersionId }: { appId: number; previousVersionId: string }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
try {
|
||||
await git.checkout({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: "main",
|
||||
force: true,
|
||||
});
|
||||
// Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory
|
||||
const matrix = await git.statusMatrix({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: previousVersionId,
|
||||
});
|
||||
|
||||
// Process each file to revert to the state in previousVersionId
|
||||
for (const [
|
||||
filepath,
|
||||
headStatus,
|
||||
workdirStatus,
|
||||
stageStatus,
|
||||
] of matrix) {
|
||||
const fullPath = path.join(appPath, filepath);
|
||||
|
||||
// If file exists in HEAD (previous version)
|
||||
if (headStatus === 1) {
|
||||
// If file doesn't exist or has changed in working directory, restore it from the target commit
|
||||
if (workdirStatus !== 1) {
|
||||
const { blob } = await git.readBlob({
|
||||
fs,
|
||||
dir: appPath,
|
||||
oid: previousVersionId,
|
||||
filepath,
|
||||
});
|
||||
await fsPromises.mkdir(path.dirname(fullPath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fsPromises.writeFile(fullPath, Buffer.from(blob));
|
||||
}
|
||||
}
|
||||
// If file doesn't exist in HEAD but exists in working directory, delete it
|
||||
else if (headStatus === 0 && workdirStatus !== 0) {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
await fsPromises.unlink(fullPath);
|
||||
await git.remove({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: filepath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: ".",
|
||||
});
|
||||
|
||||
// Create a revert commit
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: appPath,
|
||||
message: `Reverted all changes back to version ${previousVersionId}`,
|
||||
author: {
|
||||
name: "Dyad",
|
||||
email: "hi@dyad.sh",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error reverting to version ${previousVersionId} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to revert version: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"checkout-version",
|
||||
async (_, { appId, versionId }: { appId: number; versionId: string }) => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
try {
|
||||
if (versionId !== "main") {
|
||||
// First check if the version exists
|
||||
const commits = await git.log({
|
||||
fs,
|
||||
dir: appPath,
|
||||
depth: 100,
|
||||
});
|
||||
|
||||
const targetCommit = commits.find((c) => c.oid === versionId);
|
||||
if (!targetCommit) {
|
||||
throw new Error("Target version not found");
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout the target commit
|
||||
await git.checkout({
|
||||
fs,
|
||||
dir: appPath,
|
||||
ref: versionId,
|
||||
force: true,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error checking out version ${versionId} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to checkout version: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Extract codebase information
|
||||
ipcMain.handle(
|
||||
"extract-codebase",
|
||||
async (_, { appId, maxFiles }: { appId: number; maxFiles?: number }) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
try {
|
||||
return await extractCodebase(appPath, maxFiles);
|
||||
} catch (error) {
|
||||
console.error(`Error extracting codebase for app ${appId}:`, error);
|
||||
throw new Error(
|
||||
`Failed to extract codebase: ${(error as any).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"edit-app-file",
|
||||
async (
|
||||
_,
|
||||
{
|
||||
appId,
|
||||
filePath,
|
||||
content,
|
||||
}: { appId: number; filePath: string; content: string }
|
||||
) => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
const fullPath = path.join(appPath, filePath);
|
||||
|
||||
// Check if the path is within the app directory (security check)
|
||||
if (!fullPath.startsWith(appPath)) {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = path.dirname(fullPath);
|
||||
await fsPromises.mkdir(dirPath, { recursive: true });
|
||||
|
||||
try {
|
||||
await fsPromises.writeFile(fullPath, content, "utf-8");
|
||||
|
||||
// Check if git repository exists and commit the change
|
||||
if (fs.existsSync(path.join(appPath, ".git"))) {
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: filePath,
|
||||
});
|
||||
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: appPath,
|
||||
message: `Updated ${filePath}`,
|
||||
author: {
|
||||
name: "Dyad",
|
||||
email: "hi@dyad.sh",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error writing file ${filePath} for app ${appId}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to write file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("delete-app", async (_, { appId }: { appId: number }) => {
|
||||
return withLock(appId, async () => {
|
||||
// Check if app exists
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
// Stop the app if it's running
|
||||
if (runningApps.has(appId)) {
|
||||
const appInfo = runningApps.get(appId)!;
|
||||
try {
|
||||
await killProcess(appInfo.process);
|
||||
runningApps.delete(appId);
|
||||
} catch (error: any) {
|
||||
console.error(`Error stopping app ${appId} before deletion:`, error);
|
||||
// Continue with deletion even if stopping fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete app files
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
try {
|
||||
await fsPromises.rm(appPath, { recursive: true, force: true });
|
||||
} catch (error: any) {
|
||||
console.error(`Error deleting app files for app ${appId}:`, error);
|
||||
throw new Error(`Failed to delete app files: ${error.message}`);
|
||||
}
|
||||
|
||||
// Delete app from database
|
||||
try {
|
||||
await db.delete(apps).where(eq(apps.id, appId));
|
||||
// Note: Associated chats will cascade delete if that's set up in the schema
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error(`Error deleting app ${appId} from database:`, error);
|
||||
throw new Error(`Failed to delete app from database: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"rename-app",
|
||||
async (
|
||||
_,
|
||||
{
|
||||
appId,
|
||||
appName,
|
||||
appPath,
|
||||
}: { appId: number; appName: string; appPath: string }
|
||||
) => {
|
||||
return withLock(appId, async () => {
|
||||
// Check if app exists
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error("App not found");
|
||||
}
|
||||
|
||||
// Check for conflicts with existing apps
|
||||
const nameConflict = await db.query.apps.findFirst({
|
||||
where: eq(apps.name, appName),
|
||||
});
|
||||
|
||||
const pathConflict = await db.query.apps.findFirst({
|
||||
where: eq(apps.path, appPath),
|
||||
});
|
||||
|
||||
if (nameConflict && nameConflict.id !== appId) {
|
||||
throw new Error(`An app with the name '${appName}' already exists`);
|
||||
}
|
||||
|
||||
if (pathConflict && pathConflict.id !== appId) {
|
||||
throw new Error(`An app with the path '${appPath}' already exists`);
|
||||
}
|
||||
|
||||
// Stop the app if it's running
|
||||
if (runningApps.has(appId)) {
|
||||
const appInfo = runningApps.get(appId)!;
|
||||
try {
|
||||
await killProcess(appInfo.process);
|
||||
runningApps.delete(appId);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error stopping app ${appId} before renaming:`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to stop app before renaming: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const oldAppPath = getDyadAppPath(app.path);
|
||||
const newAppPath = getDyadAppPath(appPath);
|
||||
// Only move files if needed
|
||||
if (newAppPath !== oldAppPath) {
|
||||
// Move app files
|
||||
try {
|
||||
// Check if destination directory already exists
|
||||
if (fs.existsSync(newAppPath)) {
|
||||
throw new Error(
|
||||
`Destination path '${newAppPath}' already exists`
|
||||
);
|
||||
}
|
||||
|
||||
// Create parent directory if it doesn't exist
|
||||
await fsPromises.mkdir(path.dirname(newAppPath), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Move the files
|
||||
await fsPromises.rename(oldAppPath, newAppPath);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`Error moving app files from ${oldAppPath} to ${newAppPath}:`,
|
||||
error
|
||||
);
|
||||
throw new Error(`Failed to move app files: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update app in database
|
||||
try {
|
||||
const [updatedApp] = await db
|
||||
.update(apps)
|
||||
.set({
|
||||
name: appName,
|
||||
path: appPath,
|
||||
})
|
||||
.where(eq(apps.id, appId))
|
||||
.returning();
|
||||
|
||||
return { success: true, app: updatedApp };
|
||||
} catch (error: any) {
|
||||
// Attempt to rollback the file move
|
||||
if (newAppPath !== oldAppPath) {
|
||||
try {
|
||||
await fsPromises.rename(newAppPath, oldAppPath);
|
||||
} catch (rollbackError) {
|
||||
console.error(
|
||||
`Failed to rollback file move during rename error:`,
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Error updating app ${appId} in database:`, error);
|
||||
throw new Error(`Failed to update app in database: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("reset-all", async () => {
|
||||
// Stop all running apps first
|
||||
const runningAppIds = Array.from(runningApps.keys());
|
||||
for (const appId of runningAppIds) {
|
||||
try {
|
||||
const appInfo = runningApps.get(appId)!;
|
||||
await killProcess(appInfo.process);
|
||||
runningApps.delete(appId);
|
||||
} catch (error) {
|
||||
console.error(`Error stopping app ${appId} during reset:`, error);
|
||||
// Continue with reset even if stopping fails
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Remove all app files recursively
|
||||
const dyadAppPath = getDyadAppPath(".");
|
||||
if (fs.existsSync(dyadAppPath)) {
|
||||
await fsPromises.rm(dyadAppPath, { recursive: true, force: true });
|
||||
// Recreate the base directory
|
||||
await fsPromises.mkdir(dyadAppPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Drop the database by deleting the SQLite file
|
||||
const dbPath = getDatabasePath();
|
||||
if (fs.existsSync(dbPath)) {
|
||||
// Close database connections first
|
||||
if (db.$client) {
|
||||
db.$client.close();
|
||||
}
|
||||
await fsPromises.unlink(dbPath);
|
||||
console.log(`Database file deleted: ${dbPath}`);
|
||||
}
|
||||
|
||||
// 3. Remove settings
|
||||
const userDataPath = getUserDataPath();
|
||||
const settingsPath = path.join(userDataPath, "user-settings.json");
|
||||
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
await fsPromises.unlink(settingsPath);
|
||||
console.log(`Settings file deleted: ${settingsPath}`);
|
||||
}
|
||||
|
||||
return { success: true, message: "Successfully reset everything" };
|
||||
});
|
||||
}
|
||||
66
src/ipc/handlers/chat_handlers.ts
Normal file
66
src/ipc/handlers/chat_handlers.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { db } from "../../db";
|
||||
import { chats } from "../../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { ChatSummary } from "../../lib/schemas";
|
||||
|
||||
export function registerChatHandlers() {
|
||||
ipcMain.handle("create-chat", async (_, appId: number) => {
|
||||
// Create a new chat
|
||||
const [chat] = await db
|
||||
.insert(chats)
|
||||
.values({
|
||||
appId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return chat.id;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-chat", async (_, chatId: number) => {
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, chatId),
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
return chat;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"get-chats",
|
||||
async (_, appId?: number): Promise<ChatSummary[]> => {
|
||||
// If appId is provided, filter chats for that app
|
||||
const query = appId
|
||||
? db.query.chats.findMany({
|
||||
where: eq(chats.appId, appId),
|
||||
columns: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
appId: true,
|
||||
},
|
||||
orderBy: [desc(chats.createdAt)],
|
||||
})
|
||||
: db.query.chats.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
appId: true,
|
||||
},
|
||||
orderBy: [desc(chats.createdAt)],
|
||||
});
|
||||
|
||||
const allChats = await query;
|
||||
return allChats;
|
||||
}
|
||||
);
|
||||
}
|
||||
323
src/ipc/handlers/chat_stream_handlers.ts
Normal file
323
src/ipc/handlers/chat_stream_handlers.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { streamText } from "ai";
|
||||
import { db } from "../../db";
|
||||
import { chats, messages } from "../../db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { SYSTEM_PROMPT } from "../../prompts/system_prompt";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { readSettings } from "../../main/settings";
|
||||
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
|
||||
import { extractCodebase } from "../../utils/codebase";
|
||||
import { processFullResponseActions } from "../processors/response_processor";
|
||||
import { streamTestResponse } from "./testing_chat_handlers";
|
||||
import { getTestResponse } from "./testing_chat_handlers";
|
||||
import { getModelClient } from "../utils/get_model_client";
|
||||
|
||||
// Track active streams for cancellation
|
||||
const activeStreams = new Map<number, AbortController>();
|
||||
|
||||
// Track partial responses for cancelled streams
|
||||
const partialResponses = new Map<number, string>();
|
||||
|
||||
export function registerChatStreamHandlers() {
|
||||
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
|
||||
try {
|
||||
// Create an AbortController for this stream
|
||||
const abortController = new AbortController();
|
||||
activeStreams.set(req.chatId, abortController);
|
||||
|
||||
// Get the chat to check for existing messages
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, req.chatId),
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
|
||||
},
|
||||
app: true, // Include app information
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new Error(`Chat not found: ${req.chatId}`);
|
||||
}
|
||||
|
||||
// Handle redo option: remove the most recent messages if needed
|
||||
if (req.redo) {
|
||||
// Get the most recent messages
|
||||
const chatMessages = [...chat.messages];
|
||||
|
||||
// Find the most recent user message
|
||||
let lastUserMessageIndex = chatMessages.length - 1;
|
||||
while (
|
||||
lastUserMessageIndex >= 0 &&
|
||||
chatMessages[lastUserMessageIndex].role !== "user"
|
||||
) {
|
||||
lastUserMessageIndex--;
|
||||
}
|
||||
|
||||
if (lastUserMessageIndex >= 0) {
|
||||
// Delete the user message
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(eq(messages.id, chatMessages[lastUserMessageIndex].id));
|
||||
|
||||
// If there's an assistant message after the user message, delete it too
|
||||
if (
|
||||
lastUserMessageIndex < chatMessages.length - 1 &&
|
||||
chatMessages[lastUserMessageIndex + 1].role === "assistant"
|
||||
) {
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(
|
||||
eq(messages.id, chatMessages[lastUserMessageIndex + 1].id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to database
|
||||
await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
chatId: req.chatId,
|
||||
role: "user",
|
||||
content: req.prompt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Fetch updated chat data after possible deletions and additions
|
||||
const updatedChat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, req.chatId),
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
|
||||
},
|
||||
app: true, // Include app information
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedChat) {
|
||||
throw new Error(`Chat not found: ${req.chatId}`);
|
||||
}
|
||||
|
||||
let fullResponse = "";
|
||||
|
||||
// Check if this is a test prompt
|
||||
const testResponse = getTestResponse(req.prompt);
|
||||
|
||||
if (testResponse) {
|
||||
// For test prompts, use the dedicated function
|
||||
fullResponse = await streamTestResponse(
|
||||
event,
|
||||
req.chatId,
|
||||
testResponse,
|
||||
abortController,
|
||||
updatedChat
|
||||
);
|
||||
} else {
|
||||
// Normal AI processing for non-test prompts
|
||||
const settings = readSettings();
|
||||
const modelClient = getModelClient(settings.selectedModel, settings);
|
||||
|
||||
// Extract codebase information if app is associated with the chat
|
||||
let codebaseInfo = "";
|
||||
if (updatedChat.app) {
|
||||
const appPath = getDyadAppPath(updatedChat.app.path);
|
||||
try {
|
||||
codebaseInfo = await extractCodebase(appPath);
|
||||
console.log(`Extracted codebase information from ${appPath}`);
|
||||
} catch (error) {
|
||||
console.error("Error extracting codebase:", error);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
"codebaseInfo: length",
|
||||
codebaseInfo.length,
|
||||
"estimated tokens",
|
||||
codebaseInfo.length / 4
|
||||
);
|
||||
|
||||
// Append codebase information to the user's prompt if available
|
||||
const userPrompt = codebaseInfo
|
||||
? `${req.prompt}\n\nHere's the codebase:\n${codebaseInfo}`
|
||||
: req.prompt;
|
||||
|
||||
// Prepare message history for the AI
|
||||
const messageHistory = updatedChat.messages.map((message) => ({
|
||||
role: message.role as "user" | "assistant" | "system",
|
||||
content: message.content,
|
||||
}));
|
||||
|
||||
// Remove the last user message (we'll replace it with our enhanced version)
|
||||
if (
|
||||
messageHistory.length > 0 &&
|
||||
messageHistory[messageHistory.length - 1].role === "user"
|
||||
) {
|
||||
messageHistory.pop();
|
||||
}
|
||||
|
||||
const { textStream } = streamText({
|
||||
maxTokens: 8_000,
|
||||
model: modelClient,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [
|
||||
...messageHistory,
|
||||
// Add the enhanced user prompt
|
||||
{
|
||||
role: "user",
|
||||
content: userPrompt,
|
||||
},
|
||||
],
|
||||
onError: (error) => {
|
||||
console.error("Error streaming text:", error);
|
||||
const message =
|
||||
(error as any)?.error?.message || JSON.stringify(error);
|
||||
event.sender.send(
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error from the AI: ${message}`
|
||||
);
|
||||
// Clean up the abort controller
|
||||
activeStreams.delete(req.chatId);
|
||||
},
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
// Process the stream as before
|
||||
try {
|
||||
for await (const textPart of textStream) {
|
||||
fullResponse += textPart;
|
||||
// Store the current partial response
|
||||
partialResponses.set(req.chatId, fullResponse);
|
||||
|
||||
// Update the assistant message in the database
|
||||
event.sender.send("chat:response:chunk", {
|
||||
chatId: req.chatId,
|
||||
messages: [
|
||||
...updatedChat.messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: fullResponse,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// If the stream was aborted, exit early
|
||||
if (abortController.signal.aborted) {
|
||||
console.log(`Stream for chat ${req.chatId} was aborted`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
// Check if this was an abort error
|
||||
if (abortController.signal.aborted) {
|
||||
const chatId = req.chatId;
|
||||
const partialResponse = partialResponses.get(req.chatId);
|
||||
// If we have a partial response, save it to the database
|
||||
if (partialResponse) {
|
||||
try {
|
||||
// Insert a new assistant message with the partial content
|
||||
await db.insert(messages).values({
|
||||
chatId,
|
||||
role: "assistant",
|
||||
content: `${partialResponse}\n\n[Response cancelled by user]`,
|
||||
});
|
||||
console.log(`Saved partial response for chat ${chatId}`);
|
||||
partialResponses.delete(chatId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error saving partial response for chat ${chatId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return req.chatId;
|
||||
}
|
||||
throw streamError;
|
||||
}
|
||||
}
|
||||
|
||||
// Only save the response and process it if we weren't aborted
|
||||
if (!abortController.signal.aborted && fullResponse) {
|
||||
// Scrape from: <dyad-chat-summary>Renaming profile file</dyad-chat-title>
|
||||
const chatTitle = fullResponse.match(
|
||||
/<dyad-chat-summary>(.*?)<\/dyad-chat-summary>/
|
||||
);
|
||||
if (chatTitle) {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ title: chatTitle[1] })
|
||||
.where(and(eq(chats.id, req.chatId), isNull(chats.title)));
|
||||
}
|
||||
const chatSummary = chatTitle?.[1];
|
||||
|
||||
// Create initial assistant message
|
||||
const [assistantMessage] = await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
chatId: req.chatId,
|
||||
role: "assistant",
|
||||
content: fullResponse,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ content: fullResponse })
|
||||
.where(eq(messages.id, assistantMessage.id));
|
||||
|
||||
const status = await processFullResponseActions(
|
||||
fullResponse,
|
||||
req.chatId,
|
||||
{ chatSummary }
|
||||
);
|
||||
|
||||
if (status.error) {
|
||||
event.sender.send(
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error applying the AI's changes: ${status.error}`
|
||||
);
|
||||
}
|
||||
|
||||
// Signal that the stream has completed
|
||||
event.sender.send("chat:response:end", {
|
||||
chatId: req.chatId,
|
||||
updatedFiles: status.updatedFiles ?? false,
|
||||
} satisfies ChatResponseEnd);
|
||||
}
|
||||
|
||||
// Return the chat ID for backwards compatibility
|
||||
return req.chatId;
|
||||
} catch (error) {
|
||||
console.error("[MAIN] API error:", error);
|
||||
event.sender.send(
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error processing your request: ${error}`
|
||||
);
|
||||
// Clean up the abort controller
|
||||
activeStreams.delete(req.chatId);
|
||||
return "error";
|
||||
}
|
||||
});
|
||||
|
||||
// Handler to cancel an ongoing stream
|
||||
ipcMain.handle("chat:cancel", async (event, chatId: number) => {
|
||||
const abortController = activeStreams.get(chatId);
|
||||
|
||||
if (abortController) {
|
||||
// Abort the stream
|
||||
abortController.abort();
|
||||
activeStreams.delete(chatId);
|
||||
console.log(`Aborted stream for chat ${chatId}`);
|
||||
} else {
|
||||
console.warn(`No active stream found for chat ${chatId}`);
|
||||
}
|
||||
|
||||
// Send the end event to the renderer
|
||||
event.sender.send("chat:response:end", {
|
||||
chatId,
|
||||
updatedFiles: false,
|
||||
} satisfies ChatResponseEnd);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
126
src/ipc/handlers/dependency_handlers.ts
Normal file
126
src/ipc/handlers/dependency_handlers.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { db } from "../../db";
|
||||
import { messages, apps, chats } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { spawn } from "node:child_process";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
export function registerDependencyHandlers() {
|
||||
ipcMain.handle(
|
||||
"chat:add-dep",
|
||||
async (
|
||||
_event,
|
||||
{ chatId, packages }: { chatId: number; packages: string[] }
|
||||
) => {
|
||||
// Find the message from the database
|
||||
const foundMessages = await db.query.messages.findMany({
|
||||
where: eq(messages.chatId, chatId),
|
||||
});
|
||||
|
||||
// Find the chat first
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, chatId),
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new Error(`Chat ${chatId} not found`);
|
||||
}
|
||||
|
||||
// Get the app using the appId from the chat
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, chat.appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App for chat ${chatId} not found`);
|
||||
}
|
||||
|
||||
const message = [...foundMessages]
|
||||
.reverse()
|
||||
.find((m) =>
|
||||
m.content.includes(
|
||||
`<dyad-add-dependency packages="${packages.join(" ")}">`
|
||||
)
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
throw new Error(
|
||||
`Message with packages ${packages.join(", ")} not found`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the message content contains the dependency tag
|
||||
const dependencyTagRegex = new RegExp(
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">[^<]*</dyad-add-dependency>`,
|
||||
"g"
|
||||
);
|
||||
|
||||
if (!dependencyTagRegex.test(message.content)) {
|
||||
throw new Error(
|
||||
`Message doesn't contain the dependency tag for packages ${packages.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Execute npm install
|
||||
try {
|
||||
const { stdout, stderr } = await execPromise(
|
||||
`npm install ${packages.join(" ")}`,
|
||||
{
|
||||
cwd: getDyadAppPath(app.path),
|
||||
}
|
||||
);
|
||||
const installResults = stdout + (stderr ? `\n${stderr}` : "");
|
||||
|
||||
// Update the message content with the installation results
|
||||
const updatedContent = message.content.replace(
|
||||
new RegExp(
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">[^<]*</dyad-add-dependency>`,
|
||||
"g"
|
||||
),
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">${installResults}</dyad-add-dependency>`
|
||||
);
|
||||
|
||||
// Save the updated message back to the database
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ content: updatedContent })
|
||||
.where(eq(messages.id, message.id));
|
||||
|
||||
// Return undefined implicitly
|
||||
} catch (error: any) {
|
||||
// Update the message with the error
|
||||
const updatedContent = message.content.replace(
|
||||
new RegExp(
|
||||
`<dyad-add-dependency packages="${packages.join(
|
||||
" "
|
||||
)}">[^<]*</dyad-add-dependency>`,
|
||||
"g"
|
||||
),
|
||||
`<dyad-add-dependency packages="${packages.join(" ")}"><dyad-error>${
|
||||
error.message
|
||||
}</dyad-error></dyad-add-dependency>`
|
||||
);
|
||||
|
||||
// Save the updated message back to the database
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ content: updatedContent })
|
||||
.where(eq(messages.id, message.id));
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
44
src/ipc/handlers/settings_handlers.ts
Normal file
44
src/ipc/handlers/settings_handlers.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ipcMain } from "electron";
|
||||
import type { UserSettings } from "../../lib/schemas";
|
||||
import { writeSettings } from "../../main/settings";
|
||||
import { readSettings } from "../../main/settings";
|
||||
|
||||
export function registerSettingsHandlers() {
|
||||
ipcMain.handle("get-user-settings", async () => {
|
||||
const settings = await readSettings();
|
||||
|
||||
// Mask API keys before sending to renderer
|
||||
if (settings?.providerSettings) {
|
||||
// Use optional chaining
|
||||
for (const providerKey in settings.providerSettings) {
|
||||
// Ensure the key is own property and providerSetting exists
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
settings.providerSettings,
|
||||
providerKey
|
||||
)
|
||||
) {
|
||||
const providerSetting = settings.providerSettings[providerKey];
|
||||
// Check if apiKey exists and is a non-empty string before masking
|
||||
if (
|
||||
providerSetting?.apiKey &&
|
||||
typeof providerSetting.apiKey === "string" &&
|
||||
providerSetting.apiKey.length > 0
|
||||
) {
|
||||
providerSetting.apiKey = providerSetting.apiKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"set-user-settings",
|
||||
async (_, settings: Partial<UserSettings>) => {
|
||||
writeSettings(settings);
|
||||
return readSettings();
|
||||
}
|
||||
);
|
||||
}
|
||||
21
src/ipc/handlers/shell_handler.ts
Normal file
21
src/ipc/handlers/shell_handler.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ipcMain, shell } from "electron";
|
||||
|
||||
export function registerShellHandlers() {
|
||||
ipcMain.handle("open-external-url", async (_event, url: string) => {
|
||||
try {
|
||||
// Basic validation to ensure it's a http/https url
|
||||
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
}
|
||||
console.error("Attempted to open invalid or non-http URL:", url);
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid URL provided. Only http/https URLs are allowed.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to open external URL ${url}:`, error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
16
src/ipc/handlers/shell_handlers.ts
Normal file
16
src/ipc/handlers/shell_handlers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { shell } from "electron";
|
||||
|
||||
export async function handleShellOpenExternal(
|
||||
_event: IpcMainInvokeEvent,
|
||||
url: string
|
||||
): Promise<void> {
|
||||
// Basic validation to ensure it's likely a URL
|
||||
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
await shell.openExternal(url);
|
||||
} else {
|
||||
console.error(`Invalid URL attempt blocked: ${url}`);
|
||||
// Optionally, you could throw an error back to the renderer
|
||||
// throw new Error("Invalid or insecure URL provided.");
|
||||
}
|
||||
}
|
||||
83
src/ipc/handlers/testing_chat_handlers.ts
Normal file
83
src/ipc/handlers/testing_chat_handlers.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// e.g. [dyad-qa=add-dep]
|
||||
// Canned responses for test prompts
|
||||
const TEST_RESPONSES: Record<string, string> = {
|
||||
"add-dep": `I'll add that dependency for you.
|
||||
|
||||
<dyad-add-dependency packages="deno"></dyad-add-dependency>
|
||||
|
||||
EOM`,
|
||||
"add-non-existing-dep": `I'll add that dependency for you.
|
||||
|
||||
<dyad-add-dependency packages="@angular/does-not-exist"></dyad-add-dependency>
|
||||
|
||||
EOM`,
|
||||
"add-multiple-deps": `I'll add that dependency for you.
|
||||
|
||||
<dyad-add-dependency packages="react-router-dom react-query"></dyad-add-dependency>
|
||||
|
||||
EOM`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a prompt is a test prompt and returns the corresponding canned response
|
||||
* @param prompt The user prompt
|
||||
* @returns The canned response if it's a test prompt, null otherwise
|
||||
*/
|
||||
export function getTestResponse(prompt: string): string | null {
|
||||
const match = prompt.match(/\[dyad-qa=([^\]]+)\]/);
|
||||
if (match) {
|
||||
const testKey = match[1];
|
||||
return TEST_RESPONSES[testKey] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a canned test response to the client
|
||||
* @param event The IPC event
|
||||
* @param chatId The chat ID
|
||||
* @param testResponse The canned response to stream
|
||||
* @param abortController The abort controller for this stream
|
||||
* @param updatedChat The chat data with messages
|
||||
* @returns The full streamed response
|
||||
*/
|
||||
export async function streamTestResponse(
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
chatId: number,
|
||||
testResponse: string,
|
||||
abortController: AbortController,
|
||||
updatedChat: any
|
||||
): Promise<string> {
|
||||
console.log(`Using canned response for test prompt`);
|
||||
|
||||
// Simulate streaming by splitting the response into chunks
|
||||
const chunks = testResponse.split(" ");
|
||||
let fullResponse = "";
|
||||
|
||||
for (const chunk of chunks) {
|
||||
// Skip processing if aborted
|
||||
if (abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the word plus a space
|
||||
fullResponse += chunk + " ";
|
||||
|
||||
// Send the current accumulated response
|
||||
event.sender.send("chat:response:chunk", {
|
||||
chatId: chatId,
|
||||
messages: [
|
||||
...updatedChat.messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: fullResponse,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Add a small delay to simulate streaming
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
return fullResponse;
|
||||
}
|
||||
477
src/ipc/ipc_client.ts
Normal file
477
src/ipc/ipc_client.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import type { Message } from "ai";
|
||||
import type { IpcRenderer } from "electron";
|
||||
import {
|
||||
type ChatSummary,
|
||||
ChatSummariesSchema,
|
||||
type UserSettings,
|
||||
} from "../lib/schemas";
|
||||
import type {
|
||||
App,
|
||||
AppOutput,
|
||||
Chat,
|
||||
ChatResponseEnd,
|
||||
ChatStreamParams,
|
||||
CreateAppParams,
|
||||
CreateAppResult,
|
||||
ListAppsResponse,
|
||||
Version,
|
||||
} from "./ipc_types";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
export interface ChatStreamCallbacks {
|
||||
onUpdate: (messages: Message[]) => void;
|
||||
onEnd: (response: ChatResponseEnd) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface AppStreamCallbacks {
|
||||
onOutput: (output: AppOutput) => void;
|
||||
}
|
||||
|
||||
export class IpcClient {
|
||||
private static instance: IpcClient;
|
||||
private ipcRenderer: IpcRenderer;
|
||||
private chatStreams: Map<number, ChatStreamCallbacks>;
|
||||
private appStreams: Map<number, AppStreamCallbacks>;
|
||||
private constructor() {
|
||||
this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer;
|
||||
this.chatStreams = new Map();
|
||||
this.appStreams = new Map();
|
||||
// Set up listeners for stream events
|
||||
this.ipcRenderer.on("chat:response:chunk", (data) => {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"chatId" in data &&
|
||||
"messages" in data
|
||||
) {
|
||||
const { chatId, messages } = data as {
|
||||
chatId: number;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
const callbacks = this.chatStreams.get(chatId);
|
||||
if (callbacks) {
|
||||
callbacks.onUpdate(messages);
|
||||
} else {
|
||||
console.warn(
|
||||
`[IPC] No callbacks found for chat ${chatId}`,
|
||||
this.chatStreams
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(new Error(`[IPC] Invalid chunk data received: ${data}`));
|
||||
}
|
||||
});
|
||||
|
||||
this.ipcRenderer.on("app:output", (data) => {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"type" in data &&
|
||||
"message" in data &&
|
||||
"appId" in data
|
||||
) {
|
||||
const { type, message, appId } = data as AppOutput;
|
||||
const callbacks = this.appStreams.get(appId);
|
||||
if (callbacks) {
|
||||
callbacks.onOutput({ type, message, appId });
|
||||
}
|
||||
} else {
|
||||
showError(new Error(`[IPC] Invalid app output data received: ${data}`));
|
||||
}
|
||||
});
|
||||
|
||||
this.ipcRenderer.on("chat:response:end", (payload) => {
|
||||
const { chatId, updatedFiles } = payload as unknown as ChatResponseEnd;
|
||||
const callbacks = this.chatStreams.get(chatId);
|
||||
if (callbacks) {
|
||||
callbacks.onEnd({ chatId, updatedFiles });
|
||||
console.debug("chat:response:end");
|
||||
this.chatStreams.delete(chatId);
|
||||
} else {
|
||||
showError(
|
||||
new Error(`[IPC] No callbacks found for chat ${chatId} on stream end`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.ipcRenderer.on("chat:response:error", (error) => {
|
||||
console.debug("chat:response:error");
|
||||
if (typeof error === "string") {
|
||||
for (const [chatId, callbacks] of this.chatStreams.entries()) {
|
||||
callbacks.onError(error);
|
||||
this.chatStreams.delete(chatId);
|
||||
}
|
||||
} else {
|
||||
console.error("[IPC] Invalid error data received:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance(): IpcClient {
|
||||
if (!IpcClient.instance) {
|
||||
IpcClient.instance = new IpcClient();
|
||||
}
|
||||
return IpcClient.instance;
|
||||
}
|
||||
|
||||
// Create a new app with an initial chat
|
||||
public async createApp(params: CreateAppParams): Promise<CreateAppResult> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("create-app", params);
|
||||
return result as CreateAppResult;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getApp(appId: number): Promise<App> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-app", appId);
|
||||
return data;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getChat(chatId: number): Promise<Chat> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-chat", chatId);
|
||||
return data;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all chats
|
||||
public async getChats(appId?: number): Promise<ChatSummary[]> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("get-chats", appId);
|
||||
return ChatSummariesSchema.parse(data);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all apps
|
||||
public async listApps(): Promise<ListAppsResponse> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("list-apps");
|
||||
return data;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Read a file from an app directory
|
||||
public async readAppFile(appId: number, filePath: string): Promise<string> {
|
||||
try {
|
||||
const content = await this.ipcRenderer.invoke("read-app-file", {
|
||||
appId,
|
||||
filePath,
|
||||
});
|
||||
return content as string;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Edit a file in an app directory
|
||||
public async editAppFile(
|
||||
appId: number,
|
||||
filePath: string,
|
||||
content: string
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("edit-app-file", {
|
||||
appId,
|
||||
filePath,
|
||||
content,
|
||||
});
|
||||
return result as { success: boolean };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// New method for streaming responses
|
||||
public streamMessage(
|
||||
prompt: string,
|
||||
options: {
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
onUpdate: (messages: Message[]) => void;
|
||||
onEnd: (response: ChatResponseEnd) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
): void {
|
||||
const { chatId, onUpdate, onEnd, onError, redo } = options;
|
||||
this.chatStreams.set(chatId, { onUpdate, onEnd, onError });
|
||||
|
||||
// Use invoke to start the stream and pass the chatId
|
||||
this.ipcRenderer
|
||||
.invoke("chat:stream", {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
} satisfies ChatStreamParams)
|
||||
.catch((err) => {
|
||||
showError(err);
|
||||
onError(String(err));
|
||||
this.chatStreams.delete(chatId);
|
||||
});
|
||||
}
|
||||
|
||||
// Method to cancel an ongoing stream
|
||||
public cancelChatStream(chatId: number): void {
|
||||
this.ipcRenderer.invoke("chat:cancel", chatId);
|
||||
const callbacks = this.chatStreams.get(chatId);
|
||||
if (callbacks) {
|
||||
this.chatStreams.delete(chatId);
|
||||
} else {
|
||||
showError(new Error("Tried canceling chat that doesn't exist"));
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new chat for an app
|
||||
public async createChat(appId: number): Promise<number> {
|
||||
try {
|
||||
const chatId = await this.ipcRenderer.invoke("create-chat", appId);
|
||||
return chatId;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Open an external URL using the default browser
|
||||
public async openExternalUrl(
|
||||
url: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("open-external-url", url);
|
||||
return result as { success: boolean; error?: string };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
// Ensure a consistent return type even on invoke error
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// Run an app
|
||||
public async runApp(
|
||||
appId: number,
|
||||
onOutput: (output: AppOutput) => void
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("run-app", { appId });
|
||||
this.appStreams.set(appId, { onOutput });
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop a running app
|
||||
public async stopApp(appId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("stop-app", { appId });
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Restart a running app
|
||||
public async restartApp(
|
||||
appId: number,
|
||||
onOutput: (output: AppOutput) => void
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("restart-app", { appId });
|
||||
this.appStreams.set(appId, { onOutput });
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get allow-listed environment variables
|
||||
public async getEnvVars(): Promise<Record<string, string | undefined>> {
|
||||
try {
|
||||
const envVars = await this.ipcRenderer.invoke("get-env-vars");
|
||||
return envVars as Record<string, string | undefined>;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// List all versions (commits) of an app
|
||||
public async listVersions({ appId }: { appId: number }): Promise<Version[]> {
|
||||
try {
|
||||
const versions = await this.ipcRenderer.invoke("list-versions", {
|
||||
appId,
|
||||
});
|
||||
return versions;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Revert to a specific version
|
||||
public async revertVersion({
|
||||
appId,
|
||||
previousVersionId,
|
||||
}: {
|
||||
appId: number;
|
||||
previousVersionId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("revert-version", {
|
||||
appId,
|
||||
previousVersionId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout a specific version without creating a revert commit
|
||||
public async checkoutVersion({
|
||||
appId,
|
||||
versionId,
|
||||
}: {
|
||||
appId: number;
|
||||
versionId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("checkout-version", {
|
||||
appId,
|
||||
versionId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user settings
|
||||
public async getUserSettings(): Promise<UserSettings> {
|
||||
try {
|
||||
const settings = await this.ipcRenderer.invoke("get-user-settings");
|
||||
return settings;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user settings
|
||||
public async setUserSettings(
|
||||
settings: Partial<UserSettings>
|
||||
): Promise<UserSettings> {
|
||||
try {
|
||||
const updatedSettings = await this.ipcRenderer.invoke(
|
||||
"set-user-settings",
|
||||
settings
|
||||
);
|
||||
return updatedSettings;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract codebase information for a given app
|
||||
public async extractCodebase(appId: number, maxFiles = 30): Promise<string> {
|
||||
try {
|
||||
const codebaseInfo = await this.ipcRenderer.invoke("extract-codebase", {
|
||||
appId,
|
||||
maxFiles,
|
||||
});
|
||||
return codebaseInfo as string;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an app and all its files
|
||||
public async deleteApp(appId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("delete-app", { appId });
|
||||
return result as { success: boolean };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename an app (update name and path)
|
||||
public async renameApp({
|
||||
appId,
|
||||
appName,
|
||||
appPath,
|
||||
}: {
|
||||
appId: number;
|
||||
appName: string;
|
||||
appPath: string;
|
||||
}): Promise<{ success: boolean; app: App }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("rename-app", {
|
||||
appId,
|
||||
appName,
|
||||
appPath,
|
||||
});
|
||||
return result as { success: boolean; app: App };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all - removes all app files, settings, and drops the database
|
||||
public async resetAll(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("reset-all");
|
||||
return result as { success: boolean; message: string };
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async addDependency({
|
||||
chatId,
|
||||
packages,
|
||||
}: {
|
||||
chatId: number;
|
||||
packages: string[];
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.ipcRenderer.invoke("chat:add-dep", {
|
||||
chatId,
|
||||
packages,
|
||||
});
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/ipc/ipc_host.ts
Normal file
16
src/ipc/ipc_host.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { registerAppHandlers } from "./handlers/app_handlers";
|
||||
import { registerChatHandlers } from "./handlers/chat_handlers";
|
||||
import { registerChatStreamHandlers } from "./handlers/chat_stream_handlers";
|
||||
import { registerSettingsHandlers } from "./handlers/settings_handlers";
|
||||
import { registerShellHandlers } from "./handlers/shell_handler";
|
||||
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
registerAppHandlers();
|
||||
registerChatHandlers();
|
||||
registerChatStreamHandlers();
|
||||
registerSettingsHandlers();
|
||||
registerShellHandlers();
|
||||
registerDependencyHandlers();
|
||||
}
|
||||
60
src/ipc/ipc_types.ts
Normal file
60
src/ipc/ipc_types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Message } from "ai";
|
||||
|
||||
export interface AppOutput {
|
||||
type: "stdout" | "stderr" | "info" | "client-error";
|
||||
message: string;
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export interface ListAppsResponse {
|
||||
apps: App[];
|
||||
appBasePath: string;
|
||||
}
|
||||
|
||||
export interface ChatStreamParams {
|
||||
chatId: number;
|
||||
prompt: string;
|
||||
redo?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatResponseEnd {
|
||||
chatId: number;
|
||||
updatedFiles: boolean;
|
||||
}
|
||||
|
||||
export interface CreateAppParams {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CreateAppResult {
|
||||
app: {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: number;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
files: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
oid: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
220
src/ipc/processors/response_processor.ts
Normal file
220
src/ipc/processors/response_processor.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { db } from "../../db";
|
||||
import { chats } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "node:fs";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import path from "node:path";
|
||||
import git from "isomorphic-git";
|
||||
|
||||
export function getDyadWriteTags(fullResponse: string): {
|
||||
path: string;
|
||||
content: string;
|
||||
}[] {
|
||||
const dyadWriteRegex =
|
||||
/<dyad-write path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-write>/g;
|
||||
let match;
|
||||
const tags: { path: string; content: string }[] = [];
|
||||
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
|
||||
tags.push({ path: match[1], content: match[2] });
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getDyadRenameTags(fullResponse: string): {
|
||||
from: string;
|
||||
to: string;
|
||||
}[] {
|
||||
const dyadRenameRegex =
|
||||
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
|
||||
let match;
|
||||
const tags: { from: string; to: string }[] = [];
|
||||
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
|
||||
tags.push({ from: match[1], to: match[2] });
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getDyadDeleteTags(fullResponse: string): string[] {
|
||||
const dyadDeleteRegex =
|
||||
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
|
||||
let match;
|
||||
const paths: string[] = [];
|
||||
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
|
||||
paths.push(match[1]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function getDyadAddDependencyTags(fullResponse: string): string[] {
|
||||
const dyadAddDependencyRegex =
|
||||
/<dyad-add-dependency package="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
|
||||
let match;
|
||||
const packages: string[] = [];
|
||||
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
|
||||
packages.push(match[1]);
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
export async function processFullResponseActions(
|
||||
fullResponse: string,
|
||||
chatId: number,
|
||||
{ chatSummary }: { chatSummary: string | undefined }
|
||||
): Promise<{ updatedFiles?: boolean; error?: string }> {
|
||||
// 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) {
|
||||
console.error(`No app found for chat ID: ${chatId}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(chatWithApp.app.path);
|
||||
const writtenFiles: string[] = [];
|
||||
const renamedFiles: string[] = [];
|
||||
const deletedFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Extract all tags
|
||||
const dyadWriteTags = getDyadWriteTags(fullResponse);
|
||||
const dyadRenameTags = getDyadRenameTags(fullResponse);
|
||||
const dyadDeletePaths = getDyadDeleteTags(fullResponse);
|
||||
const dyadAddDependencyPackages = getDyadAddDependencyTags(fullResponse);
|
||||
|
||||
// If no tags to process, return early
|
||||
if (
|
||||
dyadWriteTags.length === 0 &&
|
||||
dyadRenameTags.length === 0 &&
|
||||
dyadDeletePaths.length === 0 &&
|
||||
dyadAddDependencyPackages.length === 0
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Process all file writes
|
||||
for (const tag of dyadWriteTags) {
|
||||
const filePath = tag.path;
|
||||
const content = tag.content;
|
||||
const fullFilePath = path.join(appPath, filePath);
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = path.dirname(fullFilePath);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
|
||||
// Write file content
|
||||
fs.writeFileSync(fullFilePath, content);
|
||||
console.log(`Successfully wrote file: ${fullFilePath}`);
|
||||
writtenFiles.push(filePath);
|
||||
}
|
||||
|
||||
// Process all file renames
|
||||
for (const tag of dyadRenameTags) {
|
||||
const fromPath = path.join(appPath, tag.from);
|
||||
const toPath = path.join(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);
|
||||
console.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) {
|
||||
console.warn(`Failed to git remove old file ${tag.from}:`, error);
|
||||
// Continue even if remove fails as the file was still renamed
|
||||
}
|
||||
} else {
|
||||
console.warn(`Source file for rename does not exist: ${fromPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process all file deletions
|
||||
for (const filePath of dyadDeletePaths) {
|
||||
const fullFilePath = path.join(appPath, filePath);
|
||||
|
||||
// Delete the file if it exists
|
||||
if (fs.existsSync(fullFilePath)) {
|
||||
fs.unlinkSync(fullFilePath);
|
||||
console.log(`Successfully deleted file: ${fullFilePath}`);
|
||||
deletedFiles.push(filePath);
|
||||
|
||||
// Remove the file from git
|
||||
try {
|
||||
await git.remove({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: filePath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to git remove deleted file ${filePath}:`, error);
|
||||
// Continue even if remove fails as the file was still deleted
|
||||
}
|
||||
} else {
|
||||
console.warn(`File to delete does not exist: ${fullFilePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have any file changes, commit them all at once
|
||||
const hasChanges =
|
||||
writtenFiles.length > 0 ||
|
||||
renamedFiles.length > 0 ||
|
||||
deletedFiles.length > 0;
|
||||
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)`);
|
||||
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: appPath,
|
||||
message: chatSummary
|
||||
? `[dyad] ${chatSummary} - ${changes.join(", ")}`
|
||||
: `[dyad] ${changes.join(", ")}`,
|
||||
author: {
|
||||
name: "Dyad AI",
|
||||
email: "dyad-ai@example.com",
|
||||
},
|
||||
});
|
||||
console.log(`Successfully committed changes: ${changes.join(", ")}`);
|
||||
return { updatedFiles: true };
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error: unknown) {
|
||||
console.error("Error processing files:", error);
|
||||
return { error: (error as any).toString() };
|
||||
}
|
||||
}
|
||||
53
src/ipc/utils/file_utils.ts
Normal file
53
src/ipc/utils/file_utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
|
||||
/**
|
||||
* Recursively gets all files in a directory, excluding node_modules and .git
|
||||
* @param dir The directory to scan
|
||||
* @param baseDir The base directory for calculating relative paths
|
||||
* @returns Array of file paths relative to the base directory
|
||||
*/
|
||||
export function getFilesRecursively(dir: string, baseDir: string): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dirents = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const res = path.join(dir, dirent.name);
|
||||
if (dirent.isDirectory()) {
|
||||
// For directories, concat the results of recursive call
|
||||
// Exclude node_modules and .git directories
|
||||
if (dirent.name !== "node_modules" && dirent.name !== ".git") {
|
||||
files.push(...getFilesRecursively(res, baseDir));
|
||||
}
|
||||
} else {
|
||||
// For files, add the relative path
|
||||
files.push(path.relative(baseDir, res));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function copyDirectoryRecursive(
|
||||
source: string,
|
||||
destination: string
|
||||
) {
|
||||
await fsPromises.mkdir(destination, { recursive: true });
|
||||
const entries = await fsPromises.readdir(source, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(source, entry.name);
|
||||
const destPath = path.join(destination, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectoryRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await fsPromises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/ipc/utils/get_model_client.ts
Normal file
65
src/ipc/utils/get_model_client.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createGoogleGenerativeAI as createGoogle } from "@ai-sdk/google";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import type { LargeLanguageModel, UserSettings } from "../../lib/schemas";
|
||||
import { PROVIDER_TO_ENV_VAR, AUTO_MODELS } from "../../constants/models";
|
||||
|
||||
export function getModelClient(
|
||||
model: LargeLanguageModel,
|
||||
settings: UserSettings
|
||||
) {
|
||||
// Handle 'auto' provider by trying each model in AUTO_MODELS until one works
|
||||
if (model.provider === "auto") {
|
||||
// Try each model in AUTO_MODELS in order until finding one with an API key
|
||||
for (const autoModel of AUTO_MODELS) {
|
||||
const apiKey =
|
||||
settings.providerSettings?.[autoModel.provider]?.apiKey ||
|
||||
process.env[PROVIDER_TO_ENV_VAR[autoModel.provider]];
|
||||
|
||||
if (apiKey) {
|
||||
console.log(
|
||||
`Using provider: ${autoModel.provider} model: ${autoModel.name}`
|
||||
);
|
||||
// Use the first model that has an API key
|
||||
return getModelClient(
|
||||
{
|
||||
provider: autoModel.provider,
|
||||
name: autoModel.name,
|
||||
} as LargeLanguageModel,
|
||||
settings
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no models have API keys, throw an error
|
||||
throw new Error("No API keys available for any model in AUTO_MODELS");
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
settings.providerSettings?.[model.provider]?.apiKey ||
|
||||
process.env[PROVIDER_TO_ENV_VAR[model.provider]];
|
||||
switch (model.provider) {
|
||||
case "openai": {
|
||||
const provider = createOpenAI({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
case "anthropic": {
|
||||
const provider = createAnthropic({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
case "google": {
|
||||
const provider = createGoogle({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
case "openrouter": {
|
||||
const provider = createOpenRouter({ apiKey });
|
||||
return provider(model.name);
|
||||
}
|
||||
default: {
|
||||
// Ensure exhaustive check if more providers are added
|
||||
const _exhaustiveCheck: never = model.provider;
|
||||
throw new Error(`Unsupported model provider: ${model.provider}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/ipc/utils/lock_utils.ts
Normal file
51
src/ipc/utils/lock_utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Track app operations that are in progress
|
||||
const appOperationLocks = new Map<number, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Acquires a lock for an app operation
|
||||
* @param appId The app ID to lock
|
||||
* @returns An object with release function and promise
|
||||
*/
|
||||
export function acquireLock(appId: number): {
|
||||
release: () => void;
|
||||
promise: Promise<void>;
|
||||
} {
|
||||
let release: () => void = () => {};
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
appOperationLocks.delete(appId);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
appOperationLocks.set(appId, promise);
|
||||
return { release, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function with a lock on the app ID
|
||||
* @param appId The app ID to lock
|
||||
* @param fn The function to execute with the lock
|
||||
* @returns Result of the function
|
||||
*/
|
||||
export async function withLock<T>(
|
||||
appId: number,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
// Wait for any existing operation to complete
|
||||
const existingLock = appOperationLocks.get(appId);
|
||||
if (existingLock) {
|
||||
await existingLock;
|
||||
}
|
||||
|
||||
// Acquire a new lock
|
||||
const { release, promise } = acquireLock(appId);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
return result;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
104
src/ipc/utils/process_manager.ts
Normal file
104
src/ipc/utils/process_manager.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { ChildProcess } from "node:child_process";
|
||||
import treeKill from "tree-kill";
|
||||
|
||||
// Define a type for the value stored in runningApps
|
||||
export interface RunningAppInfo {
|
||||
process: ChildProcess;
|
||||
processId: number;
|
||||
}
|
||||
|
||||
// Store running app processes
|
||||
export const runningApps = new Map<number, RunningAppInfo>();
|
||||
// Global counter for process IDs
|
||||
let processCounterValue = 0;
|
||||
|
||||
// Getter and setter for processCounter to allow modification from outside
|
||||
export const processCounter = {
|
||||
get value(): number {
|
||||
return processCounterValue;
|
||||
},
|
||||
set value(newValue: number) {
|
||||
processCounterValue = newValue;
|
||||
},
|
||||
increment(): number {
|
||||
return ++processCounterValue;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Kills a running process with its child processes
|
||||
* @param process The child process to kill
|
||||
* @param pid The process ID
|
||||
* @returns A promise that resolves when the process is closed or timeout
|
||||
*/
|
||||
export function killProcess(process: ChildProcess): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Add timeout to prevent hanging
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`Timeout waiting for process (PID: ${process.pid}) to close. Force killing may be needed.`
|
||||
);
|
||||
resolve();
|
||||
}, 5000); // 5-second timeout
|
||||
|
||||
process.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(
|
||||
`Received 'close' event for process (PID: ${process.pid}) with code ${code}, signal ${signal}.`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Handle potential errors during kill/close sequence
|
||||
process.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(
|
||||
`Error during stop sequence for process (PID: ${process.pid}): ${err.message}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Ensure PID exists before attempting to kill
|
||||
if (process.pid) {
|
||||
// Use tree-kill to terminate the entire process tree
|
||||
console.log(
|
||||
`Attempting to tree-kill process tree starting at PID ${process.pid}.`
|
||||
);
|
||||
treeKill(process.pid, "SIGTERM", (err: Error | undefined) => {
|
||||
if (err) {
|
||||
console.warn(
|
||||
`tree-kill error for PID ${process.pid}: ${err.message}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`tree-kill signal sent successfully to PID ${process.pid}.`
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn(`Cannot tree-kill process: PID is undefined.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an app from the running apps map if it's the current process
|
||||
* @param appId The app ID
|
||||
* @param process The process to check against
|
||||
*/
|
||||
export function removeAppIfCurrentProcess(
|
||||
appId: number,
|
||||
process: ChildProcess
|
||||
): void {
|
||||
const currentAppInfo = runningApps.get(appId);
|
||||
if (currentAppInfo && currentAppInfo.process === process) {
|
||||
runningApps.delete(appId);
|
||||
console.log(
|
||||
`Removed app ${appId} (processId ${currentAppInfo.processId}) from running map. Current size: ${runningApps.size}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`App ${appId} process was already removed or replaced in running map. Ignoring.`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user