Support image/file attachments (#80)
This commit is contained in:
62
src/components/chat/AttachmentsList.tsx
Normal file
62
src/components/chat/AttachmentsList.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FileText, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface AttachmentsListProps {
|
||||
attachments: File[];
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
export function AttachmentsList({
|
||||
attachments,
|
||||
onRemove,
|
||||
}: AttachmentsListProps) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-2 pt-2 flex flex-wrap gap-1">
|
||||
{attachments.map((file, 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)`}
|
||||
>
|
||||
{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">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={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)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
<span className="truncate max-w-[120px]">{file.name}</span>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="hover:bg-muted-foreground/20 rounded-full p-0.5"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
ChevronsDownUp,
|
||||
BarChart2,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -54,6 +55,9 @@ import {
|
||||
} from "../ui/tooltip";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { useAttachments } from "@/hooks/useAttachments";
|
||||
import { AttachmentsList } from "./AttachmentsList";
|
||||
import { DragDropOverlay } from "./DragDropOverlay";
|
||||
|
||||
const showTokenBarAtom = atom(false);
|
||||
|
||||
@@ -73,6 +77,20 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
|
||||
|
||||
// Use the attachments hook
|
||||
const {
|
||||
attachments,
|
||||
fileInputRef,
|
||||
isDraggingOver,
|
||||
handleAttachmentClick,
|
||||
handleFileChange,
|
||||
removeAttachment,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAttachments,
|
||||
} = useAttachments();
|
||||
|
||||
// Use the hook to fetch the proposal
|
||||
const {
|
||||
proposalResult,
|
||||
@@ -118,13 +136,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inputValue.trim() || isStreaming || !chatId) {
|
||||
if (
|
||||
(!inputValue.trim() && attachments.length === 0) ||
|
||||
isStreaming ||
|
||||
!chatId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentInput = inputValue;
|
||||
setInputValue("");
|
||||
await streamMessage({ prompt: currentInput, chatId });
|
||||
|
||||
// Send message with attachments and clear them after sending
|
||||
await streamMessage({
|
||||
prompt: currentInput,
|
||||
chatId,
|
||||
attachments,
|
||||
redo: false,
|
||||
});
|
||||
clearAttachments();
|
||||
posthog.capture("chat:submit");
|
||||
};
|
||||
|
||||
@@ -236,7 +266,14 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col border border-border rounded-lg bg-(--background-lighter) shadow-sm">
|
||||
<div
|
||||
className={`relative flex flex-col border border-border rounded-lg bg-(--background-lighter) shadow-sm ${
|
||||
isDraggingOver ? "ring-2 ring-blue-500 border-blue-500" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Only render ChatInputActions if proposal is loaded */}
|
||||
{proposal && proposalResult?.chatId === chatId && (
|
||||
<ChatInputActions
|
||||
@@ -255,6 +292,16 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
isRejecting={isRejecting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Use the AttachmentsList component */}
|
||||
<AttachmentsList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
/>
|
||||
|
||||
{/* Use the DragDropOverlay component */}
|
||||
<DragDropOverlay isDraggingOver={isDraggingOver} />
|
||||
|
||||
<div className="flex items-start space-x-2 ">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -266,6 +313,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
style={{ resize: "none" }}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
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 ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
@@ -277,7 +343,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim() || !isAnyProviderSetup()}
|
||||
disabled={
|
||||
(!inputValue.trim() && attachments.length === 0) ||
|
||||
!isAnyProviderSetup()
|
||||
}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<SendIcon size={20} />
|
||||
|
||||
18
src/components/chat/DragDropOverlay.tsx
Normal file
18
src/components/chat/DragDropOverlay.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
interface DragDropOverlayProps {
|
||||
isDraggingOver: boolean;
|
||||
}
|
||||
|
||||
export function DragDropOverlay({ isDraggingOver }: DragDropOverlayProps) {
|
||||
if (!isDraggingOver) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-blue-100/30 dark:bg-blue-900/30 flex items-center justify-center rounded-lg z-10 pointer-events-none">
|
||||
<div className="bg-background p-4 rounded-lg shadow-lg text-center">
|
||||
<Paperclip className="mx-auto mb-2 text-blue-500" />
|
||||
<p className="text-sm font-medium">Drop files to attach</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SendIcon, StopCircleIcon, X } from "lucide-react";
|
||||
import { SendIcon, StopCircleIcon, X, Paperclip, Loader2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ModelPicker } from "@/components/ModelPicker";
|
||||
@@ -6,14 +6,39 @@ import { useSettings } from "@/hooks/useSettings";
|
||||
import { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input
|
||||
import { useAtom } from "jotai";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useAttachments } from "@/hooks/useAttachments";
|
||||
import { AttachmentsList } from "./AttachmentsList";
|
||||
import { DragDropOverlay } from "./DragDropOverlay";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { HomeSubmitOptions } from "@/pages/home";
|
||||
|
||||
export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
||||
export function HomeChatInput({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (options?: HomeSubmitOptions) => void;
|
||||
}) {
|
||||
const posthog = usePostHog();
|
||||
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { settings, updateSettings, isAnyProviderSetup } = useSettings();
|
||||
const { streamMessage, isStreaming, setIsStreaming } = useStreamChat({
|
||||
hasChatId: false,
|
||||
}); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
// Use the attachments hook
|
||||
const {
|
||||
attachments,
|
||||
fileInputRef,
|
||||
isDraggingOver,
|
||||
handleAttachmentClick,
|
||||
handleFileChange,
|
||||
removeAttachment,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAttachments,
|
||||
} = useAttachments();
|
||||
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
@@ -30,10 +55,24 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
handleCustomSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
// Custom submit function that wraps the provided onSubmit
|
||||
const handleCustomSubmit = () => {
|
||||
if ((!inputValue.trim() && attachments.length === 0) || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the parent's onSubmit handler with attachments
|
||||
onSubmit({ attachments });
|
||||
|
||||
// Clear attachments as part of submission process
|
||||
clearAttachments();
|
||||
posthog.capture("chat:home_submit");
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return null; // Or loading state
|
||||
}
|
||||
@@ -41,7 +80,23 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm">
|
||||
<div
|
||||
className={`relative flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm ${
|
||||
isDraggingOver ? "ring-2 ring-blue-500 border-blue-500" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Attachments list */}
|
||||
<AttachmentsList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
/>
|
||||
|
||||
{/* Drag and drop overlay */}
|
||||
<DragDropOverlay isDraggingOver={isDraggingOver} />
|
||||
|
||||
<div className="flex items-start space-x-2 ">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -53,6 +108,25 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
||||
style={{ resize: "none" }}
|
||||
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"
|
||||
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 ? (
|
||||
<button
|
||||
className="px-2 py-2 mt-1 mr-2 text-(--sidebar-accent-fg) rounded-lg opacity-50 cursor-not-allowed" // Indicate disabled state
|
||||
@@ -62,8 +136,11 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={!inputValue.trim() || !isAnyProviderSetup()}
|
||||
onClick={handleCustomSubmit}
|
||||
disabled={
|
||||
(!inputValue.trim() && attachments.length === 0) ||
|
||||
!isAnyProviderSetup()
|
||||
}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
title="Start new chat"
|
||||
>
|
||||
|
||||
58
src/hooks/useAttachments.ts
Normal file
58
src/hooks/useAttachments.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
export function useAttachments() {
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
const handleAttachmentClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const files = Array.from(e.target.files);
|
||||
setAttachments((attachments) => [...attachments, ...files]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments(attachments.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
setAttachments((attachments) => [...attachments, ...files]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAttachments = () => {
|
||||
setAttachments([]);
|
||||
};
|
||||
|
||||
return {
|
||||
attachments,
|
||||
fileInputRef,
|
||||
isDraggingOver,
|
||||
handleAttachmentClick,
|
||||
handleFileChange,
|
||||
removeAttachment,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearAttachments,
|
||||
};
|
||||
}
|
||||
@@ -52,12 +52,17 @@ export function useStreamChat({
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
attachments,
|
||||
}: {
|
||||
prompt: string;
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
attachments?: File[];
|
||||
}) => {
|
||||
if (!prompt.trim() || !chatId) {
|
||||
if (
|
||||
(!prompt.trim() && (!attachments || attachments.length === 0)) ||
|
||||
!chatId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,6 +73,7 @@ export function useStreamChat({
|
||||
IpcClient.getInstance().streamMessage(prompt, {
|
||||
chatId,
|
||||
redo,
|
||||
attachments,
|
||||
onUpdate: (updatedMessages: Message[]) => {
|
||||
if (!hasIncrementedStreamCount) {
|
||||
setStreamCount((streamCount) => streamCount + 1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { CoreMessage, streamText } from "ai";
|
||||
import { CoreMessage, TextPart, ImagePart, streamText } from "ai";
|
||||
import { db } from "../../db";
|
||||
import { chats, messages } from "../../db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
getSupabaseClientCode,
|
||||
} from "../../supabase_admin/supabase_context";
|
||||
import { SUMMARIZE_CHAT_SYSTEM_PROMPT } from "../../prompts/summarize_chat_system_prompt";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as crypto from "crypto";
|
||||
import { stat, readFile, writeFile, mkdir, unlink } from "fs/promises";
|
||||
|
||||
const logger = log.scope("chat_stream_handlers");
|
||||
|
||||
@@ -31,6 +36,44 @@ const activeStreams = new Map<number, AbortController>();
|
||||
// Track partial responses for cancelled streams
|
||||
const partialResponses = new Map<number, string>();
|
||||
|
||||
// Directory for storing temporary files
|
||||
const TEMP_DIR = path.join(os.tmpdir(), "dyad-attachments");
|
||||
|
||||
// Common helper functions
|
||||
const TEXT_FILE_EXTENSIONS = [
|
||||
".md",
|
||||
".txt",
|
||||
".json",
|
||||
".csv",
|
||||
".js",
|
||||
".ts",
|
||||
".html",
|
||||
".css",
|
||||
];
|
||||
|
||||
async function isTextFile(filePath: string): Promise<boolean> {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return TEXT_FILE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
// Ensure the temp directory exists
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// First, define the proper content types to match ai SDK
|
||||
type TextContent = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ImageContent = {
|
||||
type: "image";
|
||||
image: Buffer;
|
||||
};
|
||||
|
||||
type MessageContent = TextContent | ImageContent;
|
||||
|
||||
export function registerChatStreamHandlers() {
|
||||
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
|
||||
try {
|
||||
@@ -87,13 +130,50 @@ export function registerChatStreamHandlers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to database
|
||||
// Process attachments if any
|
||||
let attachmentInfo = "";
|
||||
let attachmentPaths: string[] = [];
|
||||
|
||||
if (req.attachments && req.attachments.length > 0) {
|
||||
attachmentInfo = "\n\nAttachments:\n";
|
||||
|
||||
for (const attachment of req.attachments) {
|
||||
// Generate a unique filename
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(attachment.name + Date.now())
|
||||
.digest("hex");
|
||||
const fileExtension = path.extname(attachment.name);
|
||||
const filename = `${hash}${fileExtension}`;
|
||||
const filePath = path.join(TEMP_DIR, filename);
|
||||
|
||||
// Extract the base64 data (remove the data:mime/type;base64, prefix)
|
||||
const base64Data = attachment.data.split(";base64,").pop() || "";
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to database with attachment info
|
||||
const userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
|
||||
await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
chatId: req.chatId,
|
||||
role: "user",
|
||||
content: req.prompt,
|
||||
content: userPrompt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -188,7 +268,28 @@ export function registerChatStreamHandlers() {
|
||||
if (isSummarizeIntent) {
|
||||
systemPrompt = SUMMARIZE_CHAT_SYSTEM_PROMPT;
|
||||
}
|
||||
let chatMessages = [
|
||||
|
||||
// Update the system prompt for images if there are image attachments
|
||||
const hasImageAttachments =
|
||||
req.attachments &&
|
||||
req.attachments.some((attachment) =>
|
||||
attachment.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (hasImageAttachments) {
|
||||
systemPrompt += `
|
||||
|
||||
# Image Analysis Capabilities
|
||||
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.
|
||||
3. You can use images as references when the user has coding or design-related questions.
|
||||
4. For diagrams or wireframes, try to understand the content and structure shown.
|
||||
5. For screenshots of code or errors, try to identify the issue or explain the code.
|
||||
`;
|
||||
}
|
||||
|
||||
let chatMessages: CoreMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "This is my codebase. " + codebaseInfo,
|
||||
@@ -197,8 +298,26 @@ export function registerChatStreamHandlers() {
|
||||
role: "assistant",
|
||||
content: "OK, got it. I'm ready to help",
|
||||
},
|
||||
...messageHistory,
|
||||
] satisfies CoreMessage[];
|
||||
...messageHistory.map((msg) => ({
|
||||
role: msg.role as "user" | "assistant" | "system",
|
||||
content: msg.content,
|
||||
})),
|
||||
];
|
||||
|
||||
// Check if the last message should include attachments
|
||||
if (chatMessages.length >= 2 && attachmentPaths.length > 0) {
|
||||
const lastUserIndex = chatMessages.length - 2;
|
||||
const lastUserMessage = chatMessages[lastUserIndex];
|
||||
|
||||
if (lastUserMessage.role === "user") {
|
||||
// Replace the last message with one that includes attachments
|
||||
chatMessages[lastUserIndex] = await prepareMessageWithAttachments(
|
||||
lastUserMessage,
|
||||
attachmentPaths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSummarizeIntent) {
|
||||
const previousChat = await db.query.chats.findFirst({
|
||||
where: eq(chats.id, parseInt(req.prompt.split("=")[1])),
|
||||
@@ -217,6 +336,8 @@ export function registerChatStreamHandlers() {
|
||||
} satisfies CoreMessage,
|
||||
];
|
||||
}
|
||||
|
||||
// When calling streamText, the messages need to be properly formatted for mixed content
|
||||
const { textStream } = streamText({
|
||||
maxTokens: getMaxTokens(settings.selectedModel),
|
||||
temperature: 0,
|
||||
@@ -374,6 +495,24 @@ export function registerChatStreamHandlers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any temporary files
|
||||
if (attachmentPaths.length > 0) {
|
||||
for (const filePath of attachmentPaths) {
|
||||
try {
|
||||
// We don't immediately delete files because they might be needed for reference
|
||||
// Instead, schedule them for deletion after some time
|
||||
setTimeout(async () => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
logger.log(`Deleted temporary file: ${filePath}`);
|
||||
}
|
||||
}, 30 * 60 * 1000); // Delete after 30 minutes
|
||||
} catch (error) {
|
||||
logger.error(`Error scheduling file deletion: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the chat ID for backwards compatibility
|
||||
return req.chatId;
|
||||
} catch (error) {
|
||||
@@ -418,3 +557,99 @@ export function formatMessages(
|
||||
.map((m) => `<message role="${m.role}">${m.content}</message>`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Helper function to replace text attachment placeholders with full content
|
||||
async function replaceTextAttachmentWithContent(
|
||||
text: string,
|
||||
filePath: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
if (await isTextFile(filePath)) {
|
||||
// Read the full content
|
||||
const fullContent = await readFile(filePath, "utf-8");
|
||||
|
||||
// Replace the placeholder tag with the full content
|
||||
const escapedPath = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const tagPattern = new RegExp(
|
||||
`<dyad-text-attachment filename="[^"]*" type="[^"]*" path="${escapedPath}">\\s*<\\/dyad-text-attachment>`,
|
||||
"g"
|
||||
);
|
||||
|
||||
const replacedText = text.replace(
|
||||
tagPattern,
|
||||
`Full content of ${fileName}:\n\`\`\`\n${fullContent}\n\`\`\``
|
||||
);
|
||||
|
||||
logger.log(
|
||||
`Replaced text attachment content for: ${fileName} - length before: ${text.length} - length after: ${replacedText.length}`
|
||||
);
|
||||
return replacedText;
|
||||
}
|
||||
return text;
|
||||
} catch (error) {
|
||||
logger.error(`Error processing text file: ${error}`);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert traditional message to one with proper image attachments
|
||||
async function prepareMessageWithAttachments(
|
||||
message: CoreMessage,
|
||||
attachmentPaths: string[]
|
||||
): Promise<CoreMessage> {
|
||||
let textContent = message.content;
|
||||
// Get the original text content
|
||||
if (typeof textContent !== "string") {
|
||||
logger.warn(
|
||||
"Message content is not a string - shouldn't happen but using message as-is"
|
||||
);
|
||||
return message;
|
||||
}
|
||||
|
||||
// Process text file attachments - replace placeholder tags with full content
|
||||
for (const filePath of attachmentPaths) {
|
||||
const fileName = path.basename(filePath);
|
||||
textContent = await replaceTextAttachmentWithContent(
|
||||
textContent,
|
||||
filePath,
|
||||
fileName
|
||||
);
|
||||
}
|
||||
|
||||
// For user messages with attachments, create a content array
|
||||
const contentParts: (TextPart | ImagePart)[] = [];
|
||||
|
||||
// Add the text part first with possibly modified content
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: textContent,
|
||||
});
|
||||
|
||||
// Add image parts for any image attachments
|
||||
for (const filePath of attachmentPaths) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) {
|
||||
try {
|
||||
// Read the file as a buffer
|
||||
const imageBuffer = await readFile(filePath);
|
||||
|
||||
// Add the image to the content parts
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
image: imageBuffer,
|
||||
});
|
||||
|
||||
logger.log(`Added image attachment: ${filePath}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error reading image file: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the message with the content array
|
||||
return {
|
||||
role: "user",
|
||||
content: contentParts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,26 +240,71 @@ export class IpcClient {
|
||||
options: {
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
attachments?: File[];
|
||||
onUpdate: (messages: Message[]) => void;
|
||||
onEnd: (response: ChatResponseEnd) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
): void {
|
||||
const { chatId, onUpdate, onEnd, onError, redo } = options;
|
||||
const { chatId, redo, attachments, onUpdate, onEnd, onError } = options;
|
||||
this.chatStreams.set(chatId, { onUpdate, onEnd, onError });
|
||||
|
||||
// Use invoke to start the stream and pass the chatId
|
||||
this.ipcRenderer
|
||||
.invoke("chat:stream", {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
} satisfies ChatStreamParams)
|
||||
.catch((err) => {
|
||||
showError(err);
|
||||
onError(String(err));
|
||||
this.chatStreams.delete(chatId);
|
||||
});
|
||||
// Handle file attachments if provided
|
||||
if (attachments && attachments.length > 0) {
|
||||
// Process each file 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);
|
||||
}
|
||||
);
|
||||
})
|
||||
)
|
||||
.then((fileDataArray) => {
|
||||
// Use invoke to start the stream and pass the chatId and attachments
|
||||
this.ipcRenderer
|
||||
.invoke("chat:stream", {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
attachments: fileDataArray,
|
||||
})
|
||||
.catch((err) => {
|
||||
showError(err);
|
||||
onError(String(err));
|
||||
this.chatStreams.delete(chatId);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
showError(err);
|
||||
onError(String(err));
|
||||
this.chatStreams.delete(chatId);
|
||||
});
|
||||
} else {
|
||||
// No attachments, proceed normally
|
||||
this.ipcRenderer
|
||||
.invoke("chat:stream", {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
})
|
||||
.catch((err) => {
|
||||
showError(err);
|
||||
onError(String(err));
|
||||
this.chatStreams.delete(chatId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Method to cancel an ongoing stream
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface ChatStreamParams {
|
||||
chatId: number;
|
||||
prompt: string;
|
||||
redo?: boolean;
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
data: string; // Base64 encoded file data
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ChatResponseEnd {
|
||||
|
||||
@@ -25,6 +25,11 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
// Adding an export for attachments
|
||||
export interface HomeSubmitOptions {
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
|
||||
const navigate = useNavigate();
|
||||
@@ -91,8 +96,10 @@ export default function HomePage() {
|
||||
}
|
||||
}, [appId, navigate]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
const handleSubmit = async (options?: HomeSubmitOptions) => {
|
||||
const attachments = options?.attachments || [];
|
||||
|
||||
if (!inputValue.trim() && attachments.length === 0) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -101,8 +108,12 @@ export default function HomePage() {
|
||||
name: generateCuteAppName(),
|
||||
});
|
||||
|
||||
// Stream the message
|
||||
streamMessage({ prompt: inputValue, chatId: result.chatId });
|
||||
// Stream the message with attachments
|
||||
streamMessage({
|
||||
prompt: inputValue,
|
||||
chatId: result.chatId,
|
||||
attachments,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
setInputValue("");
|
||||
|
||||
Reference in New Issue
Block a user