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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user