wuyiqunLu commited on
Commit
8782869
·
unverified ·
2 Parent(s): a86b547 2d611fd

Merge pull request #7 from landing-ai/feat/upload-api

Browse files
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
- const { url, initMessages } = (await req.json()) as {
21
- url?: string;
22
- file?: File;
23
- base64?: string;
24
- initMessages?: MessageBase[];
25
- };
26
-
27
- const id = nanoid();
28
-
29
- const payload: ChatEntity = {
30
- url: url!, // TODO can be uploaded as well
31
- id,
32
- user: email,
33
- messages: initMessages ?? [],
34
- };
35
-
36
- await kv.hmset(`chat:${id}`, payload);
37
- await kv.zadd(`user:chat:${email}`, {
38
- score: Date.now(),
39
- member: `chat:${id}`,
40
- });
41
- await kv.zadd('user:chat:all', {
42
- score: Date.now(),
43
- member: `chat:${id}`,
44
- });
45
-
46
- return Response.json(payload);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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].content.slice(0, 50) + ' ...' ?? 'new chat'}
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 { datasetAtom } from '../../state';
4
- import { toast } from 'react-hot-toast';
5
 
6
- const useImageUpload = (options?: Partial<DropzoneOptions>) => {
 
 
 
7
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
8
  accept: {
9
  'image/*': ['.jpeg', '.png'],
10
  },
11
- multiple: true,
12
- onDrop: acceptedFiles => {
13
- // if (acceptedFiles.length > 10) {
14
- // toast('You can only upload 10 images max.', {
15
- // icon: '⚠️',
16
- // });
17
- // }
18
- acceptedFiles.forEach(file => {
19
- try {
20
- const reader = new FileReader();
21
- reader.onloadend = () => {
22
- // const newImage = reader.result as string;
23
- // setTarget(prev => {
24
- // // Check if the image already exists in the state
25
- // if (
26
- // // prev.length >= 10 ||
27
- // prev.find(entity => entity.url === newImage)
28
- // ) {
29
- // // If it does, return the state unchanged
30
- // return prev;
31
- // } else {
32
- // // If it doesn't, add the new image to the state
33
- // return [
34
- // ...prev,
35
- // {
36
- // url: newImage,
37
- // selected: false,
38
- // name: `i-${prev.length + 1}`,
39
- // } satisfies DatasetImageEntity,
40
- // ];
41
- // }
42
- // });
43
- };
44
- reader.readAsDataURL(file);
45
- } catch (err) {
46
- console.error(err);
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