Skip to content

Commit cd43f80

Browse files
authored
Merge pull request #67 from DEEIX-AI/file_upload
feat: add file drag and drop functionality to chat input
2 parents a26b420 + f62b073 commit cd43f80

4 files changed

Lines changed: 79 additions & 3 deletions

File tree

frontend/features/chat/components/app-chat-area.tsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ import { cn } from "@/lib/utils";
5151
const MODEL_OPTIONS_STORAGE_PREFIX = "deeix-chat:chat-model-options:";
5252
const EMPTY_CONVERSATION_OPTIONS: ConversationOptions = {};
5353

54+
function dragEventContainsFiles(event: React.DragEvent<HTMLElement>): boolean {
55+
return Array.from(event.dataTransfer.types ?? []).includes("Files");
56+
}
57+
58+
function droppedFiles(event: React.DragEvent<HTMLElement>): File[] {
59+
return Array.from(event.dataTransfer.files ?? []).filter((file) => file.name.trim() || file.size > 0);
60+
}
61+
5462
function modelOptionsStorageKey(platformModelName: string): string {
5563
return `${MODEL_OPTIONS_STORAGE_PREFIX}${encodeURIComponent(platformModelName)}`;
5664
}
@@ -215,6 +223,8 @@ export function AppChatArea() {
215223
const [selectedToolIDs, setSelectedToolIDs] = React.useState<number[]>([]);
216224
const htmlVisualPrompt = useHTMLVisualPrompt();
217225
const initializedOptionsModelRef = React.useRef("");
226+
const fileDragDepthRef = React.useRef(0);
227+
const [fileDragActive, setFileDragActive] = React.useState(false);
218228

219229
React.useEffect(() => {
220230
setSelectedToolIDs((current) => {
@@ -359,6 +369,7 @@ export function AppChatArea() {
359369
activeGenerationRunsRef,
360370
});
361371
const generating = sending || Boolean(resumingRunID);
372+
const uploadDropDisabled = generating || loading || uploading;
362373
const showLiveAssistant = showPendingAssistant || Boolean(resumingRunID);
363374
const latestMessageKey = visibleMessages.at(-1)?.key ?? "";
364375
const onStopActiveMessage = React.useCallback(() => {
@@ -637,6 +648,59 @@ export function AppChatArea() {
637648
const selectedModelDefaultOptions = modelOptionPolicyDisabled
638649
? EMPTY_CONVERSATION_OPTIONS
639650
: (selectedModel?.defaultOptions ?? EMPTY_CONVERSATION_OPTIONS);
651+
const resetFileDragState = React.useCallback(() => {
652+
fileDragDepthRef.current = 0;
653+
setFileDragActive(false);
654+
}, []);
655+
const onFileDragEnter = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
656+
if (!dragEventContainsFiles(event)) {
657+
return;
658+
}
659+
event.preventDefault();
660+
event.stopPropagation();
661+
if (uploadDropDisabled) {
662+
return;
663+
}
664+
fileDragDepthRef.current += 1;
665+
setFileDragActive(true);
666+
}, [uploadDropDisabled]);
667+
const onFileDragOver = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
668+
if (!dragEventContainsFiles(event)) {
669+
return;
670+
}
671+
event.preventDefault();
672+
event.stopPropagation();
673+
event.dataTransfer.dropEffect = uploadDropDisabled ? "none" : "copy";
674+
}, [uploadDropDisabled]);
675+
const onFileDragLeave = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
676+
if (!dragEventContainsFiles(event)) {
677+
return;
678+
}
679+
event.preventDefault();
680+
event.stopPropagation();
681+
fileDragDepthRef.current = Math.max(0, fileDragDepthRef.current - 1);
682+
if (fileDragDepthRef.current === 0) {
683+
setFileDragActive(false);
684+
}
685+
}, []);
686+
const onFileDrop = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
687+
if (!dragEventContainsFiles(event)) {
688+
return;
689+
}
690+
event.preventDefault();
691+
event.stopPropagation();
692+
const files = droppedFiles(event);
693+
resetFileDragState();
694+
if (uploadDropDisabled || files.length === 0) {
695+
return;
696+
}
697+
void onUploadFiles(files);
698+
}, [onUploadFiles, resetFileDragState, uploadDropDisabled]);
699+
React.useEffect(() => {
700+
if (uploadDropDisabled) {
701+
resetFileDragState();
702+
}
703+
}, [resetFileDragState, uploadDropDisabled]);
640704

