Support rename/delete edge function & standardize output

This commit is contained in:
Will Chen
2025-04-23 11:15:52 -07:00
parent 6e1935bbba
commit 55c0190f26
7 changed files with 295 additions and 67 deletions

View File

@@ -330,12 +330,20 @@ function ChatInputActions({
}: ChatInputActionsProps) { }: ChatInputActionsProps) {
const [autoApprove, setAutoApprove] = useState(false); const [autoApprove, setAutoApprove] = useState(false);
const [isDetailsVisible, setIsDetailsVisible] = useState(false); const [isDetailsVisible, setIsDetailsVisible] = useState(false);
if (proposal.type === "tip-proposal") { if (proposal.type === "tip-proposal") {
return <div>Tip proposal</div>; return <div>Tip proposal</div>;
} }
if (proposal.type === "action-proposal") { if (proposal.type === "action-proposal") {
return <ActionProposalActions proposal={proposal}></ActionProposalActions>; return <ActionProposalActions proposal={proposal}></ActionProposalActions>;
} }
// Split files into server functions and other files - only for CodeProposal
const serverFunctions =
proposal.filesChanged?.filter((f: FileChange) => f.isServerFunction) ?? [];
const otherFilesChanged =
proposal.filesChanged?.filter((f: FileChange) => !f.isServerFunction) ?? [];
return ( return (
<div className="border-b border-border"> <div className="border-b border-border">
<div className="p-2"> <div className="p-2">
@@ -462,11 +470,30 @@ function ChatInputActions({
</div> </div>
)} )}
{proposal.filesChanged?.length > 0 && ( {serverFunctions.length > 0 && (
<div className="mb-3">
<h4 className="font-semibold mb-1">Server Functions Changed</h4>
<ul className="space-y-1">
{serverFunctions.map((file: FileChange, index: number) => (
<li key={index} className="flex items-center space-x-2">
{getIconForFileChange(file)}
<span title={file.path} className="truncate cursor-default">
{file.name}
</span>
<span className="text-muted-foreground text-xs truncate">
- {file.summary}
</span>
</li>
))}
</ul>
</div>
)}
{otherFilesChanged.length > 0 && (
<div> <div>
<h4 className="font-semibold mb-1">Files Changed</h4> <h4 className="font-semibold mb-1">Files Changed</h4>
<ul className="space-y-1"> <ul className="space-y-1">
{proposal.filesChanged.map((file, index) => ( {otherFilesChanged.map((file: FileChange, index: number) => (
<li key={index} className="flex items-center space-x-2"> <li key={index} className="flex items-center space-x-2">
{getIconForFileChange(file)} {getIconForFileChange(file)}
<span title={file.path} className="truncate cursor-default"> <span title={file.path} className="truncate cursor-default">

View File

@@ -11,6 +11,7 @@ import { CodeHighlight } from "./CodeHighlight";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { isStreamingAtom } from "@/atoms/chatAtoms"; import { isStreamingAtom } from "@/atoms/chatAtoms";
import { CustomTagState } from "./stateTypes"; import { CustomTagState } from "./stateTypes";
import { DyadOutput } from "./DyadOutput";
interface DyadMarkdownParserProps { interface DyadMarkdownParserProps {
content: string; content: string;
@@ -77,6 +78,7 @@ function preprocessUnclosedTags(content: string): {
"dyad-add-dependency", "dyad-add-dependency",
"dyad-execute-sql", "dyad-execute-sql",
"dyad-add-integration", "dyad-add-integration",
"dyad-output",
]; ];
let processedContent = content; let processedContent = content;
@@ -137,6 +139,7 @@ function parseCustomTags(content: string): ContentPiece[] {
"dyad-add-dependency", "dyad-add-dependency",
"dyad-execute-sql", "dyad-execute-sql",
"dyad-add-integration", "dyad-add-integration",
"dyad-output",
]; ];
const tagPattern = new RegExp( const tagPattern = new RegExp(
@@ -303,6 +306,16 @@ function renderCustomTag(
</DyadAddIntegration> </DyadAddIntegration>
); );
case "dyad-output":
return (
<DyadOutput
type={attributes.type as "warning" | "error"}
message={attributes.message}
>
{content}
</DyadOutput>
);
default: default:
return null; return null;
} }

View File

@@ -0,0 +1,77 @@
import React, { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
AlertTriangle,
XCircle,
} from "lucide-react";
interface DyadOutputProps {
type: "error" | "warning";
message?: string;
children?: React.ReactNode;
}
export const DyadOutput: React.FC<DyadOutputProps> = ({
type,
message,
children,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// If the type is not warning, it is an error (in case LLM gives a weird "type")
const isError = type !== "warning";
const borderColor = isError ? "border-red-500" : "border-amber-500";
const iconColor = isError ? "text-red-500" : "text-amber-500";
const icon = isError ? (
<XCircle size={16} className={iconColor} />
) : (
<AlertTriangle size={16} className={iconColor} />
);
const label = isError ? "Error" : "Warning";
return (
<div
className={`relative bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${borderColor}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
{/* Top-left label badge */}
<div
className={`absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold ${iconColor} bg-white dark:bg-gray-900`}
style={{ zIndex: 1 }}
>
{icon}
<span>{label}</span>
</div>
{/* Main content, padded to avoid label */}
<div className="flex items-center justify-between pl-20">
<div className="flex items-center gap-2">
{message && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{message.slice(0, isContentVisible ? undefined : 80) +
(!isContentVisible ? "..." : "")}
</span>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{isContentVisible && children && (
<div className="text-sm mt-2 text-gray-800 dark:text-gray-200 pl-20">
{children}
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,4 @@
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import { SupabaseManagementAPI } from "supabase-management-js";
import { readSettings, writeSettings } from "../../main/settings";
import log from "electron-log"; import log from "electron-log";
import { db } from "../../db"; import { db } from "../../db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";

View File

@@ -17,55 +17,31 @@ export async function executeAddDependency({
appPath: string; appPath: string;
}) { }) {
const packageStr = packages.join(" "); const packageStr = packages.join(" ");
try {
const { stdout, stderr } = await execPromise(
`(pnpm add ${packageStr}) || (npm install ${packageStr})`,
{
cwd: appPath,
}
);
const installResults = stdout + (stderr ? `\n${stderr}` : "");
// Update the message content with the installation results const { stdout, stderr } = await execPromise(
const updatedContent = message.content.replace( `(pnpm add ${packageStr}) || (npm install ${packageStr})`,
new RegExp( {
`<dyad-add-dependency packages="${packages.join( cwd: appPath,
" " }
)}">[^<]*</dyad-add-dependency>`, );
"g" 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 packages="${packages.join(
" " " "
)}">${installResults}</dyad-add-dependency>` )}">[^<]*</dyad-add-dependency>`,
); "g"
),
`<dyad-add-dependency packages="${packages.join(
" "
)}">${installResults}</dyad-add-dependency>`
);
// Save the updated message back to the database // Save the updated message back to the database
await db await db
.update(messages) .update(messages)
.set({ content: updatedContent }) .set({ content: updatedContent })
.where(eq(messages.id, message.id)); .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

@@ -10,11 +10,13 @@ import { getGitAuthor } from "../utils/git_author";
import log from "electron-log"; import log from "electron-log";
import { executeAddDependency } from "./executeAddDependency"; import { executeAddDependency } from "./executeAddDependency";
import { import {
deleteSupabaseFunction,
deploySupabaseFunctions, deploySupabaseFunctions,
executeSupabaseSql, executeSupabaseSql,
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils"; import { isServerFunction } from "../../supabase_admin/supabase_utils";
const readFile = fs.promises.readFile;
const logger = log.scope("response_processor"); const logger = log.scope("response_processor");
export function getDyadWriteTags(fullResponse: string): { export function getDyadWriteTags(fullResponse: string): {
@@ -131,6 +133,23 @@ export function getDyadExecuteSqlTags(fullResponse: string): string[] {
return queries; return queries;
} }
interface Output {
message: string;
error: unknown;
}
function getFunctionNameFromPath(input: string): string {
return path.basename(path.extname(input) ? path.dirname(input) : input);
}
async function readFileFromFunctionPath(input: string): Promise<string> {
// Sometimes, the path given is a directory, sometimes it's the file itself.
if (path.extname(input) === "") {
return readFile(path.join(input, "index.ts"), "utf8");
}
return readFile(input, "utf8");
}
export async function processFullResponseActions( export async function processFullResponseActions(
fullResponse: string, fullResponse: string,
chatId: number, chatId: number,
@@ -158,6 +177,9 @@ export async function processFullResponseActions(
const deletedFiles: string[] = []; const deletedFiles: string[] = [];
let hasChanges = false; let hasChanges = false;
const warnings: Output[] = [];
const errors: Output[] = [];
try { try {
// Extract all tags // Extract all tags
const dyadWriteTags = getDyadWriteTags(fullResponse); const dyadWriteTags = getDyadWriteTags(fullResponse);
@@ -184,21 +206,37 @@ export async function processFullResponseActions(
// Handle SQL execution tags // Handle SQL execution tags
if (dyadExecuteSqlQueries.length > 0) { if (dyadExecuteSqlQueries.length > 0) {
for (const query of dyadExecuteSqlQueries) { for (const query of dyadExecuteSqlQueries) {
const result = await executeSupabaseSql({ try {
supabaseProjectId: chatWithApp.app.supabaseProjectId!, const result = await executeSupabaseSql({
query, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
}); query,
});
} catch (error) {
errors.push({
message: `Failed to execute SQL query: ${query}`,
error: error,
});
}
} }
logger.log(`Executed ${dyadExecuteSqlQueries.length} SQL queries`); logger.log(`Executed ${dyadExecuteSqlQueries.length} SQL queries`);
} }
// TODO: Handle add dependency tags // TODO: Handle add dependency tags
if (dyadAddDependencyPackages.length > 0) { if (dyadAddDependencyPackages.length > 0) {
await executeAddDependency({ try {
packages: dyadAddDependencyPackages, await executeAddDependency({
message: message, packages: dyadAddDependencyPackages,
appPath, message: message,
}); appPath,
});
} catch (error) {
errors.push({
message: `Failed to add dependencies: ${dyadAddDependencyPackages.join(
", "
)}`,
error: error,
});
}
writtenFiles.push("package.json"); writtenFiles.push("package.json");
const pnpmFilename = "pnpm-lock.yaml"; const pnpmFilename = "pnpm-lock.yaml";
if (fs.existsSync(path.join(appPath, pnpmFilename))) { if (fs.existsSync(path.join(appPath, pnpmFilename))) {
@@ -225,11 +263,18 @@ export async function processFullResponseActions(
logger.log(`Successfully wrote file: ${fullFilePath}`); logger.log(`Successfully wrote file: ${fullFilePath}`);
writtenFiles.push(filePath); writtenFiles.push(filePath);
if (isServerFunction(filePath)) { if (isServerFunction(filePath)) {
await deploySupabaseFunctions({ try {
supabaseProjectId: chatWithApp.app.supabaseProjectId!, await deploySupabaseFunctions({
functionName: path.basename(path.dirname(filePath)), supabaseProjectId: chatWithApp.app.supabaseProjectId!,
content: content, functionName: path.basename(path.dirname(filePath)),
}); content: content,
});
} catch (error) {
errors.push({
message: `Failed to deploy Supabase function: ${filePath}`,
error: error,
});
}
} }
} }
@@ -267,6 +312,33 @@ export async function processFullResponseActions(
} else { } else {
logger.warn(`Source file for rename does not exist: ${fromPath}`); logger.warn(`Source file for rename does not exist: ${fromPath}`);
} }
if (isServerFunction(tag.from)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.from),
});
} catch (error) {
warnings.push({
message: `Failed to delete Supabase function: ${tag.from} as part of renaming ${tag.from} to ${tag.to}`,
error: error,
});
}
}
if (isServerFunction(tag.to)) {
try {
await deploySupabaseFunctions({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.to),
content: await readFileFromFunctionPath(toPath),
});
} catch (error) {
errors.push({
message: `Failed to deploy Supabase function: ${tag.to} as part of renaming ${tag.from} to ${tag.to}`,
error: error,
});
}
}
} }
// Process all file deletions // Process all file deletions
@@ -275,7 +347,11 @@ export async function processFullResponseActions(
// Delete the file if it exists // Delete the file if it exists
if (fs.existsSync(fullFilePath)) { if (fs.existsSync(fullFilePath)) {
fs.unlinkSync(fullFilePath); if (fs.lstatSync(fullFilePath).isDirectory()) {
fs.rmdirSync(fullFilePath, { recursive: true });
} else {
fs.unlinkSync(fullFilePath);
}
logger.log(`Successfully deleted file: ${fullFilePath}`); logger.log(`Successfully deleted file: ${fullFilePath}`);
deletedFiles.push(filePath); deletedFiles.push(filePath);
@@ -293,6 +369,19 @@ export async function processFullResponseActions(
} else { } else {
logger.warn(`File to delete does not exist: ${fullFilePath}`); logger.warn(`File to delete does not exist: ${fullFilePath}`);
} }
if (isServerFunction(filePath)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(filePath),
});
} catch (error) {
errors.push({
message: `Failed to delete Supabase function: ${filePath}`,
error: error,
});
}
}
} }
// If we have any file changes, commit them all at once // If we have any file changes, commit them all at once
@@ -346,10 +435,32 @@ export async function processFullResponseActions(
approvalState: "approved", approvalState: "approved",
}) })
.where(eq(messages.id, messageId)); .where(eq(messages.id, messageId));
return { updatedFiles: hasChanges }; return { updatedFiles: hasChanges };
} catch (error: unknown) { } catch (error: unknown) {
logger.error("Error processing files:", error); logger.error("Error processing files:", error);
return { error: (error as any).toString() }; return { error: (error as any).toString() };
} finally {
const appendedContent = `
${warnings
.map(
(warning) =>
`<dyad-output type="warning" message="${warning.message}">${warning.error}</dyad-output>`
)
.join("\n")}
${errors
.map(
(error) =>
`<dyad-output type="error" message="${error.message}">${error.error}</dyad-output>`
)
.join("\n")}
`;
if (appendedContent.length > 0) {
await db
.update(messages)
.set({
content: fullResponse + "\n\n" + appendedContent,
})
.where(eq(messages.id, messageId));
}
} }
} }

