Spaces:
Running
Running
MingruiZhang
commited on
Commit
·
93dd66e
1
Parent(s):
f3a9ef2
mutil image selection
Browse files- components/chat-panel.tsx +94 -94
- components/chat/ChatList.tsx +1 -1
- components/chat/ImageList.tsx +37 -16
- components/chat/index.tsx +1 -1
- components/empty-screen.tsx +3 -1
- lib/hooks/useImageUpload.ts +23 -7
- package.json +1 -0
- state/index.ts +7 -1
- yarn.lock +5 -0
components/chat-panel.tsx
CHANGED
@@ -1,103 +1,103 @@
|
|
1 |
-
import * as React from 'react'
|
2 |
-
import { type UseChatHelpers } from 'ai/react'
|
3 |
|
4 |
-
import { shareChat } from '@/app/actions'
|
5 |
-
import { Button } from '@/components/ui/button'
|
6 |
-
import { PromptForm } from '@/components/prompt-form'
|
7 |
-
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
|
8 |
-
import { IconRefresh, IconShare, IconStop } from '@/components/ui/icons'
|
9 |
-
import { ChatShareDialog } from '@/components/chat-share-dialog'
|
10 |
|
11 |
export interface ChatPanelProps
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
}
|
25 |
|
26 |
export function ChatPanel({
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
}: ChatPanelProps) {
|
37 |
-
|
38 |
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
}
|
|
|
1 |
+
import * as React from 'react';
|
2 |
+
import { type UseChatHelpers } from 'ai/react';
|
3 |
|
4 |
+
import { shareChat } from '@/app/actions';
|
5 |
+
import { Button } from '@/components/ui/button';
|
6 |
+
import { PromptForm } from '@/components/prompt-form';
|
7 |
+
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom';
|
8 |
+
import { IconRefresh, IconShare, IconStop } from '@/components/ui/icons';
|
9 |
+
import { ChatShareDialog } from '@/components/chat-share-dialog';
|
10 |
|
11 |
export interface ChatPanelProps
|
12 |
+
extends Pick<
|
13 |
+
UseChatHelpers,
|
14 |
+
| 'append'
|
15 |
+
| 'isLoading'
|
16 |
+
| 'reload'
|
17 |
+
| 'messages'
|
18 |
+
| 'stop'
|
19 |
+
| 'input'
|
20 |
+
| 'setInput'
|
21 |
+
> {
|
22 |
+
id?: string;
|
23 |
+
title?: string;
|
24 |
}
|
25 |
|
26 |
export function ChatPanel({
|
27 |
+
id,
|
28 |
+
title,
|
29 |
+
isLoading,
|
30 |
+
stop,
|
31 |
+
append,
|
32 |
+
reload,
|
33 |
+
input,
|
34 |
+
setInput,
|
35 |
+
messages,
|
36 |
}: ChatPanelProps) {
|
37 |
+
const [shareDialogOpen, setShareDialogOpen] = React.useState(false);
|
38 |
|
39 |
+
return (
|
40 |
+
<div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% animate-in duration-300 ease-in-out dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
|
41 |
+
<ButtonScrollToBottom />
|
42 |
+
<div className="mx-auto sm:max-w-3xl sm:px-4">
|
43 |
+
<div className="flex items-center justify-center h-12">
|
44 |
+
{isLoading ? (
|
45 |
+
<Button
|
46 |
+
variant="outline"
|
47 |
+
onClick={() => stop()}
|
48 |
+
className="bg-background"
|
49 |
+
>
|
50 |
+
<IconStop className="mr-2" />
|
51 |
+
Stop generating
|
52 |
+
</Button>
|
53 |
+
) : (
|
54 |
+
messages?.length >= 2 && (
|
55 |
+
<div className="flex space-x-2">
|
56 |
+
<Button variant="outline" onClick={() => reload()}>
|
57 |
+
<IconRefresh className="mr-2" />
|
58 |
+
Regenerate response
|
59 |
+
</Button>
|
60 |
+
{id && title ? (
|
61 |
+
<>
|
62 |
+
<Button
|
63 |
+
variant="outline"
|
64 |
+
onClick={() => setShareDialogOpen(true)}
|
65 |
+
>
|
66 |
+
<IconShare className="mr-2" />
|
67 |
+
Share
|
68 |
+
</Button>
|
69 |
+
<ChatShareDialog
|
70 |
+
open={shareDialogOpen}
|
71 |
+
onOpenChange={setShareDialogOpen}
|
72 |
+
onCopy={() => setShareDialogOpen(false)}
|
73 |
+
shareChat={shareChat}
|
74 |
+
chat={{
|
75 |
+
id,
|
76 |
+
title,
|
77 |
+
messages,
|
78 |
+
}}
|
79 |
+
/>
|
80 |
+
</>
|
81 |
+
) : null}
|
82 |
+
</div>
|
83 |
+
)
|
84 |
+
)}
|
85 |
+
</div>
|
86 |
+
<div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4">
|
87 |
+
<PromptForm
|
88 |
+
onSubmit={async value => {
|
89 |
+
await append({
|
90 |
+
id,
|
91 |
+
content: value,
|
92 |
+
role: 'user',
|
93 |
+
});
|
94 |
+
}}
|
95 |
+
input={input}
|
96 |
+
setInput={setInput}
|
97 |
+
isLoading={isLoading}
|
98 |
+
/>
|
99 |
+
</div>
|
100 |
+
</div>
|
101 |
+
</div>
|
102 |
+
);
|
103 |
}
|
components/chat/ChatList.tsx
CHANGED
@@ -57,7 +57,7 @@ export function ChatList({ messages, isLoading }: ChatList) {
|
|
57 |
: messages;
|
58 |
|
59 |
return (
|
60 |
-
<div className="relative mx-auto max-w-
|
61 |
{messageWithLoading.map((message, index) => (
|
62 |
<div key={index}>
|
63 |
<ChatMessage message={message} />
|
|
|
57 |
: messages;
|
58 |
|
59 |
return (
|
60 |
+
<div className="relative mx-auto max-w-3xl px-8 pr-12">
|
61 |
{messageWithLoading.map((message, index) => (
|
62 |
<div key={index}>
|
63 |
<ChatMessage message={message} />
|
components/chat/ImageList.tsx
CHANGED
@@ -1,19 +1,23 @@
|
|
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 |
-
|
|
|
|
|
|
|
12 |
return (
|
13 |
-
<div className="relative
|
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">
|
@@ -24,17 +28,34 @@ const ImageList: React.FC<ImageListProps> = () => {
|
|
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(
|
|
|
28 |
return (
|
29 |
-
<
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
);
|
39 |
})}
|
40 |
</div>
|
|
|
1 |
+
import React, { useCallback } from 'react';
|
2 |
import useImageUpload from '../../lib/hooks/useImageUpload';
|
3 |
+
import { useAtom, useAtomValue } from 'jotai';
|
4 |
import { datasetAtom } from '../../state';
|
5 |
import Image from 'next/image';
|
6 |
+
import { produce } from 'immer';
|
7 |
|
8 |
export interface ImageListProps {}
|
9 |
|
10 |
const ImageList: React.FC<ImageListProps> = () => {
|
11 |
+
const { getRootProps, getInputProps, isDragActive } = useImageUpload({
|
12 |
+
noClick: true,
|
13 |
+
});
|
14 |
+
|
15 |
+
const [dataset, setDataset] = useAtom(datasetAtom);
|
16 |
return (
|
17 |
+
<div className="relative size-full px-12 max-w-3xl mx-auto">
|
18 |
{dataset.length < 10 ? (
|
19 |
<div className="col-span-full px-8 py-4 rounded-xl bg-blue-100 text-blue-400 mb-8">
|
20 |
+
You can upload up to 10 images max by dragging image.
|
21 |
</div>
|
22 |
) : (
|
23 |
<div className="col-span-full px-8 py-4 rounded-xl bg-red-100 text-red-400 mb-8">
|
|
|
28 |
{...getRootProps()}
|
29 |
className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-4"
|
30 |
>
|
31 |
+
{dataset.map(entity => {
|
32 |
+
const { url: imageSrc, name, selected } = entity;
|
33 |
return (
|
34 |
+
<div
|
35 |
+
key={name}
|
36 |
+
onClick={() =>
|
37 |
+
setDataset(prev =>
|
38 |
+
produce(prev, draft => {
|
39 |
+
const index = draft.findIndex(d => d.name === name);
|
40 |
+
draft[index].selected = !selected;
|
41 |
+
}),
|
42 |
+
)
|
43 |
+
}
|
44 |
+
className={`relative rounded-xl overflow-hidden shadow-md cursor-pointer transition-transform hover:scale-105 box-content ${selected ? 'border-4 border-blue-500' : ''}`}
|
45 |
+
>
|
46 |
+
<Image
|
47 |
+
src={imageSrc}
|
48 |
+
draggable={false}
|
49 |
+
alt="dataset images"
|
50 |
+
width={500}
|
51 |
+
height={500}
|
52 |
+
objectFit="cover"
|
53 |
+
className="rounded-xl"
|
54 |
+
/>
|
55 |
+
<div className="absolute bottom-0 left-0 bg-gray-800/50 text-white px-3 py-1 rounded-tr-lg">
|
56 |
+
<p className="text-xs font-bold">{name}</p>
|
57 |
+
</div>
|
58 |
+
</div>
|
59 |
);
|
60 |
})}
|
61 |
</div>
|
components/chat/index.tsx
CHANGED
@@ -24,7 +24,7 @@ export function Chat({ id, initialMessages, className }: ChatProps) {
|
|
24 |
id,
|
25 |
body: {
|
26 |
id,
|
27 |
-
dataset: dataset,
|
28 |
},
|
29 |
onResponse(response) {
|
30 |
if (response.status === 401) {
|
|
|
24 |
id,
|
25 |
body: {
|
26 |
id,
|
27 |
+
dataset: dataset.filter(entity => entity.selected),
|
28 |
},
|
29 |
onResponse(response) {
|
30 |
if (response.status === 401) {
|
components/empty-screen.tsx
CHANGED
@@ -40,7 +40,9 @@ export function EmptyScreen() {
|
|
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={() =>
|
|
|
|
|
44 |
/>
|
45 |
))}
|
46 |
</div>
|
|
|
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={() =>
|
44 |
+
setTarget([{ url: example, name: 'i-1', selected: false }])
|
45 |
+
}
|
46 |
/>
|
47 |
))}
|
48 |
</div>
|
lib/hooks/useImageUpload.ts
CHANGED
@@ -1,29 +1,44 @@
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
|
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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
}
|
28 |
});
|
29 |
};
|
@@ -33,6 +48,7 @@ const useImageUpload = () => {
|
|
33 |
}
|
34 |
});
|
35 |
},
|
|
|
36 |
});
|
37 |
|
38 |
return { getRootProps, getInputProps, isDragActive };
|
|
|
1 |
import { useAtom } from 'jotai';
|
2 |
+
import { DropzoneOptions, useDropzone } from 'react-dropzone';
|
3 |
+
import { DatasetImageEntity, datasetAtom } from '../../state';
|
4 |
+
import { toast } from 'react-hot-toast';
|
5 |
|
6 |
+
const useImageUpload = (options?: Partial<DropzoneOptions>) => {
|
7 |
const [, setTarget] = useAtom(datasetAtom);
|
8 |
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
9 |
accept: {
|
10 |
'image/*': ['.jpeg', '.png'],
|
11 |
},
|
|
|
12 |
multiple: true,
|
13 |
onDrop: acceptedFiles => {
|
14 |
+
if (acceptedFiles.length > 10) {
|
15 |
+
toast('You can only upload 10 images max.', {
|
16 |
+
icon: '⚠️',
|
17 |
+
});
|
18 |
+
}
|
19 |
+
acceptedFiles.slice(0, 10).forEach(file => {
|
20 |
try {
|
21 |
const reader = new FileReader();
|
22 |
reader.onloadend = () => {
|
23 |
const newImage = reader.result as string;
|
24 |
setTarget(prev => {
|
25 |
// Check if the image already exists in the state
|
26 |
+
if (
|
27 |
+
prev.length >= 10 ||
|
28 |
+
prev.find(entity => entity.url === newImage)
|
29 |
+
) {
|
30 |
// If it does, return the state unchanged
|
31 |
return prev;
|
32 |
} else {
|
33 |
// If it doesn't, add the new image to the state
|
34 |
+
return [
|
35 |
+
...prev,
|
36 |
+
{
|
37 |
+
url: newImage,
|
38 |
+
selected: false,
|
39 |
+
name: `i-${prev.length + 1}`,
|
40 |
+
} satisfies DatasetImageEntity,
|
41 |
+
];
|
42 |
}
|
43 |
});
|
44 |
};
|
|
|
48 |
}
|
49 |
});
|
50 |
},
|
51 |
+
...options,
|
52 |
});
|
53 |
|
54 |
return { getRootProps, getInputProps, isDragActive };
|
package.json
CHANGED
@@ -30,6 +30,7 @@
|
|
30 |
"focus-trap-react": "^10.2.3",
|
31 |
"framer-motion": "^10.18.0",
|
32 |
"geist": "^1.2.1",
|
|
|
33 |
"jotai": "^2.7.0",
|
34 |
"nanoid": "^5.0.4",
|
35 |
"next": "14.1.0",
|
|
|
30 |
"focus-trap-react": "^10.2.3",
|
31 |
"framer-motion": "^10.18.0",
|
32 |
"geist": "^1.2.1",
|
33 |
+
"immer": "^10.0.3",
|
34 |
"jotai": "^2.7.0",
|
35 |
"nanoid": "^5.0.4",
|
36 |
"next": "14.1.0",
|
state/index.ts
CHANGED
@@ -1,4 +1,10 @@
|
|
1 |
import { atom } from 'jotai';
|
2 |
|
|
|
|
|
|
|
|
|
|
|
3 |
// list of image urls or base64 strings
|
4 |
-
export const datasetAtom = atom<
|
|
|
|
1 |
import { atom } from 'jotai';
|
2 |
|
3 |
+
export type DatasetImageEntity = {
|
4 |
+
url: string;
|
5 |
+
selected: boolean;
|
6 |
+
name: string;
|
7 |
+
};
|
8 |
// list of image urls or base64 strings
|
9 |
+
export const datasetAtom = atom<DatasetImageEntity[]>([]);
|
10 |
+
// export const selectedImagesAtom = atom<number[]>([]);
|
yarn.lock
CHANGED
@@ -2262,6 +2262,11 @@ ignore@^5.2.0:
|
|
2262 |
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
|
2263 |
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
|
2264 |
|
|
|
|
|
|
|
|
|
|
|
2265 |
import-fresh@^3.2.1:
|
2266 |
version "3.3.0"
|
2267 |
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
|
|
|
2262 |
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
|
2263 |
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
|
2264 |
|
2265 |
+
immer@^10.0.3:
|
2266 |
+
version "10.0.3"
|
2267 |
+
resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.3.tgz#a8de42065e964aa3edf6afc282dfc7f7f34ae3c9"
|
2268 |
+
integrity sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==
|
2269 |
+
|
2270 |
import-fresh@^3.2.1:
|
2271 |
version "3.3.0"
|
2272 |
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
|