Spaces:
Running
Running
Merge pull request #7 from landing-ai/feat/upload-api
Browse files- app/api/upload/route.ts +53 -27
- components/chat-sidebar/ChatCard.tsx +1 -1
- components/chat/ImageSelector.tsx +27 -1
- lib/aws.ts +43 -0
- lib/hooks/useImageUpload.ts +46 -43
- package.json +3 -0
- pnpm-lock.yaml +0 -0
app/api/upload/route.ts
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
import { auth } from '@/auth';
|
|
|
2 |
import { ChatEntity, MessageBase } from '@/lib/types';
|
3 |
import { nanoid } from '@/lib/utils';
|
4 |
import { kv } from '@vercel/kv';
|
@@ -17,31 +18,56 @@ export async function POST(req: Request): Promise<Response> {
|
|
17 |
});
|
18 |
}
|
19 |
|
20 |
-
|
21 |
-
url
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
}
|
|
|
1 |
import { auth } from '@/auth';
|
2 |
+
import { upload } from '@/lib/aws';
|
3 |
import { ChatEntity, MessageBase } from '@/lib/types';
|
4 |
import { nanoid } from '@/lib/utils';
|
5 |
import { kv } from '@vercel/kv';
|
|
|
18 |
});
|
19 |
}
|
20 |
|
21 |
+
try {
|
22 |
+
const { url, base64, initMessages, fileType } = (await req.json()) as {
|
23 |
+
url?: string;
|
24 |
+
file?: File;
|
25 |
+
base64?: string;
|
26 |
+
fileType?: string;
|
27 |
+
initMessages?: MessageBase[];
|
28 |
+
};
|
29 |
+
|
30 |
+
if (!url && !base64) {
|
31 |
+
return new Response('Missing both url and base64 in payload', {
|
32 |
+
status: 400,
|
33 |
+
});
|
34 |
+
}
|
35 |
+
|
36 |
+
const id = nanoid();
|
37 |
+
|
38 |
+
let urlToSave = url;
|
39 |
+
if (base64) {
|
40 |
+
const fileName = `${email}/${id}/${Date.now()}-image.jpg`;
|
41 |
+
const res = await upload(base64, fileName, fileType ?? 'image/png');
|
42 |
+
if (res.ok) {
|
43 |
+
console.log('Image uploaded successfully');
|
44 |
+
urlToSave = `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${fileName}`;
|
45 |
+
} else {
|
46 |
+
throw new Error('Failed to upload image');
|
47 |
+
}
|
48 |
+
}
|
49 |
+
|
50 |
+
const payload: ChatEntity = {
|
51 |
+
url: urlToSave!, // TODO can be uploaded as well
|
52 |
+
id,
|
53 |
+
user: email,
|
54 |
+
messages: initMessages ?? [],
|
55 |
+
};
|
56 |
+
|
57 |
+
await kv.hmset(`chat:${id}`, payload);
|
58 |
+
await kv.zadd(`user:chat:${email}`, {
|
59 |
+
score: Date.now(),
|
60 |
+
member: `chat:${id}`,
|
61 |
+
});
|
62 |
+
await kv.zadd('user:chat:all', {
|
63 |
+
score: Date.now(),
|
64 |
+
member: `chat:${id}`,
|
65 |
+
});
|
66 |
+
|
67 |
+
return Response.json(payload);
|
68 |
+
} catch (error) {
|
69 |
+
return new Response((error as Error).message, {
|
70 |
+
status: 400,
|
71 |
+
});
|
72 |
+
}
|
73 |
}
|
components/chat-sidebar/ChatCard.tsx
CHANGED
@@ -30,7 +30,7 @@ const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
|
|
30 |
className="rounded w-1/4 "
|
31 |
/>
|
32 |
<p className="text-xs text-gray-500 w-3/4 ml-2">
|
33 |
-
{messages?.[0]
|
34 |
</p>
|
35 |
</div>
|
36 |
</Link>
|
|
|
30 |
className="rounded w-1/4 "
|
31 |
/>
|
32 |
<p className="text-xs text-gray-500 w-3/4 ml-2">
|
33 |
+
{messages?.[0]?.content.slice(0, 50) + ' ...' ?? 'new chat'}
|
34 |
</p>
|
35 |
</div>
|
36 |
</Link>
|
components/chat/ImageSelector.tsx
CHANGED
@@ -32,7 +32,30 @@ const examples: Example[] = [
|
|
32 |
|
33 |
const ImageSelector: React.FC<ImageSelectorProps> = () => {
|
34 |
const router = useRouter();
|
35 |
-
const { getRootProps, getInputProps } = useImageUpload(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
return (
|
37 |
<div className="mx-auto max-w-2xl px-4 mt-8">
|
38 |
<div className="rounded-lg border bg-background p-8">
|
@@ -62,6 +85,9 @@ const ImageSelector: React.FC<ImageSelectorProps> = () => {
|
|
62 |
onClick={async () => {
|
63 |
const resp = await fetcher<ChatEntity>('/api/upload', {
|
64 |
method: 'POST',
|
|
|
|
|
|
|
65 |
body: JSON.stringify({ url, initMessages }),
|
66 |
});
|
67 |
if (resp) {
|
|
|
32 |
|
33 |
const ImageSelector: React.FC<ImageSelectorProps> = () => {
|
34 |
const router = useRouter();
|
35 |
+
const { getRootProps, getInputProps } = useImageUpload(
|
36 |
+
undefined,
|
37 |
+
async files => {
|
38 |
+
const formData = new FormData();
|
39 |
+
if (files.length !== 1) {
|
40 |
+
throw new Error('Only one image can be uploaded at a time');
|
41 |
+
}
|
42 |
+
console.log();
|
43 |
+
const reader = new FileReader();
|
44 |
+
reader.readAsDataURL(files[0]);
|
45 |
+
reader.onload = async () => {
|
46 |
+
const resp = await fetcher<ChatEntity>('/api/upload', {
|
47 |
+
method: 'POST',
|
48 |
+
body: JSON.stringify({
|
49 |
+
base64: reader.result as string,
|
50 |
+
fileType: files[0].type,
|
51 |
+
}),
|
52 |
+
});
|
53 |
+
if (resp) {
|
54 |
+
router.push(`/chat/${resp.id}`);
|
55 |
+
}
|
56 |
+
};
|
57 |
+
},
|
58 |
+
);
|
59 |
return (
|
60 |
<div className="mx-auto max-w-2xl px-4 mt-8">
|
61 |
<div className="rounded-lg border bg-background p-8">
|
|
|
85 |
onClick={async () => {
|
86 |
const resp = await fetcher<ChatEntity>('/api/upload', {
|
87 |
method: 'POST',
|
88 |
+
headers: {
|
89 |
+
'Content-Type': 'application/json',
|
90 |
+
},
|
91 |
body: JSON.stringify({ url, initMessages }),
|
92 |
});
|
93 |
if (resp) {
|
lib/aws.ts
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
|
2 |
+
import { S3Client } from '@aws-sdk/client-s3';
|
3 |
+
import { fromEnv } from '@aws-sdk/credential-providers';
|
4 |
+
|
5 |
+
const s3Client = new S3Client({
|
6 |
+
region: process.env.AWS_REGION,
|
7 |
+
credentials: fromEnv(),
|
8 |
+
});
|
9 |
+
|
10 |
+
const FILE_SIZE_LIMIT = 10485760; // 10MB
|
11 |
+
|
12 |
+
export const upload = async (
|
13 |
+
base64: string,
|
14 |
+
fileName: string,
|
15 |
+
fileType: string,
|
16 |
+
) => {
|
17 |
+
const imageBuffer = Buffer.from(base64, 'base64');
|
18 |
+
const { url, fields } = await createPresignedPost(s3Client, {
|
19 |
+
Bucket: process.env.AWS_BUCKET_NAME ?? 'vision-agent-dev',
|
20 |
+
Key: fileName,
|
21 |
+
Conditions: [
|
22 |
+
['content-length-range', 0, FILE_SIZE_LIMIT],
|
23 |
+
['starts-with', '$Content-Type', fileType],
|
24 |
+
],
|
25 |
+
Fields: {
|
26 |
+
acl: 'public-read',
|
27 |
+
'Content-Type': fileType,
|
28 |
+
},
|
29 |
+
Expires: 600,
|
30 |
+
});
|
31 |
+
const formData = new FormData();
|
32 |
+
Object.entries(fields).forEach(([key, value]) => {
|
33 |
+
formData.append(key, value as string);
|
34 |
+
});
|
35 |
+
const res = await fetch(base64);
|
36 |
+
const blob = await res.blob();
|
37 |
+
formData.append('file', blob);
|
38 |
+
|
39 |
+
return fetch(url, {
|
40 |
+
method: 'POST',
|
41 |
+
body: formData,
|
42 |
+
});
|
43 |
+
};
|
lib/hooks/useImageUpload.ts
CHANGED
@@ -1,52 +1,55 @@
|
|
1 |
-
import { useAtom } from 'jotai';
|
2 |
import { DropzoneOptions, useDropzone } from 'react-dropzone';
|
3 |
-
import {
|
4 |
-
import { toast } from 'react-hot-toast';
|
5 |
|
6 |
-
const useImageUpload = (
|
|
|
|
|
|
|
7 |
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
8 |
accept: {
|
9 |
'image/*': ['.jpeg', '.png'],
|
10 |
},
|
11 |
-
multiple:
|
12 |
-
onDrop:
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
|
|
|
|
50 |
...options,
|
51 |
});
|
52 |
|
|
|
|
|
1 |
import { DropzoneOptions, useDropzone } from 'react-dropzone';
|
2 |
+
// import { toast } from 'react-hot-toast';
|
|
|
3 |
|
4 |
+
const useImageUpload = (
|
5 |
+
options?: Partial<DropzoneOptions>,
|
6 |
+
onDrop?: (files: File[]) => void,
|
7 |
+
) => {
|
8 |
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
9 |
accept: {
|
10 |
'image/*': ['.jpeg', '.png'],
|
11 |
},
|
12 |
+
multiple: false,
|
13 |
+
onDrop: onDrop
|
14 |
+
? onDrop
|
15 |
+
: acceptedFiles => {
|
16 |
+
// if (acceptedFiles.length > 10) {
|
17 |
+
// toast('You can only upload 10 images max.', {
|
18 |
+
// icon: '⚠️',
|
19 |
+
// });
|
20 |
+
// }
|
21 |
+
acceptedFiles.forEach(file => {
|
22 |
+
try {
|
23 |
+
const reader = new FileReader();
|
24 |
+
reader.onloadend = () => {
|
25 |
+
// const newImage = reader.result as string;
|
26 |
+
// setTarget(prev => {
|
27 |
+
// // Check if the image already exists in the state
|
28 |
+
// if (
|
29 |
+
// // prev.length >= 10 ||
|
30 |
+
// prev.find(entity => entity.url === newImage)
|
31 |
+
// ) {
|
32 |
+
// // If it does, return the state unchanged
|
33 |
+
// return prev;
|
34 |
+
// } else {
|
35 |
+
// // If it doesn't, add the new image to the state
|
36 |
+
// return [
|
37 |
+
// ...prev,
|
38 |
+
// {
|
39 |
+
// url: newImage,
|
40 |
+
// selected: false,
|
41 |
+
// name: `i-${prev.length + 1}`,
|
42 |
+
// } satisfies DatasetImageEntity,
|
43 |
+
// ];
|
44 |
+
// }
|
45 |
+
// });
|
46 |
+
};
|
47 |
+
reader.readAsDataURL(file);
|
48 |
+
} catch (err) {
|
49 |
+
console.error(err);
|
50 |
+
}
|
51 |
+
});
|
52 |
+
},
|
53 |
...options,
|
54 |
});
|
55 |
|
package.json
CHANGED
@@ -13,6 +13,9 @@
|
|
13 |
"format:check": "prettier --check \"{app,lib,components}**/*.{ts,tsx,mdx}\" --cache"
|
14 |
},
|
15 |
"dependencies": {
|
|
|
|
|
|
|
16 |
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
17 |
"@radix-ui/react-label": "^2.0.2",
|
18 |
"@radix-ui/react-select": "^2.0.0",
|
|
|
13 |
"format:check": "prettier --check \"{app,lib,components}**/*.{ts,tsx,mdx}\" --cache"
|
14 |
},
|
15 |
"dependencies": {
|
16 |
+
"@aws-sdk/client-s3": "^3.556.0",
|
17 |
+
"@aws-sdk/credential-providers": "^3.556.0",
|
18 |
+
"@aws-sdk/s3-presigned-post": "^3.556.0",
|
19 |
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
20 |
"@radix-ui/react-label": "^2.0.2",
|
21 |
"@radix-ui/react-select": "^2.0.0",
|
pnpm-lock.yaml
CHANGED
The diff for this file is too large to render.
See raw diff
|
|