View File

@@ -4,6 +4,9 @@ import {
SupabaseManagementAPI, SupabaseManagementAPI,
SupabaseManagementAPIError, SupabaseManagementAPIError,
} from "@dyad-sh/supabase-management-js"; } from "@dyad-sh/supabase-management-js";
import log from "electron-log";
const logger = log.scope("supabase_management_client");
/** /**
* Checks if the Supabase access token is expired or about to expire * Checks if the Supabase access token is expired or about to expire
@@ -137,6 +140,23 @@ export async function executeSupabaseSql({
return JSON.stringify(result); return JSON.stringify(result);
} }
export async function deleteSupabaseFunction({
supabaseProjectId,
functionName,
}: {
supabaseProjectId: string;
functionName: string;
}): Promise<void> {
logger.info(
`Deleting Supabase function: ${functionName} from project: ${supabaseProjectId}`
);
const supabase = await getSupabaseClient();
await supabase.deleteFunction(supabaseProjectId, functionName);
logger.info(
`Deleted Supabase function: ${functionName} from project: ${supabaseProjectId}`
);
}
export async function deploySupabaseFunctions({ export async function deploySupabaseFunctions({
supabaseProjectId, supabaseProjectId,
functionName, functionName,
@@ -146,6 +166,9 @@ export async function deploySupabaseFunctions({
functionName: string; functionName: string;
content: string; content: string;
}): Promise<void> { }): Promise<void> {
logger.info(
`Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}`
);
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient();
const formData = new FormData(); const formData = new FormData();
formData.append( formData.append(
@@ -172,6 +195,9 @@ export async function deploySupabaseFunctions({
throw await createResponseError(response, "create function"); throw await createResponseError(response, "create function");
} }
logger.info(
`Deployed Supabase function: ${functionName} to project: ${supabaseProjectId}`
);
return response.json(); return response.json();
} }