Spaces:
Sleeping
Sleeping
Commit
·
f3a9ef2
1
Parent(s):
1d92adf
multi image
Browse files- app/(chat)/chat/[id]/page.tsx +29 -29
- app/(chat)/page.tsx +4 -13
- app/api/chat/route.ts +10 -6
- app/share/[id]/page.tsx +1 -1
- components/{chat-list.tsx → chat/ChatList.tsx} +0 -0
- components/chat/ImageList.tsx +55 -0
- components/{chat.tsx → chat/index.tsx} +7 -44
- components/empty-screen.tsx +5 -21
- lib/hooks/useImageUpload.ts +41 -0
- state/index.ts +2 -1
app/(chat)/chat/[id]/page.tsx
CHANGED
@@ -1,47 +1,47 @@
|
|
1 |
-
import { type Metadata } from 'next'
|
2 |
-
import { notFound, redirect } from 'next/navigation'
|
3 |
|
4 |
-
import { auth } from '@/auth'
|
5 |
-
import { getChat } from '@/app/actions'
|
6 |
-
import { Chat } from '@/components/chat'
|
7 |
|
8 |
export interface ChatPageProps {
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
}
|
13 |
|
14 |
export async function generateMetadata({
|
15 |
-
|
16 |
}: ChatPageProps): Promise<Metadata> {
|
17 |
-
|
18 |
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
}
|
28 |
|
29 |
export default async function ChatPage({ params }: ChatPageProps) {
|
30 |
-
|
31 |
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
|
36 |
-
|
37 |
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
|
46 |
-
|
47 |
}
|
|
|
1 |
+
import { type Metadata } from 'next';
|
2 |
+
import { notFound, redirect } from 'next/navigation';
|
3 |
|
4 |
+
import { auth } from '@/auth';
|
5 |
+
import { getChat } from '@/app/actions';
|
6 |
+
import { Chat } from '@/components/chat';
|
7 |
|
8 |
export interface ChatPageProps {
|
9 |
+
params: {
|
10 |
+
id: string;
|
11 |
+
};
|
12 |
}
|
13 |
|
14 |
export async function generateMetadata({
|
15 |
+
params,
|
16 |
}: ChatPageProps): Promise<Metadata> {
|
17 |
+
const session = await auth();
|
18 |
|
19 |
+
if (!session?.user) {
|
20 |
+
return {};
|
21 |
+
}
|
22 |
|
23 |
+
const chat = await getChat(params.id, session.user.id);
|
24 |
+
return {
|
25 |
+
title: chat?.title.toString().slice(0, 50) ?? 'Chat',
|
26 |
+
};
|
27 |
}
|
28 |
|
29 |
export default async function ChatPage({ params }: ChatPageProps) {
|
30 |
+
const session = await auth();
|
31 |
|
32 |
+
if (!session?.user) {
|
33 |
+
redirect(`/sign-in?next=/chat/${params.id}`);
|
34 |
+
}
|
35 |
|
36 |
+
const chat = await getChat(params.id, session.user.id);
|
37 |
|
38 |
+
if (!chat) {
|
39 |
+
notFound();
|
40 |
+
}
|
41 |
|
42 |
+
if (chat?.userId !== session?.user?.id) {
|
43 |
+
notFound();
|
44 |
+
}
|
45 |
|
46 |
+
return <Chat id={chat.id} initialMessages={chat.messages} />;
|
47 |
}
|
app/(chat)/page.tsx
CHANGED
@@ -4,14 +4,14 @@ import { nanoid } from '@/lib/utils';
|
|
4 |
import { Chat } from '@/components/chat';
|
5 |
import { ThemeToggle } from '../../components/theme-toggle';
|
6 |
import { useAtomValue } from 'jotai';
|
7 |
-
import {
|
8 |
import { EmptyScreen } from '../../components/empty-screen';
|
9 |
|
10 |
export default function IndexPage() {
|
11 |
const id = nanoid();
|
12 |
-
const
|
13 |
|
14 |
-
if (!
|
15 |
return (
|
16 |
<div className="pb-[150px] pt-4 md:pt-10 h-full">
|
17 |
<EmptyScreen />
|
@@ -20,16 +20,7 @@ export default function IndexPage() {
|
|
20 |
|
21 |
return (
|
22 |
<>
|
23 |
-
<Chat
|
24 |
-
id={id}
|
25 |
-
initialMessages={[
|
26 |
-
{
|
27 |
-
id: '123',
|
28 |
-
content: 'Hi, what do you want to know about this image?',
|
29 |
-
role: 'system',
|
30 |
-
},
|
31 |
-
]}
|
32 |
-
/>
|
33 |
<ThemeToggle />
|
34 |
</>
|
35 |
);
|
|
|
4 |
import { Chat } from '@/components/chat';
|
5 |
import { ThemeToggle } from '../../components/theme-toggle';
|
6 |
import { useAtomValue } from 'jotai';
|
7 |
+
import { datasetAtom } from '../../state';
|
8 |
import { EmptyScreen } from '../../components/empty-screen';
|
9 |
|
10 |
export default function IndexPage() {
|
11 |
const id = nanoid();
|
12 |
+
const dataset = useAtomValue(datasetAtom);
|
13 |
|
14 |
+
if (!dataset.length)
|
15 |
return (
|
16 |
<div className="pb-[150px] pt-4 md:pt-10 h-full">
|
17 |
<EmptyScreen />
|
|
|
20 |
|
21 |
return (
|
22 |
<>
|
23 |
+
<Chat id={id} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
<ThemeToggle />
|
25 |
</>
|
26 |
);
|
app/api/chat/route.ts
CHANGED
@@ -5,6 +5,7 @@ import { auth } from '@/auth';
|
|
5 |
import { nanoid } from '@/lib/utils';
|
6 |
import {
|
7 |
ChatCompletionContentPart,
|
|
|
8 |
ChatCompletionMessageParam,
|
9 |
} from 'openai/resources';
|
10 |
|
@@ -16,9 +17,9 @@ const openai = new OpenAI({
|
|
16 |
|
17 |
export async function POST(req: Request) {
|
18 |
const json = await req.json();
|
19 |
-
const { messages,
|
20 |
messages: ChatCompletionMessageParam[];
|
21 |
-
|
22 |
};
|
23 |
|
24 |
const userId = (await auth())?.user.id;
|
@@ -37,10 +38,13 @@ export async function POST(req: Request) {
|
|
37 |
type: 'text',
|
38 |
text: message.content as string,
|
39 |
},
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
|
|
|
|
|
|
44 |
];
|
45 |
return {
|
46 |
role: 'user',
|
|
|
5 |
import { nanoid } from '@/lib/utils';
|
6 |
import {
|
7 |
ChatCompletionContentPart,
|
8 |
+
ChatCompletionContentPartImage,
|
9 |
ChatCompletionMessageParam,
|
10 |
} from 'openai/resources';
|
11 |
|
|
|
17 |
|
18 |
export async function POST(req: Request) {
|
19 |
const json = await req.json();
|
20 |
+
const { messages, dataset } = json as {
|
21 |
messages: ChatCompletionMessageParam[];
|
22 |
+
dataset: string[];
|
23 |
};
|
24 |
|
25 |
const userId = (await auth())?.user.id;
|
|
|
38 |
type: 'text',
|
39 |
text: message.content as string,
|
40 |
},
|
41 |
+
...dataset.map(
|
42 |
+
image =>
|
43 |
+
({
|
44 |
+
type: 'image_url',
|
45 |
+
image_url: { url: image },
|
46 |
+
}) satisfies ChatCompletionContentPartImage,
|
47 |
+
),
|
48 |
];
|
49 |
return {
|
50 |
role: 'user',
|
app/share/[id]/page.tsx
CHANGED
@@ -3,7 +3,7 @@ import { notFound } from 'next/navigation';
|
|
3 |
|
4 |
import { formatDate } from '@/lib/utils';
|
5 |
import { getSharedChat } from '@/app/actions';
|
6 |
-
import { ChatList } from '@/components/chat
|
7 |
|
8 |
interface SharePageProps {
|
9 |
params: {
|
|
|
3 |
|
4 |
import { formatDate } from '@/lib/utils';
|
5 |
import { getSharedChat } from '@/app/actions';
|
6 |
+
import { ChatList } from '@/components/chat/ChatList';
|
7 |
|
8 |
interface SharePageProps {
|
9 |
params: {
|
components/{chat-list.tsx → chat/ChatList.tsx}
RENAMED
File without changes
|
components/chat/ImageList.tsx
ADDED
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import useImageUpload from '../../lib/hooks/useImageUpload';
|
3 |
+
import { useAtomValue } from 'jotai';
|
4 |
+
import { datasetAtom } from '../../state';
|
5 |
+
import Image from 'next/image';
|
6 |
+
|
7 |
+
export interface ImageListProps {}
|
8 |
+
|
9 |
+
const ImageList: React.FC<ImageListProps> = () => {
|
10 |
+
const { getRootProps, getInputProps, isDragActive } = useImageUpload();
|
11 |
+
const dataset = useAtomValue(datasetAtom);
|
12 |
+
return (
|
13 |
+
<div className="relative aspect-[1/1] size-full px-12">
|
14 |
+
{dataset.length < 10 ? (
|
15 |
+
<div className="col-span-full px-8 py-4 rounded-xl bg-blue-100 text-blue-400 mb-8">
|
16 |
+
You can upload up to 10 images max.
|
17 |
+
</div>
|
18 |
+
) : (
|
19 |
+
<div className="col-span-full px-8 py-4 rounded-xl bg-red-100 text-red-400 mb-8">
|
20 |
+
You have reached the maximum limit of 10 images.
|
21 |
+
</div>
|
22 |
+
)}
|
23 |
+
<div
|
24 |
+
{...getRootProps()}
|
25 |
+
className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-4"
|
26 |
+
>
|
27 |
+
{dataset.map((imageSrc, index) => {
|
28 |
+
return (
|
29 |
+
<Image
|
30 |
+
src={imageSrc}
|
31 |
+
key={index}
|
32 |
+
alt="dataset images"
|
33 |
+
width={500}
|
34 |
+
height={500}
|
35 |
+
objectFit="cover"
|
36 |
+
className="relative rounded-xl overflow-hidden shadow-md cursor-pointer transition-transform hover:scale-105"
|
37 |
+
/>
|
38 |
+
);
|
39 |
+
})}
|
40 |
+
</div>
|
41 |
+
|
42 |
+
{isDragActive && (
|
43 |
+
<div
|
44 |
+
{...getRootProps()}
|
45 |
+
className="dropzone border-2 border-dashed border-gray-400 size-full absolute top-0 left-0 flex items-center justify-center rounded-lg cursor-pointer bg-gray-500/50"
|
46 |
+
>
|
47 |
+
<input {...getInputProps()} />
|
48 |
+
<p className="text-white">Drop the files here ...</p>
|
49 |
+
</div>
|
50 |
+
)}
|
51 |
+
</div>
|
52 |
+
);
|
53 |
+
};
|
54 |
+
|
55 |
+
export default ImageList;
|
components/{chat.tsx → chat/index.tsx}
RENAMED
@@ -1,22 +1,15 @@
|
|
1 |
'use client';
|
2 |
|
3 |
import { useChat, type Message } from 'ai/react';
|
4 |
-
import '@/app/globals.css';
|
5 |
|
6 |
import { cn } from '@/lib/utils';
|
7 |
-
import { ChatList } from '@/components/chat
|
8 |
import { ChatPanel } from '@/components/chat-panel';
|
9 |
-
import { EmptyScreen } from '@/components/empty-screen';
|
10 |
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor';
|
11 |
-
import { useState } from 'react';
|
12 |
-
import { Button } from './ui/button';
|
13 |
-
import { Input } from './ui/input';
|
14 |
import { toast } from 'react-hot-toast';
|
15 |
-
import { usePathname, useRouter } from 'next/navigation';
|
16 |
import { useAtom } from 'jotai';
|
17 |
-
import {
|
18 |
-
import
|
19 |
-
import { ThemeToggle } from './theme-toggle';
|
20 |
|
21 |
export interface ChatProps extends React.ComponentProps<'div'> {
|
22 |
initialMessages?: Message[];
|
@@ -24,14 +17,14 @@ export interface ChatProps extends React.ComponentProps<'div'> {
|
|
24 |
}
|
25 |
|
26 |
export function Chat({ id, initialMessages, className }: ChatProps) {
|
27 |
-
const [
|
28 |
const { messages, append, reload, stop, isLoading, input, setInput } =
|
29 |
useChat({
|
30 |
initialMessages,
|
31 |
id,
|
32 |
body: {
|
33 |
id,
|
34 |
-
|
35 |
},
|
36 |
onResponse(response) {
|
37 |
if (response.status === 401) {
|
@@ -45,43 +38,13 @@ export function Chat({ id, initialMessages, className }: ChatProps) {
|
|
45 |
<div className={cn('pb-[150px] pt-4 md:pt-10 h-full', className)}>
|
46 |
<div className="flex h-full">
|
47 |
<div className="w-1/2 relative border-r-2 border-gray-200">
|
48 |
-
<
|
49 |
-
<div className="flex items-center h-[600px] relative">
|
50 |
-
<Image
|
51 |
-
src={targetImage!}
|
52 |
-
alt="target image"
|
53 |
-
layout="fill"
|
54 |
-
objectFit="contain"
|
55 |
-
className="rounded-xl bg-gray-200"
|
56 |
-
/>
|
57 |
-
</div>
|
58 |
-
<button
|
59 |
-
className="px-2 py-1 rounded-lg text-gray-600 border-2 border-gray-600 flex items-center mt-4"
|
60 |
-
onClick={() => setTargetImage(null)}
|
61 |
-
>
|
62 |
-
<svg
|
63 |
-
xmlns="http://www.w3.org/2000/svg"
|
64 |
-
fill="none"
|
65 |
-
viewBox="0 0 24 24"
|
66 |
-
stroke="currentColor"
|
67 |
-
className="size-4"
|
68 |
-
>
|
69 |
-
<path
|
70 |
-
strokeLinecap="round"
|
71 |
-
strokeLinejoin="round"
|
72 |
-
strokeWidth={2}
|
73 |
-
d="M15 19l-7-7 7-7"
|
74 |
-
/>
|
75 |
-
</svg>
|
76 |
-
Back
|
77 |
-
</button>
|
78 |
-
</div>
|
79 |
</div>
|
80 |
<div className="w-1/2 relative overflow-auto">
|
81 |
<ChatList messages={messages} isLoading={isLoading} />
|
|
|
82 |
</div>
|
83 |
</div>
|
84 |
-
<ChatScrollAnchor trackVisibility={isLoading} />
|
85 |
</div>
|
86 |
<ChatPanel
|
87 |
id={id}
|
|
|
1 |
'use client';
|
2 |
|
3 |
import { useChat, type Message } from 'ai/react';
|
|
|
4 |
|
5 |
import { cn } from '@/lib/utils';
|
6 |
+
import { ChatList } from '@/components/chat/ChatList';
|
7 |
import { ChatPanel } from '@/components/chat-panel';
|
|
|
8 |
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor';
|
|
|
|
|
|
|
9 |
import { toast } from 'react-hot-toast';
|
|
|
10 |
import { useAtom } from 'jotai';
|
11 |
+
import { datasetAtom } from '@/state';
|
12 |
+
import ImageList from './ImageList';
|
|
|
13 |
|
14 |
export interface ChatProps extends React.ComponentProps<'div'> {
|
15 |
initialMessages?: Message[];
|
|
|
17 |
}
|
18 |
|
19 |
export function Chat({ id, initialMessages, className }: ChatProps) {
|
20 |
+
const [dataset] = useAtom(datasetAtom);
|
21 |
const { messages, append, reload, stop, isLoading, input, setInput } =
|
22 |
useChat({
|
23 |
initialMessages,
|
24 |
id,
|
25 |
body: {
|
26 |
id,
|
27 |
+
dataset: dataset,
|
28 |
},
|
29 |
onResponse(response) {
|
30 |
if (response.status === 401) {
|
|
|
38 |
<div className={cn('pb-[150px] pt-4 md:pt-10 h-full', className)}>
|
39 |
<div className="flex h-full">
|
40 |
<div className="w-1/2 relative border-r-2 border-gray-200">
|
41 |
+
<ImageList />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
</div>
|
43 |
<div className="w-1/2 relative overflow-auto">
|
44 |
<ChatList messages={messages} isLoading={isLoading} />
|
45 |
+
<ChatScrollAnchor trackVisibility={isLoading} />
|
46 |
</div>
|
47 |
</div>
|
|
|
48 |
</div>
|
49 |
<ChatPanel
|
50 |
id={id}
|
components/empty-screen.tsx
CHANGED
@@ -1,7 +1,8 @@
|
|
1 |
import { useAtom } from 'jotai';
|
2 |
import { useDropzone } from 'react-dropzone';
|
3 |
-
import {
|
4 |
import Image from 'next/image';
|
|
|
5 |
|
6 |
const examples = [
|
7 |
'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg',
|
@@ -11,25 +12,8 @@ const examples = [
|
|
11 |
];
|
12 |
|
13 |
export function EmptyScreen() {
|
14 |
-
const [, setTarget] = useAtom(
|
15 |
-
const { getRootProps, getInputProps } =
|
16 |
-
accept: {
|
17 |
-
'image/*': ['.jpeg', '.png'],
|
18 |
-
},
|
19 |
-
multiple: false,
|
20 |
-
onDrop: async acceptedFiles => {
|
21 |
-
try {
|
22 |
-
const file = acceptedFiles[0];
|
23 |
-
const reader = new FileReader();
|
24 |
-
reader.onloadend = () => {
|
25 |
-
setTarget(reader.result as string);
|
26 |
-
};
|
27 |
-
reader.readAsDataURL(file);
|
28 |
-
} catch (err) {
|
29 |
-
console.error(err);
|
30 |
-
}
|
31 |
-
},
|
32 |
-
});
|
33 |
return (
|
34 |
<div className="mx-auto max-w-2xl px-4">
|
35 |
<div className="rounded-lg border bg-background p-8">
|
@@ -56,7 +40,7 @@ export function EmptyScreen() {
|
|
56 |
height={120}
|
57 |
alt="example images"
|
58 |
className="object-cover rounded mr-3 shadow-md hover:scale-105 cursor-pointer transition-transform"
|
59 |
-
onClick={() => setTarget(example)}
|
60 |
/>
|
61 |
))}
|
62 |
</div>
|
|
|
1 |
import { useAtom } from 'jotai';
|
2 |
import { useDropzone } from 'react-dropzone';
|
3 |
+
import { datasetAtom } from '../state';
|
4 |
import Image from 'next/image';
|
5 |
+
import useImageUpload from '../lib/hooks/useImageUpload';
|
6 |
|
7 |
const examples = [
|
8 |
'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg',
|
|
|
12 |
];
|
13 |
|
14 |
export function EmptyScreen() {
|
15 |
+
const [, setTarget] = useAtom(datasetAtom);
|
16 |
+
const { getRootProps, getInputProps } = useImageUpload();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
return (
|
18 |
<div className="mx-auto max-w-2xl px-4">
|
19 |
<div className="rounded-lg border bg-background p-8">
|
|
|
40 |
height={120}
|
41 |
alt="example images"
|
42 |
className="object-cover rounded mr-3 shadow-md hover:scale-105 cursor-pointer transition-transform"
|
43 |
+
onClick={() => setTarget([example])}
|
44 |
/>
|
45 |
))}
|
46 |
</div>
|
lib/hooks/useImageUpload.ts
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useAtom } from 'jotai';
|
2 |
+
import { useDropzone } from 'react-dropzone';
|
3 |
+
import { datasetAtom } from '../../state';
|
4 |
+
|
5 |
+
const useImageUpload = () => {
|
6 |
+
const [, setTarget] = useAtom(datasetAtom);
|
7 |
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
8 |
+
accept: {
|
9 |
+
'image/*': ['.jpeg', '.png'],
|
10 |
+
},
|
11 |
+
maxFiles: 10,
|
12 |
+
multiple: true,
|
13 |
+
onDrop: acceptedFiles => {
|
14 |
+
acceptedFiles.forEach(file => {
|
15 |
+
try {
|
16 |
+
const reader = new FileReader();
|
17 |
+
reader.onloadend = () => {
|
18 |
+
const newImage = reader.result as string;
|
19 |
+
setTarget(prev => {
|
20 |
+
// Check if the image already exists in the state
|
21 |
+
if (prev.length >= 10 || prev.includes(newImage)) {
|
22 |
+
// If it does, return the state unchanged
|
23 |
+
return prev;
|
24 |
+
} else {
|
25 |
+
// If it doesn't, add the new image to the state
|
26 |
+
return [...prev, newImage];
|
27 |
+
}
|
28 |
+
});
|
29 |
+
};
|
30 |
+
reader.readAsDataURL(file);
|
31 |
+
} catch (err) {
|
32 |
+
console.error(err);
|
33 |
+
}
|
34 |
+
});
|
35 |
+
},
|
36 |
+
});
|
37 |
+
|
38 |
+
return { getRootProps, getInputProps, isDragActive };
|
39 |
+
};
|
40 |
+
|
41 |
+
export default useImageUpload;
|
state/index.ts
CHANGED
@@ -1,3 +1,4 @@
|
|
1 |
import { atom } from 'jotai';
|
2 |
|
3 |
-
|
|
|
|
1 |
import { atom } from 'jotai';
|
2 |
|
3 |
+
// list of image urls or base64 strings
|
4 |
+
export const datasetAtom = atom<string[]>([]);
|