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

477
src/ipc/ipc_client.ts Normal file
View 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
View 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
View 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;
}

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

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

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

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

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