Unverified 提交 ac8ef73b authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Support image/file attachments (#80)

上级 0108ff1a
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 { ...@@ -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 { ...@@ -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 }) { ...@@ -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 }) { ...@@ -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 }) { ...@@ -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 }) { ...@@ -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 }) { ...@@ -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 }) { ...@@ -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} />
......
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>
);
}
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"; ...@@ -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 }) { ...@@ -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 }) { ...@@ -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 }) { ...@@ -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 }) { ...@@ -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"
> >
......
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({ ...@@ -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({ ...@@ -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);
......
...@@ -240,26 +240,71 @@ export class IpcClient { ...@@ -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 { ...@@ -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"; ...@@ -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() { ...@@ -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() { ...@@ -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("");
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论