Initial open-source release

This commit is contained in:
Will Chen
2025-04-11 09:37:05 -07:00
commit 43f67e0739
208 changed files with 45476 additions and 0 deletions

View 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" };
});
}

View 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;
}
);
}

View 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;
});
}

View 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;
}
}
);
}

View 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();
}
);
}

View 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 };
}
});
}

View 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.");
}
}

View 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;
}