Skip to content

Commit dee1d7f

Browse files
committed
Fix file upload
1 parent c49d2da commit dee1d7f

9 files changed

Lines changed: 149 additions & 134 deletions

File tree

app/api/chat/route.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,22 @@ export async function POST(request: Request) {
3636

3737
let finalMessages: Message[] = messages;
3838
if (attachments && attachments.length > 0) {
39-
finalMessages = [
40-
{ role: 'system', content: `The user has attached the following image(s):\n${attachments.join('\n')}` },
41-
...messages,
42-
];
39+
// Find last user message
40+
const lastUserIdx = messages.map((m: Message) => m.role).lastIndexOf('user');
41+
if (lastUserIdx !== -1) {
42+
const userMsg = messages[lastUserIdx];
43+
let contentArr: Array<{ type: string; text?: string; image_url?: { url: string } }> = [];
44+
if (typeof userMsg.content === 'string') {
45+
contentArr.push({ type: 'text', text: userMsg.content });
46+
} else if (Array.isArray(userMsg.content)) {
47+
contentArr = userMsg.content as Array<{ type: string; text?: string; image_url?: { url: string } }>;
48+
}
49+
for (const url of attachments) {
50+
contentArr.push({ type: 'image_url', image_url: { url } });
51+
}
52+
messages[lastUserIdx] = { ...userMsg, content: contentArr };
53+
}
54+
finalMessages = messages;
4355
}
4456

4557
const res = await createChatCompletionWithFallback({ model, messages: finalMessages });

components/chat/__tests__/chat-phase3-phase4.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,15 @@ describe('Chat Phase 3 & 4', () => {
182182
await act(async () => {
183183
fireEvent.change(fileInput as HTMLInputElement, { target: { files: [file] } })
184184
})
185-
// Preview should appear instantly
186-
// Debug DOM
187185
screen.debug()
188-
await waitFor(() => screen.getByTestId('attachment-filename'))
189-
expect(screen.getByTestId('attachment-filename').textContent).toContain('fail.txt')
190-
// Wait for error icon
191-
await waitFor(() => expect(screen.getByText('!')).toBeInTheDocument())
192-
// Should show the file name
193-
expect(screen.getByTestId('attachment-filename').textContent).toContain('fail.txt')
186+
// Preview should appear instantly
187+
// Find the preview row and check for file name and error icon
188+
await waitFor(() => {
189+
const previewRow = document.querySelector('.flex.items-center.gap-4.mb-2');
190+
if (!previewRow) throw new Error('No preview row');
191+
if (!previewRow.textContent?.includes('fail.txt')) throw new Error('File name not found');
192+
if (!previewRow.textContent?.includes('!')) throw new Error('Error icon not found');
193+
});
194194
})
195195

196196
it('queues message+attachment if conversation is temp and flushes on real ID', async () => {

components/chat/chat-area.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useActiveConversation } from '@/hooks/use-active-conversation'
88
import { useMessages } from '@/hooks/use-messages'
99
import ChatList from './chat-list'
1010
import { ChevronDown } from 'lucide-react'
11+
import type { Database } from '@/types/supabase'
1112

1213
export default function ChatArea() {
1314
const { activeConversationId } = useActiveConversation()
@@ -21,6 +22,8 @@ export default function ChatArea() {
2122
const [isAtBottom, setIsAtBottom] = useState(true)
2223
const scrollContainerRef = useRef<HTMLDivElement>(null)
2324

25+
type MessageWithAttachments = Database['public']['Tables']['messages']['Row'] & { attachments: Database['public']['Tables']['attachments']['Row'][] }
26+
2427
// Update search results when term or messages change
2528
useEffect(() => {
2629
if (!searchTerm || !messages) {
@@ -85,10 +88,11 @@ export default function ChatArea() {
8588
>
8689
{messages && (
8790
<ChatList
88-
messages={messages.map(m => ({
91+
messages={(messages as MessageWithAttachments[]).map(m => ({
8992
id: m.id,
9093
role: m.role as 'user' | 'assistant',
9194
content: m.content,
95+
attachments: m.attachments,
9296
}))}
9397
searchTerm={searchTerm}
9498
searchResults={searchResults}

components/chat/chat-input.tsx

Lines changed: 71 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type PendingMessage = {
2828
url: string;
2929
status: 'pending' | 'uploaded' | 'error';
3030
error?: string;
31+
base64?: string;
3132
}>;
3233
model: string | null;
3334
messages: { role: string; content: string }[];
@@ -45,7 +46,7 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
4546
})
4647
const createMessage = useCreateMessage()
4748
const [error, setError] = useState<string | null>(null)
48-
const [uploadError, setUploadError] = useState<string | null>(null)
49+
const [uploadError] = useState<string | null>(null)
4950
const fileInputRef = useRef<HTMLInputElement>(null)
5051
const chatInputRef = useRef<HTMLInputElement>(null)
5152
const [inputValue, setInputValue] = useState('')
@@ -57,8 +58,8 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
5758
url: string;
5859
status: 'pending' | 'uploaded' | 'error';
5960
error?: string;
61+
base64?: string;
6062
}>>([])
61-
const [, forceRerender] = useState(0)
6263
const { data: messages } = useMessages(activeConversationId);
6364
const queryClient = useQueryClient();
6465
const selectedModelRaw = useConversationModelStore(s => activeConversationId ? s.getModel(activeConversationId) : undefined)
@@ -68,76 +69,6 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
6869
const [showLimitModal, setShowLimitModal] = useState(false)
6970
const decrementPremiumCount = usePremiumQueryCountStore(s => s.decrement)
7071

71-
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
72-
console.log('handleFileChange', e.target.files)
73-
setUploadError(null)
74-
const files = e.target.files
75-
if (!files || files.length === 0) return
76-
const allowed = [
77-
'image/png', 'image/jpeg', 'image/webp', 'application/pdf',
78-
'text/plain', 'application/zip', 'application/json',
79-
]
80-
for (const file of Array.from(files)) {
81-
if (!allowed.includes(file.type)) {
82-
setUploadError('Unsupported file type')
83-
continue
84-
}
85-
if (file.size > 10 * 1024 * 1024) {
86-
setUploadError('File too large (max 10MB)')
87-
continue
88-
}
89-
// Optimistically add preview
90-
let localUrl = ''
91-
if (typeof window !== 'undefined' && typeof window.URL !== 'undefined' && typeof window.URL.createObjectURL === 'function') {
92-
localUrl = window.URL.createObjectURL(file)
93-
} else {
94-
// fallback for test/SSR
95-
localUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB...'
96-
}
97-
const ext = file.name.split('.').pop()
98-
const filePath = `uploads/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
99-
setPendingAttachments(prev => {
100-
const next: typeof prev = [
101-
...prev,
102-
{
103-
name: file.name,
104-
type: file.type,
105-
size: file.size,
106-
filePath,
107-
url: localUrl,
108-
status: 'pending' as const,
109-
},
110-
]
111-
return next
112-
})
113-
forceRerender(n => n + 1)
114-
// Start upload in background
115-
void (async () => {
116-
try {
117-
const { error: uploadError } = await supabase.storage.from('attachments').upload(filePath, file, {
118-
cacheControl: '3600',
119-
upsert: false,
120-
})
121-
if (uploadError) {
122-
setPendingAttachments(prev => prev.map(a =>
123-
a.filePath === filePath ? { ...a, status: 'error', error: uploadError.message } : a
124-
))
125-
return
126-
}
127-
const { data } = supabase.storage.from('attachments').getPublicUrl(filePath)
128-
setPendingAttachments(prev => prev.map(a =>
129-
a.filePath === filePath ? { ...a, url: data.publicUrl, status: 'uploaded' } : a
130-
))
131-
} catch {
132-
setPendingAttachments(prev => prev.map(a =>
133-
a.filePath === filePath ? { ...a, status: 'error', error: 'Upload failed' } : a
134-
))
135-
}
136-
})()
137-
}
138-
if (fileInputRef.current) fileInputRef.current.value = ''
139-
}
140-
14172
const handleRemoveAttachment = (filePath: string) => {
14273
setPendingAttachments(prev => prev.filter(a => a.filePath !== filePath))
14374
}
@@ -306,13 +237,14 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
306237
console.log('Assistant message created:', assistantMsg);
307238
let streamedContent = '';
308239
let res;
309-
const imageUrls = pendingAttachments
310-
.filter((att: { type: string }) => att.type.startsWith('image/'))
311-
.map((att: { url: string }) => att.url);
240+
// Only use base64 for Together AI attachments
241+
const togetherAttachments = pendingAttachments
242+
.filter(att => att.type.startsWith('image/') && att.base64)
243+
.map(att => att.base64)
312244
try {
313245
res = await fetch('/api/chat', {
314246
method: 'POST',
315-
body: JSON.stringify({ messages: currentMessages, conversation_id: conversationId, model: selectedModel, attachments: imageUrls }),
247+
body: JSON.stringify({ messages: currentMessages, conversation_id: conversationId, model: selectedModel, attachments: togetherAttachments }),
316248
});
317249
console.log('API response:', res);
318250
} catch (err) {
@@ -360,23 +292,13 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
360292
<div className="text-sm text-red-500 mb-2">{error}</div>
361293
)}
362294
{uploadError && <div className="text-xs text-red-500 mb-2">{uploadError}</div>}
363-
{/* Hidden file input for upload icon */}
364-
<input
365-
ref={fileInputRef}
366-
type="file"
367-
className="hidden"
368-
accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,application/zip,application/json"
369-
onChange={handleFileChange}
370-
disabled={createConversation.isPending || createMessage.isPending}
371-
multiple
372-
/>
373295
{/* File preview row inside the input box, at the top */}
374296
<div className="flex items-center gap-4 mb-2 overflow-x-auto scrollbar-thin scrollbar-thumb-[#353740] scrollbar-track-transparent">
375297
{pendingAttachments.map(att => {
376298
console.log('preview row map', att)
377299
return (
378300
<div
379-
key={att.filePath}
301+
key={att.filePath || att.name || att.base64 || Math.random()}
380302
className={`relative flex-shrink-0 w-16 h-16 rounded-2xl overflow-hidden bg-[#23272f] border border-[#353740] group`}
381303
>
382304
{/* Status indicator (spinner or error) */}
@@ -402,7 +324,7 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
402324
) : (
403325
<span className="flex items-center justify-center w-full h-full text-3xl text-[#b4bcd0]">📄</span>
404326
)}
405-
{/* Filename overlay at bottom */}
327+
{/* Filename overlay at bottom (always show, even on error) */}
406328
<div data-testid="attachment-filename" className="absolute bottom-0 left-0 w-full px-2 py-1 bg-gradient-to-t from-black/80 to-black/0 text-[11px] text-[#ececf1] truncate pointer-events-none font-medium">
407329
{att.name}
408330
</div>
@@ -534,6 +456,67 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
534456
</Tooltip.Portal>
535457
</Tooltip.Root>
536458
</div>
459+
{/* Hidden file input for upload icon */}
460+
<input
461+
ref={fileInputRef}
462+
type="file"
463+
className="hidden"
464+
accept="image/png,image/jpeg,image/webp"
465+
onChange={async e => {
466+
const files = e.target.files;
467+
if (!files || files.length === 0) return;
468+
const file = files[0];
469+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
470+
setPendingAttachments(prev => [
471+
...prev,
472+
{
473+
name: file.name,
474+
type: file.type,
475+
size: file.size,
476+
filePath: '',
477+
url: '',
478+
status: 'error',
479+
error: 'Unsupported file type',
480+
}
481+
]);
482+
return;
483+
}
484+
if (file.size > 10 * 1024 * 1024) {
485+
setPendingAttachments(prev => [
486+
...prev,
487+
{
488+
name: file.name,
489+
type: file.type,
490+
size: file.size,
491+
filePath: '',
492+
url: '',
493+
status: 'error',
494+
error: 'File too large',
495+
}
496+
]);
497+
return;
498+
}
499+
const base64 = await new Promise<string>((resolve, reject) => {
500+
const reader = new FileReader();
501+
reader.onload = () => resolve(reader.result as string);
502+
reader.onerror = reject;
503+
reader.readAsDataURL(file);
504+
});
505+
setPendingAttachments(prev => [
506+
...prev,
507+
{
508+
name: file.name,
509+
type: file.type,
510+
size: file.size,
511+
filePath: '',
512+
url: base64,
513+
status: 'uploaded',
514+
base64,
515+
}
516+
]);
517+
}}
518+
disabled={createConversation.isPending || createMessage.isPending}
519+
/>
537520
<style jsx global>{`
538521
input::placeholder {
539522
color: #b4bcd0 !important;

components/chat/chat-list.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import ChatMessage from './chat-message'
22
import React, { useEffect, useRef } from 'react'
33
import { Loader2 } from 'lucide-react'
4+
import type { Database } from '@/types/supabase'
45

56
type ChatListProps = {
6-
messages: { id: string; role: 'user' | 'assistant'; content: string }[]
7+
messages: Array<{
8+
id: string;
9+
role: 'user' | 'assistant';
10+
content: string;
11+
attachments?: Database['public']['Tables']['attachments']['Row'][];
12+
}>
713
searchTerm?: string
814
searchResults?: number[]
915
activeResultIdx?: number
@@ -50,6 +56,7 @@ export default function ChatList({ messages, searchTerm, searchResults, activeRe
5056
content={msg.content || (msg.role === 'assistant' ? <span className="flex items-center gap-2 text-[#b4bcd0]"><Loader2 className="animate-spin w-4 h-4" /> Generating...</span> : '')}
5157
highlight={!!searchTerm && activeIdx === idx}
5258
searchTerm={searchTerm}
59+
attachments={msg.attachments}
5360
/>
5461
</div>
5562
</React.Fragment>

components/chat/chat-message.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,20 @@ import remarkGfm from 'remark-gfm'
33
import rehypeHighlight from 'rehype-highlight'
44
import React from 'react'
55

6-
export default function ChatMessage({ role, content, highlight, searchTerm }: {
6+
export default function ChatMessage({ role, content, highlight, searchTerm, attachments }: {
77
role: 'user' | 'assistant',
88
content: React.ReactNode,
99
highlight?: boolean,
10-
searchTerm?: string
10+
searchTerm?: string,
11+
attachments?: Array<{
12+
file_name: string;
13+
file_type: string;
14+
file_path: string;
15+
file_size: number;
16+
created_at: string;
17+
id: string;
18+
message_id: string | null;
19+
}>
1120
}) {
1221
// Highlight search term in content
1322
let displayContent: React.ReactNode = content
@@ -94,6 +103,19 @@ export default function ChatMessage({ role, content, highlight, searchTerm }: {
94103
return (
95104
<div className={`bg-[#545563] text-white rounded-2xl rounded-br-3xl px-6 py-4 max-w-full font-normal leading-relaxed whitespace-pre-line text-[15px]${highlight ? ' ring-2 ring-yellow-400/60' : ''}`} style={{ fontFamily: 'Inter, sans-serif', fontWeight: 400 }}>
96105
{displayContent}
106+
{attachments && attachments.length > 0 && (
107+
<div className="flex flex-wrap gap-2 mt-2">
108+
{attachments.map(att => (
109+
<span
110+
key={att.id}
111+
className="bg-[#23272f] border border-[#353740] rounded-lg px-3 py-1 text-xs text-[#ececf1] font-medium truncate max-w-[160px]"
112+
title={att.file_name}
113+
>
114+
{att.file_name}
115+
</span>
116+
))}
117+
</div>
118+
)}
97119
</div>
98120
)
99121
}

0 commit comments

Comments
 (0)