Upload image via chat (#686)
This commit is contained in:
@@ -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 (
|
||||
<div className="px-2 pt-2 flex flex-wrap gap-1">
|
||||
{attachments.map((file, index) => (
|
||||
{attachments.map((attachment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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="relative group">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
onLoad={(e) =>
|
||||
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">
|
||||
<div className="flex items-center gap-1">
|
||||
{attachment.type === "upload-to-codebase" ? (
|
||||
<Upload size={12} className="text-blue-600" />
|
||||
) : (
|
||||
<MessageSquare size={12} className="text-green-600" />
|
||||
)}
|
||||
{attachment.file.type.startsWith("image/") ? (
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
className="max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg"
|
||||
src={URL.createObjectURL(attachment.file)}
|
||||
alt={attachment.file.name}
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
onLoad={(e) =>
|
||||
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>
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
<span className="truncate max-w-[120px]">{file.name}</span>
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate max-w-[120px]">{attachment.file.name}</span>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="hover:bg-muted-foreground/20 rounded-full p-0.5"
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Database,
|
||||
ChevronsUpDown,
|
||||
ChevronsDownUp,
|
||||
Paperclip,
|
||||
ChartColumnIncreasing,
|
||||
SendHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
@@ -58,6 +57,7 @@ import { useVersions } from "@/hooks/useVersions";
|
||||
import { useAttachments } from "@/hooks/useAttachments";
|
||||
import { AttachmentsList } from "./AttachmentsList";
|
||||
import { DragDropOverlay } from "./DragDropOverlay";
|
||||
import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
|
||||
import { showError, showExtraFilesToast } from "@/lib/toast";
|
||||
import { ChatInputControls } from "../ChatInputControls";
|
||||
import { ChatErrorBox } from "./ChatErrorBox";
|
||||
@@ -89,10 +89,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
// Use the attachments hook
|
||||
const {
|
||||
attachments,
|
||||
fileInputRef,
|
||||
isDraggingOver,
|
||||
handleAttachmentClick,
|
||||
handleFileChange,
|
||||
handleFileSelect,
|
||||
removeAttachment,
|
||||
handleDragOver,
|
||||
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="flex items-center">
|
||||
<ChatInputControls showContextFilesPicker={true} />
|
||||
{/* File attachment button */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"
|
||||
{/* File attachment dropdown */}
|
||||
<FileAttachmentDropdown
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</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 { 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 */}
|
||||
<button
|
||||
onClick={handleAttachmentClick}
|
||||
className="px-2 py-2 mt-1 mr-1 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
{/* File attachment dropdown */}
|
||||
<FileAttachmentDropdown
|
||||
className="mt-1 mr-1"
|
||||
onFileSelect={handleFileSelect}
|
||||
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 ? (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import type { FileAttachment } from "@/ipc/ipc_types";
|
||||
|
||||
export function useAttachments() {
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
@@ -9,15 +10,34 @@ export function useAttachments() {
|
||||
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) {
|
||||
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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<T> = AsyncIterable<T> & ReadableStream<T>;
|
||||
|
||||
@@ -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 += `<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}`);
|
||||
|
||||
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 += `<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/"),
|
||||
);
|
||||
|
||||
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 += `
|
||||
|
||||
# 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";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
|
||||
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 { 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() {
|
||||
|
||||
Reference in New Issue
Block a user