641705
const chatInputProps = {
642706
draft,
@@ -661,6 +725,7 @@ export function AppChatArea() {
661725
defaultOptions: selectedModelDefaultOptions,
662726
modelOptionPolicy,
663727
modelLoading: modelsLoading,
728+
dropActive: fileDragActive,
664729
onDraftChange: setDraft,
665730
onModelChange: setSelectedPlatformModelName,
666731
onSelectedToolsChange: setSelectedToolIDs,
@@ -679,7 +744,13 @@ export function AppChatArea() {
679744
!isConversationLoading && !isConversationLoadFailed && !isConversationMode && messagesWithInlineError.length === 0;
680745

681746
return (
682-
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:overflow-visible">
747+
<div
748+
className="relative flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:overflow-visible"
749+
onDragEnter={onFileDragEnter}
750+
onDragOver={onFileDragOver}
751+
onDragLeave={onFileDragLeave}
752+
onDrop={onFileDrop}
753+
>
683754
{shouldUseCenteredComposer ? (
684755
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
685756
<ChatEmptyState greetingTitle={greetingTitle}>

frontend/features/chat/components/sections/chat-input.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ type ChatInputProps = {
7676
modelOptionPolicy: ModelOptionPolicy | null;
7777
modelLoading: boolean;
7878
modelDisabled?: boolean;
79+
dropActive?: boolean;
7980
onDraftChange: (value: string) => void;
8081
onModelChange: (platformModelName: string) => void;
8182
onSelectedToolsChange: (toolIDs: number[]) => void;
@@ -178,6 +179,7 @@ function ChatInputComponent({
178179
modelOptionPolicy,
179180
modelLoading,
180181
modelDisabled = false,
182+
dropActive = false,
181183
onDraftChange,
182184
onModelChange,
183185
onSelectedToolsChange,
@@ -262,6 +264,7 @@ function ChatInputComponent({
262264
<InputGroup
263265
className={cn(
264266
"bg-pure rounded-3xl border-[0.5px] border-border/70 shadow-xs has-[[data-slot=input-group-control]:focus-visible]:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-border",
267+
dropActive && "border-dashed border-foreground/30 bg-muted/20 shadow-none",
265268
)}
266269
>
267270
{attachments.length > 0 || uploadingAttachments.length > 0 ? (
@@ -379,11 +382,11 @@ function ChatInputComponent({
379382
value={draft}
380383
disabled={sending || loading || uploading}
381384
readOnly={speechInput.active}
382-
placeholder={speechInput.placeholder}
385+
placeholder={dropActive ? tChat("attachments.dropTitle") : speechInput.placeholder}
383386
rows={1}
384387
style={{ fontFamily: "var(--font-chat)", fontWeight: "var(--font-chat-weight)" }}
385388
className={cn(
386-
"rounded-3xl min-h-12 overflow-y-auto px-5 pt-4 text-[15px] leading-6 placeholder:text-[15px] placeholder:font-[inherit] placeholder:leading-6",
389+
"rounded-3xl min-h-12 overflow-y-auto px-5 pt-4 text-[15px] leading-6 placeholder:text-[inherit] placeholder:font-[inherit] placeholder:leading-[inherit]",
387390
inputHeightClassName,
388391
speechInput.active ? "placeholder:font-normal placeholder:text-muted-foreground" : "",
389392
)}

frontend/i18n/messages/en-US/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@
195195
"autoTruncatedDescription": "Kept the first {max} attachments and ignored {count} files over the limit.",
196196
"uploadFailed": "Upload failed",
197197
"uploadSignInRequired": "Sign in before uploading files.",
198+
"dropTitle": "Drop files to attach",
198199
"duplicateReused": "Duplicate file detected and reused",
199200
"partialUploadFailed": "Some files failed to upload",
200201
"retryFailedFiles": "Please retry the failed files.",

frontend/i18n/messages/zh-CN/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@
195195
"autoTruncatedDescription": "最多保留前 {max} 个附件,已忽略 {count} 个超出上限的文件。",
196196
"uploadFailed": "上传失败",
197197
"uploadSignInRequired": "未登录,无法上传文件。",
198+
"dropTitle": "松开以添加附件",
198199
"duplicateReused": "检测到文件重复,已复用",
199200
"partialUploadFailed": "部分文件上传失败",
200201
"retryFailedFiles": "请重试失败的文件。",

0 commit comments

Comments
 (0)