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,
|
ChevronsUpDown,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -54,6 +55,9 @@ import {
|
|||||||
} from "../ui/tooltip";
|
} from "../ui/tooltip";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useVersions } from "@/hooks/useVersions";
|
import { useVersions } from "@/hooks/useVersions";
|
||||||
|
import { useAttachments } from "@/hooks/useAttachments";
|
||||||
|
import { AttachmentsList } from "./AttachmentsList";
|
||||||
|
import { DragDropOverlay } from "./DragDropOverlay";
|
||||||
|
|
||||||
const showTokenBarAtom = atom(false);
|
const showTokenBarAtom = atom(false);
|
||||||
|
|
||||||
@@ -73,6 +77,20 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
|||||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||||
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
|
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
|
// Use the hook to fetch the proposal
|
||||||
const {
|
const {
|
||||||
proposalResult,
|
proposalResult,
|
||||||
@@ -118,13 +136,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!inputValue.trim() || isStreaming || !chatId) {
|
if (
|
||||||
|
(!inputValue.trim() && attachments.length === 0) ||
|
||||||
|
isStreaming ||
|
||||||
|
!chatId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentInput = inputValue;
|
const currentInput = inputValue;
|
||||||
setInputValue("");
|
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");
|
posthog.capture("chat:submit");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,7 +266,14 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<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 */}
|
{/* Only render ChatInputActions if proposal is loaded */}
|
||||||
{proposal && proposalResult?.chatId === chatId && (
|
{proposal && proposalResult?.chatId === chatId && (
|
||||||
<ChatInputActions
|
<ChatInputActions
|
||||||
@@ -255,6 +292,16 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
|||||||
isRejecting={isRejecting}
|
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 ">
|
<div className="flex items-start space-x-2 ">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -266,6 +313,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
|||||||
style={{ resize: "none" }}
|
style={{ resize: "none" }}
|
||||||
disabled={isStreaming}
|
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 ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
@@ -277,7 +343,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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"
|
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} />
|
<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 type React from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ModelPicker } from "@/components/ModelPicker";
|
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 { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
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 [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const { settings, updateSettings, isAnyProviderSetup } = useSettings();
|
const { settings, updateSettings, isAnyProviderSetup } = useSettings();
|
||||||
const { streamMessage, isStreaming, setIsStreaming } = useStreamChat({
|
const { streamMessage, isStreaming, setIsStreaming } = useStreamChat({
|
||||||
hasChatId: false,
|
hasChatId: false,
|
||||||
}); // eslint-disable-line @typescript-eslint/no-unused-vars
|
}); // 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 adjustHeight = () => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
@@ -30,10 +55,24 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
|||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
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) {
|
if (!settings) {
|
||||||
return null; // Or loading state
|
return null; // Or loading state
|
||||||
}
|
}
|
||||||
@@ -41,7 +80,23 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-4">
|
<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 ">
|
<div className="flex items-start space-x-2 ">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -53,6 +108,25 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
|
|||||||
style={{ resize: "none" }}
|
style={{ resize: "none" }}
|
||||||
disabled={isStreaming} // Should ideally reflect if *any* stream is happening
|
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 ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
className="px-2 py-2 mt-1 mr-2 text-(--sidebar-accent-fg) rounded-lg opacity-50 cursor-not-allowed" // Indicate disabled state
|
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>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onSubmit}
|
onClick={handleCustomSubmit}
|
||||||
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"
|
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"
|
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,
|
prompt,
|
||||||
chatId,
|
chatId,
|
||||||
redo,
|
redo,
|
||||||
|
attachments,
|
||||||
}: {
|
}: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
chatId: number;
|
chatId: number;
|
||||||
redo?: boolean;
|
redo?: boolean;
|
||||||
|
attachments?: File[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!prompt.trim() || !chatId) {
|
if (
|
||||||
|
(!prompt.trim() && (!attachments || attachments.length === 0)) ||
|
||||||
|
!chatId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +73,7 @@ export function useStreamChat({
|
|||||||
IpcClient.getInstance().streamMessage(prompt, {
|
IpcClient.getInstance().streamMessage(prompt, {
|
||||||
chatId,
|
chatId,
|
||||||
redo,
|
redo,
|
||||||
|
attachments,
|
||||||
onUpdate: (updatedMessages: Message[]) => {
|
onUpdate: (updatedMessages: Message[]) => {
|
||||||
if (!hasIncrementedStreamCount) {
|
if (!hasIncrementedStreamCount) {
|
||||||
setStreamCount((streamCount) => streamCount + 1);
|
setStreamCount((streamCount) => streamCount + 1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { CoreMessage, streamText } from "ai";
|
import { CoreMessage, TextPart, ImagePart, streamText } from "ai";
|
||||||
import { db } from "../../db";
|
import { db } from "../../db";
|
||||||
import { chats, messages } from "../../db/schema";
|
import { chats, messages } from "../../db/schema";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
@@ -22,6 +22,11 @@ import {
|
|||||||
getSupabaseClientCode,
|
getSupabaseClientCode,
|
||||||
} from "../../supabase_admin/supabase_context";
|
} from "../../supabase_admin/supabase_context";
|
||||||
import { SUMMARIZE_CHAT_SYSTEM_PROMPT } from "../../prompts/summarize_chat_system_prompt";
|
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");
|
const logger = log.scope("chat_stream_handlers");
|
||||||
|
|
||||||
@@ -31,6 +36,44 @@ const activeStreams = new Map<number, AbortController>();
|
|||||||
// Track partial responses for cancelled streams
|
// Track partial responses for cancelled streams
|
||||||
const partialResponses = new Map<number, string>();
|
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() {
|
export function registerChatStreamHandlers() {
|
||||||
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
|
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
|
||||||
try {
|
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
|
await db
|
||||||
.insert(messages)
|
.insert(messages)
|
||||||
.values({
|
.values({
|
||||||
chatId: req.chatId,
|
chatId: req.chatId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: req.prompt,
|
content: userPrompt,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -188,7 +268,28 @@ export function registerChatStreamHandlers() {
|
|||||||
if (isSummarizeIntent) {
|
if (isSummarizeIntent) {
|
||||||
systemPrompt = SUMMARIZE_CHAT_SYSTEM_PROMPT;
|
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",
|
role: "user",
|
||||||
content: "This is my codebase. " + codebaseInfo,
|
content: "This is my codebase. " + codebaseInfo,
|
||||||
@@ -197,8 +298,26 @@ export function registerChatStreamHandlers() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "OK, got it. I'm ready to help",
|
content: "OK, got it. I'm ready to help",
|
||||||
},
|
},
|
||||||
...messageHistory,
|
...messageHistory.map((msg) => ({
|
||||||
] satisfies CoreMessage[];
|
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) {
|
if (isSummarizeIntent) {
|
||||||
const previousChat = await db.query.chats.findFirst({
|
const previousChat = await db.query.chats.findFirst({
|
||||||
where: eq(chats.id, parseInt(req.prompt.split("=")[1])),
|
where: eq(chats.id, parseInt(req.prompt.split("=")[1])),
|
||||||
@@ -217,6 +336,8 @@ export function registerChatStreamHandlers() {
|
|||||||
} satisfies CoreMessage,
|
} satisfies CoreMessage,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When calling streamText, the messages need to be properly formatted for mixed content
|
||||||
const { textStream } = streamText({
|
const { textStream } = streamText({
|
||||||
maxTokens: getMaxTokens(settings.selectedModel),
|
maxTokens: getMaxTokens(settings.selectedModel),
|
||||||
temperature: 0,
|
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 the chat ID for backwards compatibility
|
||||||
return req.chatId;
|
return req.chatId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -418,3 +557,99 @@ export function formatMessages(
|
|||||||
.map((m) => `<message role="${m.role}">${m.content}</message>`)
|
.map((m) => `<message role="${m.role}">${m.content}</message>`)
|
||||||
.join("\n");
|
.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: {
|
options: {
|
||||||
chatId: number;
|
chatId: number;
|
||||||
redo?: boolean;
|
redo?: boolean;
|
||||||
|
attachments?: File[];
|
||||||
onUpdate: (messages: Message[]) => void;
|
onUpdate: (messages: Message[]) => void;
|
||||||
onEnd: (response: ChatResponseEnd) => void;
|
onEnd: (response: ChatResponseEnd) => void;
|
||||||
onError: (error: string) => void;
|
onError: (error: string) => void;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
const { chatId, onUpdate, onEnd, onError, redo } = options;
|
const { chatId, redo, attachments, onUpdate, onEnd, onError } = options;
|
||||||
this.chatStreams.set(chatId, { onUpdate, onEnd, onError });
|
this.chatStreams.set(chatId, { onUpdate, onEnd, onError });
|
||||||
|
|
||||||
// Use invoke to start the stream and pass the 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
|
this.ipcRenderer
|
||||||
.invoke("chat:stream", {
|
.invoke("chat:stream", {
|
||||||
prompt,
|
prompt,
|
||||||
chatId,
|
chatId,
|
||||||
redo,
|
redo,
|
||||||
} satisfies ChatStreamParams)
|
attachments: fileDataArray,
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showError(err);
|
showError(err);
|
||||||
onError(String(err));
|
onError(String(err));
|
||||||
this.chatStreams.delete(chatId);
|
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
|
// Method to cancel an ongoing stream
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export interface ChatStreamParams {
|
|||||||
chatId: number;
|
chatId: number;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
redo?: boolean;
|
redo?: boolean;
|
||||||
|
attachments?: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
data: string; // Base64 encoded file data
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponseEnd {
|
export interface ChatResponseEnd {
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import { useTheme } from "@/contexts/ThemeContext";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
// Adding an export for attachments
|
||||||
|
export interface HomeSubmitOptions {
|
||||||
|
attachments?: File[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
|
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -91,8 +96,10 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [appId, navigate]);
|
}, [appId, navigate]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (options?: HomeSubmitOptions) => {
|
||||||
if (!inputValue.trim()) return;
|
const attachments = options?.attachments || [];
|
||||||
|
|
||||||
|
if (!inputValue.trim() && attachments.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -101,8 +108,12 @@ export default function HomePage() {
|
|||||||
name: generateCuteAppName(),
|
name: generateCuteAppName(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stream the message
|
// Stream the message with attachments
|
||||||
streamMessage({ prompt: inputValue, chatId: result.chatId });
|
streamMessage({
|
||||||
|
prompt: inputValue,
|
||||||
|
chatId: result.chatId,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
|||||||
Reference in New Issue
Block a user