diff --git a/e2e-tests/attach_image.spec.ts b/e2e-tests/attach_image.spec.ts index e103270..46d35e4 100644 --- a/e2e-tests/attach_image.spec.ts +++ b/e2e-tests/attach_image.spec.ts @@ -1,4 +1,6 @@ +import path from "path"; import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; import * as fs from "fs"; // It's hard to read the snapshots, but they should be identical across @@ -15,7 +17,7 @@ test("attach image - home chat", async ({ po }) => { await po .getHomeChatInputContainer() - .locator("input[type='file']") + .getByTestId("chat-context-file-input") .setInputFiles("e2e-tests/fixtures/images/logo.png"); await po.sendPrompt("[dump]"); await po.snapshotServerDump("last-message", { name: SNAPSHOT_NAME }); @@ -29,13 +31,40 @@ test("attach image - chat", async ({ po }) => { // attach via file input (click-to-upload) await po .getChatInputContainer() - .locator("input[type='file']") + .getByTestId("chat-context-file-input") .setInputFiles("e2e-tests/fixtures/images/logo.png"); await po.sendPrompt("[dump]"); await po.snapshotServerDump("last-message", { name: SNAPSHOT_NAME }); await po.snapshotMessages({ replaceDumpPath: true }); }); +test("attach image - chat - upload to codebase", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.sendPrompt("basic"); + + // attach via file input (click-to-upload) + await po + .getChatInputContainer() + .getByTestId("upload-to-codebase-file-input") + .setInputFiles("e2e-tests/fixtures/images/logo.png"); + await po.sendPrompt("[[UPLOAD_IMAGE_TO_CODEBASE]]"); + + await po.snapshotServerDump("last-message", { name: "upload-to-codebase" }); + await po.snapshotMessages({ replaceDumpPath: true }); + + // new/image/file.png + const appPath = await po.getCurrentAppPath(); + const filePath = path.join(appPath, "new", "image", "file.png"); + expect(fs.existsSync(filePath)).toBe(true); + // check contents of filePath is equal in value to e2e-tests/fixtures/images/logo.png + const expectedContents = fs.readFileSync( + "e2e-tests/fixtures/images/logo.png", + "base64", + ); + const actualContents = fs.readFileSync(filePath, "base64"); + expect(actualContents).toBe(expectedContents); +}); + // attach image via drag-and-drop to chat input container test("attach image via drag - chat", async ({ po }) => { await po.setUp({ autoApprove: true }); diff --git a/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat---upload-to-codebase-1.aria.yml b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat---upload-to-codebase-1.aria.yml new file mode 100644 index 0000000..6e70cfe --- /dev/null +++ b/e2e-tests/snapshots/attach_image.spec.ts_attach-image---chat---upload-to-codebase-1.aria.yml @@ -0,0 +1,23 @@ +- paragraph: basic +- img +- text: file1.txt +- img +- text: file1.txt +- paragraph: More EOM +- img +- text: Approved +- paragraph: "[[UPLOAD_IMAGE_TO_CODEBASE]]" +- paragraph: "Attachments:" +- paragraph: "File to upload to codebase: logo.png (file id: DYAD_ATTACHMENT_0)" +- paragraph: Uploading image to codebase +- img +- text: file.png +- img +- text: "new/image/file.png Summary: Uploaded image to codebase" +- paragraph: "[[dyad-dump-path=*]]" +- img +- text: Approved +- button "Undo": + - img +- button "Retry": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/attach_image.spec.ts_upload-to-codebase b/e2e-tests/snapshots/attach_image.spec.ts_upload-to-codebase new file mode 100644 index 0000000..027beb3 --- /dev/null +++ b/e2e-tests/snapshots/attach_image.spec.ts_upload-to-codebase @@ -0,0 +1,3 @@ +=== +role: user +message: [{"type":"text","text":"[[UPLOAD_IMAGE_TO_CODEBASE]]\n\nAttachments:\n\n\nFile to upload to codebase: logo.png (file id: DYAD_ATTACHMENT_0)"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAACbElEQVQ4EY1TTWgTQRR+M7NpQoKmPVTPQi/qoaDWQAsxIigqRS/JzYNHxVz0oAcrG6hQoV68ePNQECUpeBFBIqQiVBrbaqvgoXjRSzVJf8zfzu7MG2eWbGywFN8e3ux73/fNm/dmAP7TFCgSQHeurSC4t1eEAFET4+WjzPIq5AX5ZURMjO5GNEk7VbJKKWU9Or8WBg2cvPRxlLXiX7arVtFWNjVkzSX/VJBPK0YKRMIciI6477kjhsDrAxfJoebVUwd0bl1vBD0ChpzR5JkrKzGvjum2B8MtrphLRXGTY5RKCaioi7wr/lcgID+//Omsu4EzERg4GIIoWAygxrezwOvNhmiAoCrkCtZtqF9BPp33d86nl46jw173aWKtXWtzWi21kELDgzOo+mJCCRBIFIowBr3rOQK4bDJC90PVrf5Ayi/efDP62QBvn14Y5m0sKogOCoWy/nvL55syqOl4ppCRT6+9GxAKjgmtLYg3ldVkM4Hs0Fr4QSmxwi28T1gUHE+J9rpGdozqRvoWcmKWUMpyEXWH2IYJiq0KtQYr/qgRWc3T4lJ/IbNVx/xmmCrMXJ+ML3+wZP+Jmre5MN8/NVYoFKTB2XbJ+vYyPi9l/4hLq+XZpZMJ0BxzP3z1XGpO99qUDg897VFGEkd+3lm9lVy8e2NsceL7q/iqwvCIohIYU9MGm+pwuuOwbUVtm+D0uXIO5b57noxCWzJoSQJNIaAhm8BxMze7PGbboLFA/El0BYxqcJTJC+Vkg5PrLU4PO1KBg/jVo87jZ++Tb4PSDX5XMxdq14QOpvfI9XDMxTKPKQim9DqtY8H/Tv8HGFE+AZtzYdAAAAAASUVORK5CYII="}}] \ No newline at end of file diff --git a/src/components/chat/AttachmentsList.tsx b/src/components/chat/AttachmentsList.tsx index 1fb212b..9c432ab 100644 --- a/src/components/chat/AttachmentsList.tsx +++ b/src/components/chat/AttachmentsList.tsx @@ -1,7 +1,8 @@ -import { FileText, X } from "lucide-react"; +import { FileText, X, MessageSquare, Upload } from "lucide-react"; +import type { FileAttachment } from "@/ipc/ipc_types"; interface AttachmentsListProps { - attachments: File[]; + attachments: FileAttachment[]; onRemove: (index: number) => void; } @@ -13,40 +14,50 @@ export function AttachmentsList({ return (
- {attachments.map((file, index) => ( + {attachments.map((attachment, index) => (
- {file.type.startsWith("image/") ? ( -
- {file.name} - URL.revokeObjectURL((e.target as HTMLImageElement).src) - } - onError={(e) => - URL.revokeObjectURL((e.target as HTMLImageElement).src) - } - /> -
+
+ {attachment.type === "upload-to-codebase" ? ( + + ) : ( + + )} + {attachment.file.type.startsWith("image/") ? ( +
{file.name} URL.revokeObjectURL((e.target as HTMLImageElement).src) } + onError={(e) => + URL.revokeObjectURL((e.target as HTMLImageElement).src) + } /> +
+ {attachment.file.name} + URL.revokeObjectURL((e.target as HTMLImageElement).src) + } + onError={(e) => + URL.revokeObjectURL((e.target as HTMLImageElement).src) + } + /> +
-
- ) : ( - - )} - {file.name} + ) : ( + + )} +
+ {attachment.file.name} - - Attach files - - -
diff --git a/src/components/chat/FileAttachmentDropdown.tsx b/src/components/chat/FileAttachmentDropdown.tsx new file mode 100644 index 0000000..57894d9 --- /dev/null +++ b/src/components/chat/FileAttachmentDropdown.tsx @@ -0,0 +1,133 @@ +import { Paperclip, MessageSquare, Upload } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { useRef } from "react"; + +interface FileAttachmentDropdownProps { + onFileSelect: ( + files: FileList, + type: "chat-context" | "upload-to-codebase", + ) => void; + disabled?: boolean; + className?: string; +} + +export function FileAttachmentDropdown({ + onFileSelect, + disabled, + className, +}: FileAttachmentDropdownProps) { + const chatContextFileInputRef = useRef(null); + const uploadToCodebaseFileInputRef = useRef(null); + + const handleChatContextClick = () => { + chatContextFileInputRef.current?.click(); + }; + + const handleUploadToCodebaseClick = () => { + uploadToCodebaseFileInputRef.current?.click(); + }; + + const handleFileChange = ( + e: React.ChangeEvent, + type: "chat-context" | "upload-to-codebase", + ) => { + if (e.target.files && e.target.files.length > 0) { + onFileSelect(e.target.files, type); + // Clear the input value so the same file can be selected again + e.target.value = ""; + } + }; + + return ( + <> + + + + + + + + + + + + + + + Attach file as chat context + + + + Example use case: screenshot of the app to point out a UI + issue + + + + + + + + + + Upload file to codebase + + + + Example use case: add an image to use for your app + + + + + + Attach files + + + + {/* Hidden file inputs */} + handleFileChange(e, "chat-context")} + className="hidden" + multiple + accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv" + /> + handleFileChange(e, "upload-to-codebase")} + className="hidden" + multiple + accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv" + /> + + ); +} diff --git a/src/components/chat/HomeChatInput.tsx b/src/components/chat/HomeChatInput.tsx index a313556..a036b1a 100644 --- a/src/components/chat/HomeChatInput.tsx +++ b/src/components/chat/HomeChatInput.tsx @@ -1,4 +1,4 @@ -import { SendIcon, StopCircleIcon, Paperclip } from "lucide-react"; +import { SendIcon, StopCircleIcon } from "lucide-react"; import type React from "react"; import { useEffect, useRef } from "react"; @@ -9,6 +9,7 @@ import { useStreamChat } from "@/hooks/useStreamChat"; import { useAttachments } from "@/hooks/useAttachments"; import { AttachmentsList } from "./AttachmentsList"; import { DragDropOverlay } from "./DragDropOverlay"; +import { FileAttachmentDropdown } from "./FileAttachmentDropdown"; import { usePostHog } from "posthog-js/react"; import { HomeSubmitOptions } from "@/pages/home"; import { ChatInputControls } from "../ChatInputControls"; @@ -28,10 +29,8 @@ export function HomeChatInput({ // Use the attachments hook const { attachments, - fileInputRef, isDraggingOver, - handleAttachmentClick, - handleFileChange, + handleFileSelect, removeAttachment, handleDragOver, handleDragLeave, @@ -111,22 +110,11 @@ export function HomeChatInput({ disabled={isStreaming} // Should ideally reflect if *any* stream is happening /> - {/* File attachment button */} - - {isStreaming ? ( diff --git a/src/hooks/useAttachments.ts b/src/hooks/useAttachments.ts index cdcbfb5..c2a374a 100644 --- a/src/hooks/useAttachments.ts +++ b/src/hooks/useAttachments.ts @@ -1,7 +1,8 @@ import React, { useState, useRef } from "react"; +import type { FileAttachment } from "@/ipc/ipc_types"; export function useAttachments() { - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState([]); const fileInputRef = useRef(null); const [isDraggingOver, setIsDraggingOver] = useState(false); @@ -9,15 +10,34 @@ export function useAttachments() { fileInputRef.current?.click(); }; - const handleFileChange = (e: React.ChangeEvent) => { + const handleFileChange = ( + e: React.ChangeEvent, + type: "chat-context" | "upload-to-codebase" = "chat-context", + ) => { if (e.target.files && e.target.files.length > 0) { const files = Array.from(e.target.files); - setAttachments((attachments) => [...attachments, ...files]); + const fileAttachments: FileAttachment[] = files.map((file) => ({ + file, + type, + })); + setAttachments((attachments) => [...attachments, ...fileAttachments]); // Clear the input value so the same file can be selected again e.target.value = ""; } }; + const handleFileSelect = ( + fileList: FileList, + type: "chat-context" | "upload-to-codebase", + ) => { + const files = Array.from(fileList); + const fileAttachments: FileAttachment[] = files.map((file) => ({ + file, + type, + })); + setAttachments((attachments) => [...attachments, ...fileAttachments]); + }; + const removeAttachment = (index: number) => { setAttachments(attachments.filter((_, i) => i !== index)); }; @@ -37,7 +57,11 @@ export function useAttachments() { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { const files = Array.from(e.dataTransfer.files); - setAttachments((attachments) => [...attachments, ...files]); + const fileAttachments: FileAttachment[] = files.map((file) => ({ + file, + type: "chat-context" as const, + })); + setAttachments((attachments) => [...attachments, ...fileAttachments]); } }; @@ -45,8 +69,15 @@ export function useAttachments() { setAttachments([]); }; - const addAttachments = (files: File[]) => { - setAttachments((attachments) => [...attachments, ...files]); + const addAttachments = ( + files: File[], + type: "chat-context" | "upload-to-codebase" = "chat-context", + ) => { + const fileAttachments: FileAttachment[] = files.map((file) => ({ + file, + type, + })); + setAttachments((attachments) => [...attachments, ...fileAttachments]); }; const handlePaste = async (e: React.ClipboardEvent) => { @@ -82,7 +113,7 @@ export function useAttachments() { } if (imageFiles.length > 0) { - addAttachments(imageFiles); + addAttachments(imageFiles, "chat-context"); // Show a brief toast or indication that image was pasted console.log(`Pasted ${imageFiles.length} image(s) from clipboard`); } @@ -95,6 +126,7 @@ export function useAttachments() { isDraggingOver, handleAttachmentClick, handleFileChange, + handleFileSelect, removeAttachment, handleDragOver, handleDragLeave, diff --git a/src/hooks/useStreamChat.ts b/src/hooks/useStreamChat.ts index 19d6ef4..65e3a5c 100644 --- a/src/hooks/useStreamChat.ts +++ b/src/hooks/useStreamChat.ts @@ -1,5 +1,9 @@ import { useCallback } from "react"; -import type { ComponentSelection, Message } from "@/ipc/ipc_types"; +import type { + ComponentSelection, + Message, + FileAttachment, +} from "@/ipc/ipc_types"; import { useAtom, useSetAtom } from "jotai"; import { chatErrorAtom, @@ -65,7 +69,7 @@ export function useStreamChat({ prompt: string; chatId: number; redo?: boolean; - attachments?: File[]; + attachments?: FileAttachment[]; selectedComponent?: ComponentSelection | null; }) => { if ( diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts index f53a234..e9901d8 100644 --- a/src/ipc/handlers/chat_stream_handlers.ts +++ b/src/ipc/handlers/chat_stream_handlers.ts @@ -57,6 +57,7 @@ import { getDyadRenameTags, } from "../utils/dyad_tag_parser"; import { fileExists } from "../utils/file_utils"; +import { FileUploadsState } from "../utils/file_uploads_state"; type AsyncIterableStream = AsyncIterable & ReadableStream; @@ -161,6 +162,9 @@ async function processStreamChunks({ export function registerChatStreamHandlers() { ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => { try { + const fileUploadsState = FileUploadsState.getInstance(); + fileUploadsState.initialize({ chatId: req.chatId }); + // Create an AbortController for this stream const abortController = new AbortController(); activeStreams.set(req.chatId, abortController); @@ -221,7 +225,7 @@ export function registerChatStreamHandlers() { if (req.attachments && req.attachments.length > 0) { attachmentInfo = "\n\nAttachments:\n"; - for (const attachment of req.attachments) { + for (const [index, attachment] of req.attachments.entries()) { // Generate a unique filename const hash = crypto .createHash("md5") @@ -236,15 +240,30 @@ export function registerChatStreamHandlers() { await writeFile(filePath, Buffer.from(base64Data, "base64")); attachmentPaths.push(filePath); - attachmentInfo += `- ${attachment.name} (${attachment.type})\n`; - // If it's a text-based file, try to include the content - if (await isTextFile(filePath)) { - try { - attachmentInfo += ` - - \n\n`; - } catch (err) { - logger.error(`Error reading file content: ${err}`); + + if (attachment.attachmentType === "upload-to-codebase") { + // For upload-to-codebase, create a unique file ID and store the mapping + const fileId = `DYAD_ATTACHMENT_${index}`; + + fileUploadsState.addFileUpload(fileId, { + filePath, + originalName: attachment.name, + }); + + // Add instruction for AI to use dyad-write tag + attachmentInfo += `\n\nFile to upload to codebase: ${attachment.name} (file id: ${fileId})\n`; + } else { + // For chat-context, use the existing logic + attachmentInfo += `- ${attachment.name} (${attachment.type})\n`; + // If it's a text-based file, try to include the content + if (await isTextFile(filePath)) { + try { + attachmentInfo += ` + + \n\n`; + } catch (err) { + logger.error(`Error reading file content: ${err}`); + } } } } @@ -454,10 +473,35 @@ ${componentSnippet} attachment.type.startsWith("image/"), ); - if (hasImageAttachments) { + const hasUploadedAttachments = + req.attachments && + req.attachments.some( + (attachment) => attachment.attachmentType === "upload-to-codebase", + ); + // If there's mixed attachments (e.g. some upload to codebase attachments and some upload images as chat context attachemnts) + // we will just include the file upload system prompt, otherwise the AI gets confused and doesn't reliably + // print out the dyad-write tags. + // Usually, AI models will want to use the image as reference to generate code (e.g. UI mockups) anyways, so + // it's not that critical to include the image analysis instructions. + if (hasUploadedAttachments) { + systemPrompt += ` + +When files are attached to this conversation, upload them to the codebase using this exact format: + + +DYAD_ATTACHMENT_X + + +Example for file with id of DYAD_ATTACHMENT_0: + +DYAD_ATTACHMENT_0 + + + `; + } else if (hasImageAttachments) { systemPrompt += ` -# Image Analysis Capabilities +# Image Analysis Instructions This conversation includes one or more image attachments. When the user uploads images: 1. If the user explicitly asks for analysis, description, or information about the image, please analyze the image content. 2. Describe what you see in the image if asked. @@ -857,7 +901,10 @@ ${problemReport.problems const status = await processFullResponseActions( fullResponse, req.chatId, - { chatSummary, messageId: placeholderAssistantMessage.id }, // Use placeholder ID + { + chatSummary, + messageId: placeholderAssistantMessage.id, + }, // Use placeholder ID ); const chat = await db.query.chats.findFirst({ @@ -929,6 +976,8 @@ ${problemReport.problems ); // Clean up the abort controller activeStreams.delete(req.chatId); + // Clean up file uploads state on error + FileUploadsState.getInstance().clear(); return "error"; } }); diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 40aebd1..a5749a2 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -50,6 +50,7 @@ import type { SaveVercelAccessTokenParams, VercelProject, UpdateChatParams, + FileAttachment, } from "./ipc_types"; import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import { showError } from "@/lib/toast"; @@ -258,7 +259,7 @@ export class IpcClient { selectedComponent: ComponentSelection | null; chatId: number; redo?: boolean; - attachments?: File[]; + attachments?: FileAttachment[]; onUpdate: (messages: Message[]) => void; onEnd: (response: ChatResponseEnd) => void; onError: (error: string) => void; @@ -278,24 +279,28 @@ export class IpcClient { // Handle file attachments if provided if (attachments && attachments.length > 0) { - // Process each file and convert to base64 + // Process each file attachment and convert to base64 Promise.all( - attachments.map(async (file) => { - return new Promise<{ name: string; type: string; data: string }>( - (resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - resolve({ - name: file.name, - type: file.type, - data: reader.result as string, - }); - }; - reader.onerror = () => - reject(new Error(`Failed to read file: ${file.name}`)); - reader.readAsDataURL(file); - }, - ); + attachments.map(async (attachment) => { + return new Promise<{ + name: string; + type: string; + data: string; + attachmentType: "upload-to-codebase" | "chat-context"; + }>((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve({ + name: attachment.file.name, + type: attachment.file.type, + data: reader.result as string, + attachmentType: attachment.type, + }); + }; + reader.onerror = () => + reject(new Error(`Failed to read file: ${attachment.file.name}`)); + reader.readAsDataURL(attachment.file); + }); }), ) .then((fileDataArray) => { diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index b1e4494..0981853 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -22,6 +22,7 @@ export interface ChatStreamParams { name: string; type: string; data: string; // Base64 encoded file data + attachmentType: "upload-to-codebase" | "chat-context"; // FileAttachment type }>; selectedComponent: ComponentSelection | null; } @@ -321,3 +322,20 @@ export interface UpdateChatParams { chatId: number; title: string; } + +export interface UploadFileToCodebaseParams { + appId: number; + filePath: string; + fileData: string; // Base64 encoded file data + fileName: string; +} + +export interface UploadFileToCodebaseResult { + success: boolean; + filePath: string; +} + +export interface FileAttachment { + file: File; + type: "upload-to-codebase" | "chat-context"; +} diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index 4256315..9edf10e 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -26,6 +26,7 @@ import { getDyadAddDependencyTags, getDyadExecuteSqlTags, } from "../utils/dyad_tag_parser"; +import { FileUploadsState } from "../utils/file_uploads_state"; const readFile = fs.promises.readFile; const logger = log.scope("response_processor"); @@ -53,13 +54,19 @@ export async function processFullResponseActions( { chatSummary, messageId, - }: { chatSummary: string | undefined; messageId: number }, + }: { + chatSummary: string | undefined; + messageId: number; + }, ): Promise<{ updatedFiles?: boolean; error?: string; extraFiles?: string[]; extraFilesError?: string; }> { + const fileUploadsState = FileUploadsState.getInstance(); + const fileUploadsMap = fileUploadsState.getFileUploadsForChat(chatId); + fileUploadsState.clear(); logger.log("processFullResponseActions for chatId", chatId); // Get the app associated with the chat const chatWithApp = await db.query.chats.findFirst({ @@ -289,9 +296,33 @@ export async function processFullResponseActions( // Process all file writes for (const tag of dyadWriteTags) { const filePath = tag.path; - const content = tag.content; + let content: string | Buffer = tag.content; const fullFilePath = safeJoin(appPath, filePath); + // Check if content (stripped of whitespace) exactly matches a file ID and replace with actual file content + if (fileUploadsMap) { + const trimmedContent = tag.content.trim(); + const fileInfo = fileUploadsMap.get(trimmedContent); + if (fileInfo) { + try { + const fileContent = await readFile(fileInfo.filePath); + content = fileContent; + logger.log( + `Replaced file ID ${trimmedContent} with content from ${fileInfo.originalName}`, + ); + } catch (error) { + logger.error( + `Failed to read uploaded file ${fileInfo.originalName}:`, + error, + ); + errors.push({ + message: `Failed to read uploaded file: ${fileInfo.originalName}`, + error: error, + }); + } + } + } + // Ensure directory exists const dirPath = path.dirname(fullFilePath); fs.mkdirSync(dirPath, { recursive: true }); @@ -300,7 +331,7 @@ export async function processFullResponseActions( fs.writeFileSync(fullFilePath, content); logger.log(`Successfully wrote file: ${fullFilePath}`); writtenFiles.push(filePath); - if (isServerFunction(filePath)) { + if (isServerFunction(filePath) && typeof content === "string") { try { await deploySupabaseFunctions({ supabaseProjectId: chatWithApp.app.supabaseProjectId!, diff --git a/src/ipc/utils/file_uploads_state.ts b/src/ipc/utils/file_uploads_state.ts new file mode 100644 index 0000000..1b99855 --- /dev/null +++ b/src/ipc/utils/file_uploads_state.ts @@ -0,0 +1,66 @@ +import log from "electron-log"; + +const logger = log.scope("file_uploads_state"); + +export interface FileUploadInfo { + filePath: string; + originalName: string; +} + +export class FileUploadsState { + private static instance: FileUploadsState; + private currentChatId: number | null = null; + private fileUploadsMap = new Map(); + + private constructor() {} + + public static getInstance(): FileUploadsState { + if (!FileUploadsState.instance) { + FileUploadsState.instance = new FileUploadsState(); + } + return FileUploadsState.instance; + } + + /** + * Initialize file uploads state for a specific chat and message + */ + public initialize({ chatId }: { chatId: number }): void { + this.currentChatId = chatId; + this.fileUploadsMap.clear(); + logger.debug(`Initialized file uploads state for chat ${chatId}`); + } + + /** + * Add a file upload mapping + */ + public addFileUpload(fileId: string, fileInfo: FileUploadInfo): void { + this.fileUploadsMap.set(fileId, fileInfo); + logger.log(`Added file upload: ${fileId} -> ${fileInfo.originalName}`); + } + + /** + * Get the current file uploads map + */ + public getFileUploadsForChat(chatId: number): Map { + if (this.currentChatId !== chatId) { + return new Map(); + } + return new Map(this.fileUploadsMap); + } + + /** + * Get current chat ID + */ + public getCurrentChatId(): number | null { + return this.currentChatId; + } + + /** + * Clear the current state + */ + public clear(): void { + this.currentChatId = null; + this.fileUploadsMap.clear(); + logger.debug("Cleared file uploads state"); + } +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 459e1ac..db864b0 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -29,9 +29,11 @@ import { showError } from "@/lib/toast"; import { invalidateAppQuery } from "@/hooks/useLoadApp"; import { useQueryClient } from "@tanstack/react-query"; +import type { FileAttachment } from "@/ipc/ipc_types"; + // Adding an export for attachments export interface HomeSubmitOptions { - attachments?: File[]; + attachments?: FileAttachment[]; } export default function HomePage() { diff --git a/testing/fake-llm-server/chatCompletionHandler.ts b/testing/fake-llm-server/chatCompletionHandler.ts index 6cae268..095ce90 100644 --- a/testing/fake-llm-server/chatCompletionHandler.ts +++ b/testing/fake-llm-server/chatCompletionHandler.ts @@ -24,6 +24,24 @@ export const createChatCompletionHandler = } let messageContent = CANNED_MESSAGE; + console.error("LASTMESSAGE********", lastMessage.content); + + if ( + lastMessage && + Array.isArray(lastMessage.content) && + lastMessage.content.some( + (part: { type: string; text: string }) => + part.type === "text" && + part.text.includes("[[UPLOAD_IMAGE_TO_CODEBASE]]"), + ) + ) { + messageContent = `Uploading image to codebase + +DYAD_ATTACHMENT_0 + +`; + messageContent += "\n\n" + generateDump(req); + } // TS auto-fix prefixes if (