Upload image via chat (#686)
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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="}}]
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
133
src/components/chat/FileAttachmentDropdown.tsx
Normal file
133
src/components/chat/FileAttachmentDropdown.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
|||||||
66
src/ipc/utils/file_uploads_state.ts
Normal file
66
src/ipc/utils/file_uploads_state.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user