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;
|
||||
}
|
||||
Reference in New Issue
Block a user