wuyiqunLu commited on
Commit
6b8f69a
β€’
1 Parent(s): c2f566c

feat: change the upload flow (#17)

Browse files

Local works, deploy to vercel to see if larger than 4mb can be uploaded

app/api/chat/route.ts CHANGED
@@ -5,7 +5,6 @@ import { auth } from '@/auth';
5
  import {
6
  ChatCompletionMessageParam,
7
  ChatCompletionContentPart,
8
- ChatCompletionContentPartImage,
9
  } from 'openai/resources';
10
  import { MessageBase } from '../../../lib/types';
11
 
 
5
  import {
6
  ChatCompletionMessageParam,
7
  ChatCompletionContentPart,
 
8
  } from 'openai/resources';
9
  import { MessageBase } from '../../../lib/types';
10
 
app/api/sign/route.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { auth } from '@/auth';
2
+ import { getPresignedUrl } from '@/lib/aws';
3
+ import { nanoid } from '@/lib/utils';
4
+
5
+ /**
6
+ * @param req
7
+ * @returns
8
+ */
9
+ export async function POST(req: Request): Promise<Response> {
10
+ const session = await auth();
11
+ const user = session?.user?.email ?? 'anonymous';
12
+ // if (!email) {
13
+ // return new Response('Unauthorized', {
14
+ // status: 401,
15
+ // });
16
+ // }
17
+
18
+ try {
19
+ const { fileName, fileType } = (await req.json()) as {
20
+ fileName: string;
21
+ fileType: string;
22
+ };
23
+
24
+ const id = nanoid();
25
+
26
+ const signedFileName = `${user}/${id}/${fileName}`;
27
+ const res = await getPresignedUrl(signedFileName, fileType);
28
+ return Response.json({
29
+ id,
30
+ signedUrl: res.url,
31
+ publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`,
32
+ fields: res.fields,
33
+ });
34
+ } catch (error) {
35
+ return new Response((error as Error).message, {
36
+ status: 400,
37
+ });
38
+ }
39
+ }
app/api/upload/route.ts CHANGED
@@ -1,12 +1,10 @@
1
  import { auth } from '@/auth';
2
- import { upload } from '@/lib/aws';
3
  import { createKVChat } from '@/lib/kv/chat';
4
  import { ChatEntity, MessageBase } from '@/lib/types';
5
  import { nanoid } from '@/lib/utils';
6
  import { revalidatePath } from 'next/cache';
7
 
8
  /**
9
- * TODO: this should be replaced with actual upload to S3
10
  * @param req
11
  * @returns
12
  */
@@ -20,37 +18,15 @@ export async function POST(req: Request): Promise<Response> {
20
  // }
21
 
22
  try {
23
- const { url, base64, initMessages, fileType } = (await req.json()) as {
24
- url?: string;
25
- file?: File;
26
- base64?: string;
27
- fileType?: string;
28
  initMessages?: MessageBase[];
29
  };
30
 
31
- if (!url && !base64) {
32
- return new Response('Missing both url and base64 in payload', {
33
- status: 400,
34
- });
35
- }
36
-
37
- const id = nanoid();
38
-
39
- let urlToSave = url;
40
- if (base64) {
41
- const fileName = `${user}/${id}/${Date.now()}-image.jpg`;
42
- const res = await upload(base64, fileName, fileType ?? 'image/png');
43
- if (res.ok) {
44
- console.log('Image uploaded successfully');
45
- urlToSave = `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${fileName}`;
46
- } else {
47
- return res;
48
- }
49
- }
50
-
51
  const payload: ChatEntity = {
52
- url: urlToSave!, // TODO can be uploaded as well
53
- id,
54
  user,
55
  messages: initMessages ?? [],
56
  };
 
1
  import { auth } from '@/auth';
 
2
  import { createKVChat } from '@/lib/kv/chat';
3
  import { ChatEntity, MessageBase } from '@/lib/types';
4
  import { nanoid } from '@/lib/utils';
5
  import { revalidatePath } from 'next/cache';
6
 
7
  /**
 
8
  * @param req
9
  * @returns
10
  */
 
18
  // }
19
 
20
  try {
21
+ const { id, url, initMessages } = (await req.json()) as {
22
+ id?: string;
23
+ url: string;
 
 
24
  initMessages?: MessageBase[];
25
  };
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const payload: ChatEntity = {
28
+ url,
29
+ id: id ?? nanoid(),
30
  user,
31
  messages: initMessages ?? [],
32
  };
components/chat/ChatMessage.tsx CHANGED
@@ -10,72 +10,14 @@ import { MemoizedReactMarkdown } from '@/components/chat/MemoizedReactMarkdown';
10
  import { IconOpenAI, IconUser } from '@/components/ui/Icons';
11
  import { ChatMessageActions } from '@/components/chat/ChatMessageActions';
12
  import { MessageBase } from '../../lib/types';
 
13
 
14
  export interface ChatMessageProps {
15
  message: MessageBase;
16
  }
17
 
18
- const PAIRS: Record<string, string> = {
19
- '┍': 'β”‘',
20
- '┝': 'β”₯',
21
- 'β”œ': '─',
22
- 'β”•': 'β”™',
23
- };
24
-
25
- const MIDDLE_STARTER = '┝';
26
- const MIDDLE_SEPARATOR = 'β”Ώ';
27
-
28
  export function ChatMessage({ message, ...props }: ChatMessageProps) {
29
- const cleanupMessage = ({ content, role }: MessageBase) => {
30
- if (role === 'user') {
31
- return {
32
- content,
33
- };
34
- }
35
- const [logs = '', answer = ''] = content.split('<ANSWER>');
36
- const cleanedLogs = [];
37
- let left = 0;
38
- let right = 0;
39
- while (right < logs.length) {
40
- if (Object.keys(PAIRS).includes(content[right])) {
41
- cleanedLogs.push(content.substring(left, right));
42
- left = right++;
43
- while (
44
- right < content.length &&
45
- PAIRS[content[left]] !== content[right]
46
- ) {
47
- right++;
48
- }
49
- if (content[left] === MIDDLE_STARTER) {
50
- // add the text alignment so it can be shown as a table
51
- const separators = logs
52
- .substring(left, right)
53
- .split(MIDDLE_SEPARATOR).length;
54
- if (separators > 0) {
55
- cleanedLogs.push(
56
- Array(separators + 1)
57
- .fill('|')
58
- .join(' :- '),
59
- );
60
- }
61
- }
62
- left = ++right;
63
- } else {
64
- right++;
65
- }
66
- }
67
- cleanedLogs.push(content.substring(left, right));
68
- return {
69
- logs: cleanedLogs
70
- .join('')
71
- .replace(/β”‚/g, '|')
72
- .split('|\n\n|')
73
- .join('|\n|'),
74
- content: answer.replace('</</ANSWER>', '').replace('</ANSWER>', ''),
75
- };
76
- };
77
-
78
- const { logs, content } = cleanupMessage(message);
79
  return (
80
  <div className={cn('group relative mb-4 flex items-start')} {...props}>
81
  <div
 
10
  import { IconOpenAI, IconUser } from '@/components/ui/Icons';
11
  import { ChatMessageActions } from '@/components/chat/ChatMessageActions';
12
  import { MessageBase } from '../../lib/types';
13
+ import { useCleanedUpMessages } from '@/lib/hooks/useCleanedUpMessages';
14
 
15
  export interface ChatMessageProps {
16
  message: MessageBase;
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
19
  export function ChatMessage({ message, ...props }: ChatMessageProps) {
20
+ const { logs, content } = useCleanedUpMessages(message);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  return (
22
  <div className={cn('group relative mb-4 flex items-start')} {...props}>
23
  <div
components/chat/ImageSelector.tsx CHANGED
@@ -3,11 +3,12 @@
3
  import React, { useState } from 'react';
4
  import useImageUpload from '../../lib/hooks/useImageUpload';
5
  import { cn, fetcher } from '@/lib/utils';
6
- import { ChatEntity, MessageBase } from '@/lib/types';
7
  import { useRouter } from 'next/navigation';
8
  import Loading from '../ui/Loading';
 
9
 
10
- export interface ImageSelectorProps {}
11
 
12
  type Example = {
13
  url: string;
@@ -28,20 +29,43 @@ const ImageSelector: React.FC<ImageSelectorProps> = () => {
28
  const reader = new FileReader();
29
  reader.readAsDataURL(files[0]);
30
  reader.onload = async () => {
31
- const resp = await fetcher<ChatEntity>('/api/upload', {
32
  method: 'POST',
33
  body: JSON.stringify({
34
- base64: reader.result as string,
35
  fileType: files[0].type,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }),
37
  });
38
  setUploading(false);
39
  if (resp) {
40
  router.push(`/chat/${resp.id}`);
41
  }
42
- };
43
- },
44
- );
45
  return (
46
  <div
47
  {...getRootProps()}
 
3
  import React, { useState } from 'react';
4
  import useImageUpload from '../../lib/hooks/useImageUpload';
5
  import { cn, fetcher } from '@/lib/utils';
6
+ import { SignedPayload, MessageBase, ChatEntity } from '@/lib/types';
7
  import { useRouter } from 'next/navigation';
8
  import Loading from '../ui/Loading';
9
+ import toast from 'react-hot-toast';
10
 
11
+ export interface ImageSelectorProps { }
12
 
13
  type Example = {
14
  url: string;
 
29
  const reader = new FileReader();
30
  reader.readAsDataURL(files[0]);
31
  reader.onload = async () => {
32
+ const { id, signedUrl, publicUrl, fields } = await fetcher<SignedPayload>('/api/sign', {
33
  method: 'POST',
34
  body: JSON.stringify({
 
35
  fileType: files[0].type,
36
+ fileName: files[0].name,
37
+ }),
38
+ });
39
+ const formData = new FormData();
40
+ Object.entries(fields).forEach(([key, value]) => {
41
+ formData.append(key, value as string)
42
+ })
43
+ formData.append('file', files[0]);
44
+
45
+ const uploadResponse = await fetch(signedUrl, {
46
+ method: 'POST',
47
+ body: formData,
48
+ })
49
+ if (!uploadResponse.ok) {
50
+ toast.error(uploadResponse.statusText);
51
+ return;
52
+ }
53
+ const resp = await fetcher<ChatEntity>('/api/upload', {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({
59
+ id,
60
+ url: publicUrl,
61
  }),
62
  });
63
  setUploading(false);
64
  if (resp) {
65
  router.push(`/chat/${resp.id}`);
66
  }
67
+ }
68
+ });
 
69
  return (
70
  <div
71
  {...getRootProps()}
lib/aws.ts CHANGED
@@ -9,8 +9,8 @@ const s3Client = new S3Client({
9
  credentials: fromEnv(),
10
  });
11
 
12
- export const upload = async (base64: string, fileName: string, fileType: string) => {
13
- const { url, fields } = await createPresignedPost(s3Client, {
14
  Bucket: process.env.AWS_BUCKET_NAME ?? 'vision-agent-dev',
15
  Key: fileName,
16
  Conditions: [
@@ -23,6 +23,14 @@ export const upload = async (base64: string, fileName: string, fileType: string)
23
  },
24
  Expires: 600,
25
  });
 
 
 
 
 
 
 
 
26
  const formData = new FormData();
27
  Object.entries(fields).forEach(([key, value]) => {
28
  formData.append(key, value as string);
 
9
  credentials: fromEnv(),
10
  });
11
 
12
+ export const getPresignedUrl = async (fileName: string, fileType: string) => {
13
+ return createPresignedPost(s3Client, {
14
  Bucket: process.env.AWS_BUCKET_NAME ?? 'vision-agent-dev',
15
  Key: fileName,
16
  Conditions: [
 
23
  },
24
  Expires: 600,
25
  });
26
+ };
27
+
28
+ export const upload = async (
29
+ base64: string,
30
+ fileName: string,
31
+ fileType: string,
32
+ ) => {
33
+ const { url, fields } = await getPresignedUrl(fileName, fileType);
34
  const formData = new FormData();
35
  Object.entries(fields).forEach(([key, value]) => {
36
  formData.append(key, value as string);
lib/hooks/useCleanedUpMessages.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from 'react';
2
+ import { MessageBase } from '../types';
3
+
4
+ const PAIRS: Record<string, string> = {
5
+ '┍': 'β”‘',
6
+ '┝': 'β”₯',
7
+ 'β”œ': '─',
8
+ 'β”•': 'β”™',
9
+ };
10
+
11
+ const MIDDLE_STARTER = '┝';
12
+ const MIDDLE_SEPARATOR = 'β”Ώ';
13
+
14
+ export const useCleanedUpMessages = ({ content, role }: MessageBase) => {
15
+ return useMemo(() => {
16
+ if (role === 'user') {
17
+ return {
18
+ content,
19
+ };
20
+ }
21
+ const [logs = '', answer = ''] = content.split('<ANSWER>');
22
+ const cleanedLogs = [];
23
+ let left = 0;
24
+ let right = 0;
25
+ while (right < logs.length) {
26
+ if (Object.keys(PAIRS).includes(content[right])) {
27
+ cleanedLogs.push(content.substring(left, right));
28
+ left = right++;
29
+ while (
30
+ right < content.length &&
31
+ PAIRS[content[left]] !== content[right]
32
+ ) {
33
+ right++;
34
+ }
35
+ if (content[left] === MIDDLE_STARTER) {
36
+ // add the text alignment so it can be shown as a table
37
+ const separators = logs
38
+ .substring(left, right)
39
+ .split(MIDDLE_SEPARATOR).length;
40
+ if (separators > 0) {
41
+ cleanedLogs.push(
42
+ Array(separators + 1)
43
+ .fill('|')
44
+ .join(' :- '),
45
+ );
46
+ }
47
+ }
48
+ left = ++right;
49
+ } else {
50
+ right++;
51
+ }
52
+ }
53
+ cleanedLogs.push(content.substring(left, right));
54
+ return {
55
+ logs: cleanedLogs
56
+ .join('')
57
+ .replace(/β”‚/g, '|')
58
+ .split('|\n\n|')
59
+ .join('|\n|'),
60
+ content: answer.replace('</</ANSWER>', '').replace('</ANSWER>', ''),
61
+ };
62
+ }, [content, role]);
63
+ };
lib/types.ts CHANGED
@@ -3,8 +3,8 @@ import { type Message } from 'ai';
3
  export type ServerActionResult<Result> = Promise<
4
  | Result
5
  | {
6
- error: string;
7
- }
8
  >;
9
 
10
  /**
@@ -35,3 +35,10 @@ export type ChatEntity = {
35
  user: string; // email
36
  messages: MessageBase[];
37
  };
 
 
 
 
 
 
 
 
3
  export type ServerActionResult<Result> = Promise<
4
  | Result
5
  | {
6
+ error: string;
7
+ }
8
  >;
9
 
10
  /**
 
35
  user: string; // email
36
  messages: MessageBase[];
37
  };
38
+
39
+ export interface SignedPayload {
40
+ id: string;
41
+ publicUrl: string;
42
+ signedUrl: string;
43
+ fields: Record<string, string>;
44
+ }