Upload image via chat (#686)

This commit is contained in:
Will Chen
2025-07-22 15:45:30 -07:00
committed by GitHub
parent de73445766
commit 9edd0fa80f
16 changed files with 509 additions and 118 deletions

View File

@@ -1,4 +1,6 @@
import path from "path";
import { test } from "./helpers/test_helper"; import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
import * as fs from "fs"; import * as fs from "fs";
// It's hard to read the snapshots, but they should be identical across // 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 await po
.getHomeChatInputContainer() .getHomeChatInputContainer()
.locator("input[type='file']") .getByTestId("chat-context-file-input")
.setInputFiles("e2e-tests/fixtures/images/logo.png"); .setInputFiles("e2e-tests/fixtures/images/logo.png");
await po.sendPrompt("[dump]"); await po.sendPrompt("[dump]");
await po.snapshotServerDump("last-message", { name: SNAPSHOT_NAME }); 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) // attach via file input (click-to-upload)
await po await po
.getChatInputContainer() .getChatInputContainer()
.locator("input[type='file']") .getByTestId("chat-context-file-input")
.setInputFiles("e2e-tests/fixtures/images/logo.png"); .setInputFiles("e2e-tests/fixtures/images/logo.png");
await po.sendPrompt("[dump]"); await po.sendPrompt("[dump]");
await po.snapshotServerDump("last-message", { name: SNAPSHOT_NAME }); await po.snapshotServerDump("last-message", { name: SNAPSHOT_NAME });
await po.snapshotMessages({ replaceDumpPath: true }); 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 // attach image via drag-and-drop to chat input container
test("attach image via drag - chat", async ({ po }) => { test("attach image via drag - chat", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });

View File

@@ -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

View File

@@ -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="}}]

View File

@@ -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 { interface AttachmentsListProps {
attachments: File[]; attachments: FileAttachment[];
onRemove: (index: number) => void; onRemove: (index: number) => void;
} }
@@ -13,40 +14,50 @@ export function AttachmentsList({
return ( return (
<div className="px-2 pt-2 flex flex-wrap gap-1"> <div className="px-2 pt-2 flex flex-wrap gap-1">
{attachments.map((file, index) => ( {attachments.map((attachment, index) => (
<div <div
key={index} key={index}
className="flex items-center bg-muted rounded-md px-2 py-1 text-xs gap-1" className="flex items-center bg-muted rounded-md px-2 py-1 text-xs gap-1"
title={`${file.name} (${(file.size / 1024).toFixed(1)}KB)`} title={`${attachment.file.name} (${(attachment.file.size / 1024).toFixed(1)}KB)`}
> >
{file.type.startsWith("image/") ? ( <div className="flex items-center gap-1">
<div className="relative group"> {attachment.type === "upload-to-codebase" ? (
<img <Upload size={12} className="text-blue-600" />
src={URL.createObjectURL(file)} ) : (
alt={file.name} <MessageSquare size={12} className="text-green-600" />
className="w-5 h-5 object-cover rounded" )}
onLoad={(e) => {attachment.file.type.startsWith("image/") ? (
URL.revokeObjectURL((e.target as HTMLImageElement).src) <div className="relative group">
}
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
<div className="absolute hidden group-hover:block top-6 left-0 z-10">
<img <img
src={URL.createObjectURL(file)} src={URL.createObjectURL(attachment.file)}
alt={file.name} alt={attachment.file.name}
className="max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg" className="w-5 h-5 object-cover rounded"
onLoad={(e) => onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src) URL.revokeObjectURL((e.target as HTMLImageElement).src)
} }
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/> />
<div className="absolute hidden group-hover:block top-6 left-0 z-10">
<img
src={URL.createObjectURL(attachment.file)}
alt={attachment.file.name}
className="max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg"
onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
</div>
</div> </div>
</div> ) : (
) : ( <FileText size={12} />
<FileText size={12} /> )}
)} </div>
<span className="truncate max-w-[120px]">{file.name}</span> <span className="truncate max-w-[120px]">{attachment.file.name}</span>
<button <button
onClick={() => onRemove(index)} onClick={() => onRemove(index)}
className="hover:bg-muted-foreground/20 rounded-full p-0.5" className="hover:bg-muted-foreground/20 rounded-full p-0.5"

View File

@@ -14,7 +14,6 @@ import {
Database, Database,
ChevronsUpDown, ChevronsUpDown,
ChevronsDownUp, ChevronsDownUp,
Paperclip,
ChartColumnIncreasing, ChartColumnIncreasing,
SendHorizontalIcon, SendHorizontalIcon,
} from "lucide-react"; } from "lucide-react";
@@ -58,6 +57,7 @@ import { useVersions } from "@/hooks/useVersions";
import { useAttachments } from "@/hooks/useAttachments"; import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList"; import { AttachmentsList } from "./AttachmentsList";
import { DragDropOverlay } from "./DragDropOverlay"; import { DragDropOverlay } from "./DragDropOverlay";
import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
import { showError, showExtraFilesToast } from "@/lib/toast"; import { showError, showExtraFilesToast } from "@/lib/toast";
import { ChatInputControls } from "../ChatInputControls"; import { ChatInputControls } from "../ChatInputControls";
import { ChatErrorBox } from "./ChatErrorBox"; import { ChatErrorBox } from "./ChatErrorBox";
@@ -89,10 +89,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
// Use the attachments hook // Use the attachments hook
const { const {
attachments, attachments,
fileInputRef,
isDraggingOver, isDraggingOver,
handleAttachmentClick, handleFileSelect,
handleFileChange,
removeAttachment, removeAttachment,
handleDragOver, handleDragOver,
handleDragLeave, handleDragLeave,
@@ -342,29 +340,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
<div className="pl-2 pr-1 flex items-center justify-between pb-2"> <div className="pl-2 pr-1 flex items-center justify-between pb-2">
<div className="flex items-center"> <div className="flex items-center">
<ChatInputControls showContextFilesPicker={true} /> <ChatInputControls showContextFilesPicker={true} />
{/* File attachment button */} {/* File attachment dropdown */}
<TooltipProvider> <FileAttachmentDropdown
<Tooltip> onFileSelect={handleFileSelect}
<TooltipTrigger asChild> disabled={isStreaming}
<Button
variant="ghost"
onClick={handleAttachmentClick}
title="Attach files"
size="sm"
>
<Paperclip size={20} />
</Button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</TooltipProvider>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/> />
</div> </div>

View File

@@ -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<HTMLInputElement>(null);
const uploadToCodebaseFileInputRef = useRef<HTMLInputElement>(null);
const handleChatContextClick = () => {
chatContextFileInputRef.current?.click();
};
const handleUploadToCodebaseClick = () => {
uploadToCodebaseFileInputRef.current?.click();
};
const handleFileChange = (
e: React.ChangeEvent<HTMLInputElement>,
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 (
<>
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
title="Attach files"
className={className}
>
<Paperclip size={20} />
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={handleChatContextClick}
className="py-3 px-4"
>
<MessageSquare size={16} className="mr-2" />
Attach file as chat context
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
Example use case: screenshot of the app to point out a UI
issue
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={handleUploadToCodebaseClick}
className="py-3 px-4"
>
<Upload size={16} className="mr-2" />
Upload file to codebase
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
Example use case: add an image to use for your app
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Hidden file inputs */}
<input
type="file"
data-testid="chat-context-file-input"
ref={chatContextFileInputRef}
onChange={(e) => handleFileChange(e, "chat-context")}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
<input
type="file"
data-testid="upload-to-codebase-file-input"
ref={uploadToCodebaseFileInputRef}
onChange={(e) => handleFileChange(e, "upload-to-codebase")}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
</>
);
}

View File

@@ -1,4 +1,4 @@
import { SendIcon, StopCircleIcon, Paperclip } from "lucide-react"; import { SendIcon, StopCircleIcon } from "lucide-react";
import type React from "react"; import type React from "react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
@@ -9,6 +9,7 @@ import { useStreamChat } from "@/hooks/useStreamChat";
import { useAttachments } from "@/hooks/useAttachments"; import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList"; import { AttachmentsList } from "./AttachmentsList";
import { DragDropOverlay } from "./DragDropOverlay"; import { DragDropOverlay } from "./DragDropOverlay";
import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import { HomeSubmitOptions } from "@/pages/home"; import { HomeSubmitOptions } from "@/pages/home";
import { ChatInputControls } from "../ChatInputControls"; import { ChatInputControls } from "../ChatInputControls";
@@ -28,10 +29,8 @@ export function HomeChatInput({
// Use the attachments hook // Use the attachments hook
const { const {
attachments, attachments,
fileInputRef,
isDraggingOver, isDraggingOver,
handleAttachmentClick, handleFileSelect,
handleFileChange,
removeAttachment, removeAttachment,
handleDragOver, handleDragOver,
handleDragLeave, handleDragLeave,
@@ -111,22 +110,11 @@ export function HomeChatInput({
disabled={isStreaming} // Should ideally reflect if *any* stream is happening disabled={isStreaming} // Should ideally reflect if *any* stream is happening
/> />
{/* File attachment button */} {/* File attachment dropdown */}
<button <FileAttachmentDropdown
onClick={handleAttachmentClick} className="mt-1 mr-1"
className="px-2 py-2 mt-1 mr-1 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50" onFileSelect={handleFileSelect}
disabled={isStreaming} disabled={isStreaming}
title="Attach files"
>
<Paperclip size={20} />
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/> />
{isStreaming ? ( {isStreaming ? (

View File

@@ -1,7 +1,8 @@
import React, { useState, useRef } from "react"; import React, { useState, useRef } from "react";
import type { FileAttachment } from "@/ipc/ipc_types";
export function useAttachments() { export function useAttachments() {
const [attachments, setAttachments] = useState<File[]>([]); const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false);
@@ -9,15 +10,34 @@ export function useAttachments() {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: "chat-context" | "upload-to-codebase" = "chat-context",
) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
const files = Array.from(e.target.files); 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 // Clear the input value so the same file can be selected again
e.target.value = ""; 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) => { const removeAttachment = (index: number) => {
setAttachments(attachments.filter((_, i) => i !== index)); setAttachments(attachments.filter((_, i) => i !== index));
}; };
@@ -37,7 +57,11 @@ export function useAttachments() {
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files); 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([]); setAttachments([]);
}; };
const addAttachments = (files: File[]) => { const addAttachments = (
setAttachments((attachments) => [...attachments, ...files]); 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) => { const handlePaste = async (e: React.ClipboardEvent) => {
@@ -82,7 +113,7 @@ export function useAttachments() {
} }
if (imageFiles.length > 0) { if (imageFiles.length > 0) {
addAttachments(imageFiles); addAttachments(imageFiles, "chat-context");
// Show a brief toast or indication that image was pasted // Show a brief toast or indication that image was pasted
console.log(`Pasted ${imageFiles.length} image(s) from clipboard`); console.log(`Pasted ${imageFiles.length} image(s) from clipboard`);
} }
@@ -95,6 +126,7 @@ export function useAttachments() {
isDraggingOver, isDraggingOver,
handleAttachmentClick, handleAttachmentClick,
handleFileChange, handleFileChange,
handleFileSelect,
removeAttachment, removeAttachment,
handleDragOver, handleDragOver,
handleDragLeave, handleDragLeave,

View File

@@ -1,5 +1,9 @@
import { useCallback } from "react"; 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 { useAtom, useSetAtom } from "jotai";
import { import {
chatErrorAtom, chatErrorAtom,
@@ -65,7 +69,7 @@ export function useStreamChat({
prompt: string; prompt: string;
chatId: number; chatId: number;
redo?: boolean; redo?: boolean;
attachments?: File[]; attachments?: FileAttachment[];
selectedComponent?: ComponentSelection | null; selectedComponent?: ComponentSelection | null;
}) => { }) => {
if ( if (

View File

@@ -57,6 +57,7 @@ import {
getDyadRenameTags, getDyadRenameTags,
} from "../utils/dyad_tag_parser"; } from "../utils/dyad_tag_parser";
import { fileExists } from "../utils/file_utils"; import { fileExists } from "../utils/file_utils";
import { FileUploadsState } from "../utils/file_uploads_state";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>; type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -161,6 +162,9 @@ async function processStreamChunks({
export function registerChatStreamHandlers() { export function registerChatStreamHandlers() {
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => { ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
try { try {
const fileUploadsState = FileUploadsState.getInstance();
fileUploadsState.initialize({ chatId: req.chatId });
// Create an AbortController for this stream // Create an AbortController for this stream
const abortController = new AbortController(); const abortController = new AbortController();
activeStreams.set(req.chatId, abortController); activeStreams.set(req.chatId, abortController);
@@ -221,7 +225,7 @@ export function registerChatStreamHandlers() {
if (req.attachments && req.attachments.length > 0) { if (req.attachments && req.attachments.length > 0) {
attachmentInfo = "\n\nAttachments:\n"; attachmentInfo = "\n\nAttachments:\n";
for (const attachment of req.attachments) { for (const [index, attachment] of req.attachments.entries()) {
// Generate a unique filename // Generate a unique filename
const hash = crypto const hash = crypto
.createHash("md5") .createHash("md5")
@@ -236,15 +240,30 @@ export function registerChatStreamHandlers() {
await writeFile(filePath, Buffer.from(base64Data, "base64")); await writeFile(filePath, Buffer.from(base64Data, "base64"));
attachmentPaths.push(filePath); attachmentPaths.push(filePath);
attachmentInfo += `- ${attachment.name} (${attachment.type})\n`;
// If it's a text-based file, try to include the content if (attachment.attachmentType === "upload-to-codebase") {
if (await isTextFile(filePath)) { // For upload-to-codebase, create a unique file ID and store the mapping
try { const fileId = `DYAD_ATTACHMENT_${index}`;
attachmentInfo += `<dyad-text-attachment filename="${attachment.name}" type="${attachment.type}" path="${filePath}">
</dyad-text-attachment> fileUploadsState.addFileUpload(fileId, {
\n\n`; filePath,
} catch (err) { originalName: attachment.name,
logger.error(`Error reading file content: ${err}`); });
// 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 += `<dyad-text-attachment filename="${attachment.name}" type="${attachment.type}" path="${filePath}">
</dyad-text-attachment>
\n\n`;
} catch (err) {
logger.error(`Error reading file content: ${err}`);
}
} }
} }
} }
@@ -454,10 +473,35 @@ ${componentSnippet}
attachment.type.startsWith("image/"), 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-write path="path/to/destination/filename.ext" description="Upload file to codebase">
DYAD_ATTACHMENT_X
</dyad-write>
Example for file with id of DYAD_ATTACHMENT_0:
<dyad-write path="src/components/Button.jsx" description="Upload file to codebase">
DYAD_ATTACHMENT_0
</dyad-write>
`;
} else if (hasImageAttachments) {
systemPrompt += ` systemPrompt += `
# Image Analysis Capabilities # Image Analysis Instructions
This conversation includes one or more image attachments. When the user uploads images: 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. 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. 2. Describe what you see in the image if asked.
@@ -857,7 +901,10 @@ ${problemReport.problems
const status = await processFullResponseActions( const status = await processFullResponseActions(
fullResponse, fullResponse,
req.chatId, req.chatId,
{ chatSummary, messageId: placeholderAssistantMessage.id }, // Use placeholder ID {
chatSummary,
messageId: placeholderAssistantMessage.id,
}, // Use placeholder ID
); );
const chat = await db.query.chats.findFirst({ const chat = await db.query.chats.findFirst({
@@ -929,6 +976,8 @@ ${problemReport.problems
); );
// Clean up the abort controller // Clean up the abort controller
activeStreams.delete(req.chatId); activeStreams.delete(req.chatId);
// Clean up file uploads state on error
FileUploadsState.getInstance().clear();
return "error"; return "error";
} }
}); });

View File

@@ -50,6 +50,7 @@ import type {
SaveVercelAccessTokenParams, SaveVercelAccessTokenParams,
VercelProject, VercelProject,
UpdateChatParams, UpdateChatParams,
FileAttachment,
} from "./ipc_types"; } from "./ipc_types";
import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
@@ -258,7 +259,7 @@ export class IpcClient {
selectedComponent: ComponentSelection | null; selectedComponent: ComponentSelection | null;
chatId: number; chatId: number;
redo?: boolean; redo?: boolean;
attachments?: File[]; attachments?: FileAttachment[];
onUpdate: (messages: Message[]) => void; onUpdate: (messages: Message[]) => void;
onEnd: (response: ChatResponseEnd) => void; onEnd: (response: ChatResponseEnd) => void;
onError: (error: string) => void; onError: (error: string) => void;
@@ -278,24 +279,28 @@ export class IpcClient {
// Handle file attachments if provided // Handle file attachments if provided
if (attachments && attachments.length > 0) { if (attachments && attachments.length > 0) {
// Process each file and convert to base64 // Process each file attachment and convert to base64
Promise.all( Promise.all(
attachments.map(async (file) => { attachments.map(async (attachment) => {
return new Promise<{ name: string; type: string; data: string }>( return new Promise<{
(resolve, reject) => { name: string;
const reader = new FileReader(); type: string;
reader.onload = () => { data: string;
resolve({ attachmentType: "upload-to-codebase" | "chat-context";
name: file.name, }>((resolve, reject) => {
type: file.type, const reader = new FileReader();
data: reader.result as string, reader.onload = () => {
}); resolve({
}; name: attachment.file.name,
reader.onerror = () => type: attachment.file.type,
reject(new Error(`Failed to read file: ${file.name}`)); data: reader.result as string,
reader.readAsDataURL(file); attachmentType: attachment.type,
}, });
); };
reader.onerror = () =>
reject(new Error(`Failed to read file: ${attachment.file.name}`));
reader.readAsDataURL(attachment.file);
});
}), }),
) )
.then((fileDataArray) => { .then((fileDataArray) => {

View File

@@ -22,6 +22,7 @@ export interface ChatStreamParams {
name: string; name: string;
type: string; type: string;
data: string; // Base64 encoded file data data: string; // Base64 encoded file data
attachmentType: "upload-to-codebase" | "chat-context"; // FileAttachment type
}>; }>;
selectedComponent: ComponentSelection | null; selectedComponent: ComponentSelection | null;
} }
@@ -321,3 +322,20 @@ export interface UpdateChatParams {
chatId: number; chatId: number;
title: string; 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";
}

View File

@@ -26,6 +26,7 @@ import {
getDyadAddDependencyTags, getDyadAddDependencyTags,
getDyadExecuteSqlTags, getDyadExecuteSqlTags,
} from "../utils/dyad_tag_parser"; } from "../utils/dyad_tag_parser";
import { FileUploadsState } from "../utils/file_uploads_state";
const readFile = fs.promises.readFile; const readFile = fs.promises.readFile;
const logger = log.scope("response_processor"); const logger = log.scope("response_processor");
@@ -53,13 +54,19 @@ export async function processFullResponseActions(
{ {
chatSummary, chatSummary,
messageId, messageId,
}: { chatSummary: string | undefined; messageId: number }, }: {
chatSummary: string | undefined;
messageId: number;
},
): Promise<{ ): Promise<{
updatedFiles?: boolean; updatedFiles?: boolean;
error?: string; error?: string;
extraFiles?: string[]; extraFiles?: string[];
extraFilesError?: string; extraFilesError?: string;
}> { }> {
const fileUploadsState = FileUploadsState.getInstance();
const fileUploadsMap = fileUploadsState.getFileUploadsForChat(chatId);
fileUploadsState.clear();
logger.log("processFullResponseActions for chatId", chatId); logger.log("processFullResponseActions for chatId", chatId);
// Get the app associated with the chat // Get the app associated with the chat
const chatWithApp = await db.query.chats.findFirst({ const chatWithApp = await db.query.chats.findFirst({
@@ -289,9 +296,33 @@ export async function processFullResponseActions(
// Process all file writes // Process all file writes
for (const tag of dyadWriteTags) { for (const tag of dyadWriteTags) {
const filePath = tag.path; const filePath = tag.path;
const content = tag.content; let content: string | Buffer = tag.content;
const fullFilePath = safeJoin(appPath, filePath); 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 // Ensure directory exists
const dirPath = path.dirname(fullFilePath); const dirPath = path.dirname(fullFilePath);
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
@@ -300,7 +331,7 @@ export async function processFullResponseActions(
fs.writeFileSync(fullFilePath, content); fs.writeFileSync(fullFilePath, content);
logger.log(`Successfully wrote file: ${fullFilePath}`); logger.log(`Successfully wrote file: ${fullFilePath}`);
writtenFiles.push(filePath); writtenFiles.push(filePath);
if (isServerFunction(filePath)) { if (isServerFunction(filePath) && typeof content === "string") {
try { try {
await deploySupabaseFunctions({ await deploySupabaseFunctions({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,

View File

@@ -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<string, FileUploadInfo>();
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<string, FileUploadInfo> {
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");
}
}

View File

@@ -29,9 +29,11 @@ import { showError } from "@/lib/toast";
import { invalidateAppQuery } from "@/hooks/useLoadApp"; import { invalidateAppQuery } from "@/hooks/useLoadApp";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { FileAttachment } from "@/ipc/ipc_types";
// Adding an export for attachments // Adding an export for attachments
export interface HomeSubmitOptions { export interface HomeSubmitOptions {
attachments?: File[]; attachments?: FileAttachment[];
} }
export default function HomePage() { export default function HomePage() {

View File

@@ -24,6 +24,24 @@ export const createChatCompletionHandler =
} }
let messageContent = CANNED_MESSAGE; 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-write path="new/image/file.png" description="Uploaded image to codebase">
DYAD_ATTACHMENT_0
</dyad-write>
`;
messageContent += "\n\n" + generateDump(req);
}
// TS auto-fix prefixes // TS auto-fix prefixes
if ( if (