Skip to content

Commit 8650843

Browse files
committed
file upload limits
1 parent 74150ed commit 8650843

4 files changed

Lines changed: 133 additions & 12 deletions

File tree

frontend/app/aipanel/ai-utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,53 @@ export const createDataUrl = (file: File): Promise<string> => {
135135
reader.onerror = reject;
136136
reader.readAsDataURL(file);
137137
});
138+
};
139+
140+
export interface FileSizeError {
141+
fileName: string;
142+
fileSize: number;
143+
maxSize: number;
144+
fileType: 'text' | 'pdf' | 'image';
145+
}
146+
147+
export const validateFileSize = (file: File): FileSizeError | null => {
148+
const TEXT_FILE_LIMIT = 200 * 1024; // 200KB
149+
const PDF_LIMIT = 5 * 1024 * 1024; // 5MB
150+
const IMAGE_LIMIT = 10 * 1024 * 1024; // 10MB
151+
152+
if (file.type.startsWith('image/')) {
153+
if (file.size > IMAGE_LIMIT) {
154+
return {
155+
fileName: file.name,
156+
fileSize: file.size,
157+
maxSize: IMAGE_LIMIT,
158+
fileType: 'image'
159+
};
160+
}
161+
} else if (file.type === 'application/pdf') {
162+
if (file.size > PDF_LIMIT) {
163+
return {
164+
fileName: file.name,
165+
fileSize: file.size,
166+
maxSize: PDF_LIMIT,
167+
fileType: 'pdf'
168+
};
169+
}
170+
} else {
171+
if (file.size > TEXT_FILE_LIMIT) {
172+
return {
173+
fileName: file.name,
174+
fileSize: file.size,
175+
maxSize: TEXT_FILE_LIMIT,
176+
fileType: 'text'
177+
};
178+
}
179+
}
180+
181+
return null;
182+
};
183+
184+
export const formatFileSizeError = (error: FileSizeError): string => {
185+
const typeLabel = error.fileType === 'image' ? 'Image' : error.fileType === 'pdf' ? 'PDF' : 'Text file';
186+
return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`;
138187
};

frontend/app/aipanel/aipanel.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useChat } from "@ai-sdk/react";
1111
import { DefaultChatTransport } from "ai";
1212
import * as jotai from "jotai";
1313
import { memo, useEffect, useRef, useState } from "react";
14-
import { createDataUrl, isAcceptableFile, normalizeMimeType } from "./ai-utils";
14+
import { createDataUrl, formatFileSizeError, isAcceptableFile, normalizeMimeType, validateFileSize } from "./ai-utils";
1515
import { AIDroppedFiles } from "./aidroppedfiles";
1616
import { AIPanelHeader } from "./aipanelheader";
1717
import { AIPanelInput, type AIPanelInputRef } from "./aipanelinput";
@@ -27,8 +27,8 @@ interface AIPanelProps {
2727
const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
2828
const [input, setInput] = useState("");
2929
const [isDragOver, setIsDragOver] = useState(false);
30-
const [errorMessage, setErrorMessage] = useState<string>("");
3130
const model = WaveAIModel.getInstance();
31+
const errorMessage = jotai.useAtomValue(model.errorMessage);
3232
const realMessageRef = useRef<AIMessage>(null);
3333
const inputRef = useRef<AIPanelInputRef>(null);
3434
const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom);
@@ -54,8 +54,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
5454
}),
5555
onError: (error) => {
5656
console.error("AI Chat error:", error);
57-
setErrorMessage(error.message || "An error occurred");
58-
// Remove the last user message that failed to send
57+
model.setError(error.message || "An error occurred");
5958
setMessages((prevMessages) => {
6059
if (prevMessages.length > 0 && prevMessages[prevMessages.length - 1].role === "user") {
6160
return prevMessages.slice(0, -1);
@@ -96,8 +95,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
9695
e.preventDefault();
9796
if (!input.trim() || status !== "ready") return;
9897

99-
// Clear any previous error when submitting
100-
setErrorMessage("");
98+
model.clearError();
10199

102100
const droppedFiles = globalStore.get(model.droppedFiles) as DroppedFile[];
103101

@@ -203,9 +201,14 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
203201
const files = Array.from(e.dataTransfer.files);
204202
const acceptableFiles = files.filter(isAcceptableFile);
205203

206-
acceptableFiles.forEach((file) => {
204+
for (const file of acceptableFiles) {
205+
const sizeError = validateFileSize(file);
206+
if (sizeError) {
207+
model.setError(formatFileSizeError(sizeError));
208+
return;
209+
}
207210
model.addFile(file);
208-
});
211+
}
209212

210213
if (acceptableFiles.length < files.length) {
211214
console.warn(`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`);
@@ -291,8 +294,15 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
291294
<>
292295
<AIPanelMessages messages={messages} status={status} />
293296
{errorMessage && (
294-
<div className="px-4 py-2 text-red-400 bg-red-900/20 border-l-4 border-red-500 mx-2 mb-2">
295-
<div className="text-sm">{errorMessage}</div>
297+
<div className="px-4 py-2 text-red-400 bg-red-900/20 border-l-4 border-red-500 mx-2 mb-2 relative">
298+
<button
299+
onClick={() => model.clearError()}
300+
className="absolute top-2 right-2 text-red-400 hover:text-red-300 cursor-pointer z-10"
301+
aria-label="Close error"
302+
>
303+
<i className="fa fa-times text-sm"></i>
304+
</button>
305+
<div className="text-sm pr-6 max-h-[100px] overflow-y-auto">{errorMessage}</div>
296306
</div>
297307
)}
298308
<AIDroppedFiles model={model} />
@@ -302,6 +312,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
302312
setInput={setInput}
303313
onSubmit={handleSubmit}
304314
status={status}
315+
model={model}
305316
/>
306317
</>
307318
)}

frontend/app/aipanel/aipanelinput.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { formatFileSizeError, isAcceptableFile, validateFileSize } from "@/app/aipanel/ai-utils";
5+
import { type WaveAIModel } from "@/app/aipanel/waveai-model";
46
import { atoms, globalStore } from "@/app/store/global";
57
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
68
import { cn } from "@/util/util";
@@ -12,6 +14,7 @@ interface AIPanelInputProps {
1214
setInput: (value: string) => void;
1315
onSubmit: (e: React.FormEvent) => void;
1416
status: string;
17+
model: WaveAIModel;
1518
}
1619

1720
export interface AIPanelInputRef {
@@ -20,9 +23,10 @@ export interface AIPanelInputRef {
2023
}
2124

2225
export const AIPanelInput = memo(
23-
forwardRef<AIPanelInputRef, AIPanelInputProps>(({ input, setInput, onSubmit, status }, ref) => {
26+
forwardRef<AIPanelInputRef, AIPanelInputProps>(({ input, setInput, onSubmit, status, model }, ref) => {
2427
const isFocused = useAtomValue(atoms.waveAIFocusedAtom);
2528
const textareaRef = useRef<HTMLTextAreaElement>(null);
29+
const fileInputRef = useRef<HTMLInputElement>(null);
2630
const isPanelOpen = useAtomValue(workspaceLayoutModel.panelVisibleAtom);
2731

2832
const resizeTextarea = useCallback(() => {
@@ -68,8 +72,47 @@ export const AIPanelInput = memo(
6872
}
6973
}, [isPanelOpen, resizeTextarea]);
7074

75+
const handleUploadClick = () => {
76+
fileInputRef.current?.click();
77+
};
78+
79+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
80+
const files = Array.from(e.target.files || []);
81+
const acceptableFiles = files.filter(isAcceptableFile);
82+
83+
for (const file of acceptableFiles) {
84+
const sizeError = validateFileSize(file);
85+
if (sizeError) {
86+
model.setError(formatFileSizeError(sizeError));
87+
if (e.target) {
88+
e.target.value = "";
89+
}
90+
return;
91+
}
92+
model.addFile(file);
93+
}
94+
95+
if (acceptableFiles.length < files.length) {
96+
console.warn(
97+
`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`
98+
);
99+
}
100+
101+
if (e.target) {
102+
e.target.value = "";
103+
}
104+
};
105+
71106
return (
72107
<div className={cn("border-t", isFocused ? "border-accent/50" : "border-gray-600")}>
108+
<input
109+
ref={fileInputRef}
110+
type="file"
111+
multiple
112+
accept="image/*,.pdf,.txt,.md,.js,.jsx,.ts,.tsx,.go,.py,.java,.c,.cpp,.h,.hpp,.html,.css,.scss,.sass,.json,.xml,.yaml,.yml,.sh,.bat,.sql"
113+
onChange={handleFileChange}
114+
className="hidden"
115+
/>
73116
<form onSubmit={onSubmit}>
74117
<div className="relative">
75118
<textarea
@@ -81,12 +124,21 @@ export const AIPanelInput = memo(
81124
onBlur={handleBlur}
82125
placeholder="Ask Wave AI anything..."
83126
className={cn(
84-
"w-full text-white px-2 py-2 pr-6 focus:outline-none resize-none overflow-hidden",
127+
"w-full text-white px-2 py-2 pr-12 focus:outline-none resize-none overflow-hidden",
85128
isFocused ? "bg-accent-900/50" : "bg-gray-800"
86129
)}
87130
style={{ fontSize: "13px" }}
88131
rows={2}
89132
/>
133+
<button
134+
type="button"
135+
onClick={handleUploadClick}
136+
className={cn(
137+
"absolute bottom-6 right-1 w-3.5 h-3.5 transition-colors flex items-center justify-center text-gray-400 hover:text-accent cursor-pointer"
138+
)}
139+
>
140+
<i className="fa fa-paperclip text-xs"></i>
141+
</button>
90142
<button
91143
type="submit"
92144
disabled={status !== "ready" || !input.trim()}

frontend/app/aipanel/waveai-model.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class WaveAIModel {
2323
widgetAccess: jotai.PrimitiveAtom<boolean> = jotai.atom(true);
2424
droppedFiles: jotai.PrimitiveAtom<DroppedFile[]> = jotai.atom([]);
2525
chatId: jotai.PrimitiveAtom<string> = jotai.atom(crypto.randomUUID());
26+
errorMessage: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>;
2627

2728
private constructor() {
2829
// Private constructor prevents direct instantiation
@@ -90,6 +91,14 @@ export class WaveAIModel {
9091
globalStore.set(this.chatId, crypto.randomUUID());
9192
}
9293

94+
setError(message: string) {
95+
globalStore.set(this.errorMessage, message);
96+
}
97+
98+
clearError() {
99+
globalStore.set(this.errorMessage, null);
100+
}
101+
93102
registerInputRef(ref: React.RefObject<AIPanelInputRef>) {
94103
this.inputRef = ref;
95104
}

0 commit comments

Comments
 (0)