Spaces:
Sleeping
Sleeping
wuyiqunLu
commited on
Commit
β’
6b8f69a
1
Parent(s):
c2f566c
feat: change the upload flow (#17)
Browse filesLocal works, deploy to vercel to see if larger than 4mb can be uploaded
- app/api/chat/route.ts +0 -1
- app/api/sign/route.ts +39 -0
- app/api/upload/route.ts +5 -29
- components/chat/ChatMessage.tsx +2 -60
- components/chat/ImageSelector.tsx +31 -7
- lib/aws.ts +10 -2
- lib/hooks/useCleanedUpMessages.ts +63 -0
- lib/types.ts +9 -2
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 {
|
24 |
-
|
25 |
-
|
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
|
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
|
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 {
|
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
|
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
|
13 |
-
|
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 |
-
|
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 |
+
}
|