Spaces:
Sleeping
Sleeping
MingruiZhang
commited on
Commit
•
cfb938a
1
Parent(s):
92f037b
feat: Examples + Beta logo (#65)
Browse files![image](https://github.com/landing-ai/vision-agent-ui/assets/5669963/8839fd32-3673-4365-98df-5f63a670b1f4)
- app/chat/page.tsx +0 -74
- app/globals.css +13 -0
- app/page.tsx +87 -9
- components/ChatSelect.tsx +1 -1
- components/Header.tsx +2 -2
- components/chat/ChatClient.tsx +11 -6
- components/chat/Composer.tsx +156 -135
- components/project/ProjectChat.tsx +0 -7
- components/ui/Chip.tsx +2 -3
- components/ui/CodeBlock.tsx +1 -1
- components/ui/Icons.tsx +24 -0
- lib/hooks/useScrollAnchor.tsx +2 -2
- tailwind.config.ts +4 -0
app/chat/page.tsx
DELETED
@@ -1,74 +0,0 @@
|
|
1 |
-
'use client';
|
2 |
-
|
3 |
-
import { generateInputImageMarkdown } from '@/lib/messageUtils';
|
4 |
-
import { useRouter } from 'next/navigation';
|
5 |
-
|
6 |
-
import { MessageRaw } from '@/lib/db/types';
|
7 |
-
import { useState } from 'react';
|
8 |
-
import { Composer } from '@/components/chat/Composer';
|
9 |
-
import { dbPostCreateChat } from '@/lib/db/functions';
|
10 |
-
import { nanoid } from '@/lib/utils';
|
11 |
-
|
12 |
-
const EXAMPLE_URL =
|
13 |
-
'https://vision-agent-dev.s3.us-east-2.amazonaws.com/examples/flower.png';
|
14 |
-
const EXAMPLE_HEADER = 'Count and find';
|
15 |
-
const EXAMPLE_SUBHEADER =
|
16 |
-
'number of flowers, area of largest and smallest flower';
|
17 |
-
const EXAMPLE_PROMPT =
|
18 |
-
'Count the number of flowers and find the area of the largest and smallest flower';
|
19 |
-
|
20 |
-
const exampleMessages = [
|
21 |
-
{
|
22 |
-
heading: EXAMPLE_HEADER,
|
23 |
-
subheading: EXAMPLE_SUBHEADER,
|
24 |
-
url: EXAMPLE_URL,
|
25 |
-
initMessages: [
|
26 |
-
{
|
27 |
-
role: 'user' as MessageRaw['role'],
|
28 |
-
content:
|
29 |
-
EXAMPLE_PROMPT + '\n\n' + generateInputImageMarkdown(EXAMPLE_URL),
|
30 |
-
},
|
31 |
-
],
|
32 |
-
},
|
33 |
-
// {
|
34 |
-
// heading: 'Detecting',
|
35 |
-
// url: 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg',
|
36 |
-
// subheading: 'number of cereals in an image',
|
37 |
-
// message: `How many cereals are there in the image?`,
|
38 |
-
// },
|
39 |
-
];
|
40 |
-
|
41 |
-
export default function Page() {
|
42 |
-
const router = useRouter();
|
43 |
-
return (
|
44 |
-
<div className="mx-auto w-[1024px] max-w-full px-4 mt-[200px]">
|
45 |
-
<h1 className="mb-4 text-5xl text-center">Vision Agent</h1>
|
46 |
-
<h4 className="mb-8 text-center">
|
47 |
-
Generate code to solve your vision problem with simple prompts.
|
48 |
-
</h4>
|
49 |
-
<Composer
|
50 |
-
onSubmit={async ({ input, mediaUrl }) => {
|
51 |
-
const newId = nanoid();
|
52 |
-
const resp = await dbPostCreateChat({
|
53 |
-
id: newId,
|
54 |
-
mediaUrl: mediaUrl,
|
55 |
-
title: `conversation-${newId}`,
|
56 |
-
initMessages: [
|
57 |
-
{
|
58 |
-
role: 'user',
|
59 |
-
content:
|
60 |
-
input +
|
61 |
-
(mediaUrl
|
62 |
-
? '\n\n' + generateInputImageMarkdown(mediaUrl)
|
63 |
-
: ''),
|
64 |
-
},
|
65 |
-
],
|
66 |
-
});
|
67 |
-
if (resp) {
|
68 |
-
router.push(`/chat/${newId}`);
|
69 |
-
}
|
70 |
-
}}
|
71 |
-
/>
|
72 |
-
</div>
|
73 |
-
);
|
74 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/globals.css
CHANGED
@@ -103,3 +103,16 @@ th {
|
|
103 |
table img {
|
104 |
background-color: transparent;
|
105 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
103 |
table img {
|
104 |
background-color: transparent;
|
105 |
}
|
106 |
+
|
107 |
+
h1 {
|
108 |
+
font-size: 3.75rem; /* 48px */
|
109 |
+
font-family: var(--font-geist-sans);
|
110 |
+
font-weight: bold;
|
111 |
+
letter-spacing: -1px;
|
112 |
+
}
|
113 |
+
|
114 |
+
.homepage {
|
115 |
+
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='1440' height='800' preserveAspectRatio='none' viewBox='0 0 1440 800'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1041%26quot%3b)' fill='none'%3e%3crect width='1440' height='800' x='0' y='0' fill='rgba(39%2c 39%2c 42%2c 1)'%3e%3c/rect%3e%3cpath d='M0%2c586.338C117.188%2c602.632%2c249.981%2c607.097%2c343.984%2c535.25C436.988%2c464.167%2c428.51%2c324.327%2c480.215%2c219.307C534.041%2c109.979%2c656.285%2c27.833%2c653.789%2c-94.001C651.262%2c-217.335%2c542.961%2c-307.497%2c467.422%2c-405.024C387.438%2c-508.29%2c329.741%2c-667.611%2c199.733%2c-680.229C64.395%2c-693.364%2c-10.885%2c-516.553%2c-136.125%2c-463.601C-240.173%2c-419.609%2c-369.118%2c-464.571%2c-462.091%2c-400.404C-565.056%2c-329.341%2c-652.036%2c-220.136%2c-668.569%2c-96.126C-685.064%2c27.595%2c-620.538%2c148.465%2c-548.895%2c250.672C-485.01%2c341.811%2c-382.503%2c389.373%2c-287.551%2c447.439C-194.861%2c504.122%2c-107.613%2c571.375%2c0%2c586.338' fill='%23212124'%3e%3c/path%3e%3cpath d='M1440 1336.819C1541.131 1345.339 1640.5529999999999 1299.223 1721.426 1237.907 1798.82 1179.229 1854.8220000000001 1095.077 1881.876 1001.798 1906.8519999999999 915.683 1873.705 828.071 1866.208 738.721 1858.141 642.578 1889.666 537.0129999999999 1836.144 456.739 1781.231 374.378 1680.9279999999999 326.709 1582.8029999999999 313.659 1490.378 301.367 1407.661 359.059 1319.016 387.966 1233.469 415.863 1131.429 413.58 1071.248 480.474 1010.852 547.608 1027.055 649.928 1004.646 737.406 978.896 837.926 891.045 936.749 929.698 1033.047 968.1410000000001 1128.8229999999999 1098.805 1140.133 1187.4850000000001 1192.922 1272.452 1243.501 1341.467 1328.517 1440 1336.819' fill='%232d2d30'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1041'%3e%3crect width='1440' height='800' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e");
|
116 |
+
background-size: cover;
|
117 |
+
background: linear-gradient(to bottom, rgb(9, 9, 11), rgb(32, 32, 39));
|
118 |
+
}
|
app/page.tsx
CHANGED
@@ -1,12 +1,90 @@
|
|
1 |
-
|
2 |
-
import { redirect } from 'next/navigation';
|
3 |
|
4 |
-
|
5 |
-
|
6 |
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
}
|
|
|
1 |
+
'use client';
|
|
|
2 |
|
3 |
+
import { generateInputImageMarkdown } from '@/lib/messageUtils';
|
4 |
+
import { useRouter } from 'next/navigation';
|
5 |
|
6 |
+
import { MessageRaw } from '@/lib/db/types';
|
7 |
+
import { useRef, useState } from 'react';
|
8 |
+
import Composer, { ComposerRef } from '@/components/chat/Composer';
|
9 |
+
import { dbPostCreateChat } from '@/lib/db/functions';
|
10 |
+
import { nanoid } from '@/lib/utils';
|
11 |
+
import Chip from '@/components/ui/Chip';
|
12 |
+
import { IconArrowUpRight, IconImage } from '@/components/ui/Icons';
|
13 |
+
|
14 |
+
const EXAMPLES = [
|
15 |
+
{
|
16 |
+
title: 'Counting flowers',
|
17 |
+
mediaUrl:
|
18 |
+
'https://vision-agent-dev.s3.us-east-2.amazonaws.com/examples/flower.png',
|
19 |
+
prompt: 'Count the number of flowers in this image.',
|
20 |
+
},
|
21 |
+
// {
|
22 |
+
// heading: 'Detecting',
|
23 |
+
// url: 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg',
|
24 |
+
// subheading: 'number of cereals in an image',
|
25 |
+
// message: `How many cereals are there in the image?`,
|
26 |
+
// },
|
27 |
+
];
|
28 |
+
|
29 |
+
export default function Page() {
|
30 |
+
const router = useRouter();
|
31 |
+
const composerRef = useRef<ComposerRef>(null);
|
32 |
+
return (
|
33 |
+
<div className="h-screen w-screen homepage">
|
34 |
+
<div className="mx-auto w-[42rem] max-w-full px-4 mt-[200px]">
|
35 |
+
<h1 className="mb-4 text-center relative">
|
36 |
+
Vision Agent
|
37 |
+
<Chip className="absolute bg-green-100 text-green-500">BETA</Chip>
|
38 |
+
</h1>
|
39 |
+
<h4 className="text-center">
|
40 |
+
Generate code to solve your vision problem with simple prompts.
|
41 |
+
</h4>
|
42 |
+
<div className="my-8">
|
43 |
+
<Composer
|
44 |
+
ref={composerRef}
|
45 |
+
onSubmit={async ({ input, mediaUrl }) => {
|
46 |
+
const newId = nanoid();
|
47 |
+
const resp = await dbPostCreateChat({
|
48 |
+
id: newId,
|
49 |
+
mediaUrl: mediaUrl,
|
50 |
+
title: `conversation-${newId}`,
|
51 |
+
initMessages: [
|
52 |
+
{
|
53 |
+
role: 'user',
|
54 |
+
content:
|
55 |
+
input +
|
56 |
+
(mediaUrl
|
57 |
+
? '\n\n' + generateInputImageMarkdown(mediaUrl)
|
58 |
+
: ''),
|
59 |
+
},
|
60 |
+
],
|
61 |
+
});
|
62 |
+
if (resp) {
|
63 |
+
router.push(`/chat/${newId}`);
|
64 |
+
}
|
65 |
+
}}
|
66 |
+
/>
|
67 |
+
</div>
|
68 |
+
<>
|
69 |
+
{EXAMPLES.map((example, index) => {
|
70 |
+
return (
|
71 |
+
<Chip
|
72 |
+
key={index}
|
73 |
+
className="bg-transparent border border-zinc-500 cursor-pointer px-4"
|
74 |
+
onClick={() => {
|
75 |
+
composerRef.current?.setInput(example.prompt);
|
76 |
+
composerRef.current?.setMediaUrl(example.mediaUrl);
|
77 |
+
}}
|
78 |
+
>
|
79 |
+
<div className="flex flex-row items-center space-x-2">
|
80 |
+
<p className="text-primary text-sm">{example.title}</p>
|
81 |
+
<IconArrowUpRight className="text-primary" />
|
82 |
+
</div>
|
83 |
+
</Chip>
|
84 |
+
);
|
85 |
+
})}
|
86 |
+
</>
|
87 |
+
</div>
|
88 |
+
</div>
|
89 |
+
);
|
90 |
}
|
components/ChatSelect.tsx
CHANGED
@@ -52,7 +52,7 @@ const ChatSelect: React.FC<{ myChats: Chat[] }> = ({ myChats }) => {
|
|
52 |
<Select
|
53 |
defaultValue={currentChat?.id}
|
54 |
value={currentChat?.id}
|
55 |
-
onValueChange={id => router.push(
|
56 |
>
|
57 |
<SelectTrigger className="w-[240px]">
|
58 |
{currentChat?.title ?? 'Select a conversation'}
|
|
|
52 |
<Select
|
53 |
defaultValue={currentChat?.id}
|
54 |
value={currentChat?.id}
|
55 |
+
onValueChange={id => router.push(id === 'new' ? '/' : `/chat/${id}`)}
|
56 |
>
|
57 |
<SelectTrigger className="w-[240px]">
|
58 |
{currentChat?.title ?? 'Select a conversation'}
|
components/Header.tsx
CHANGED
@@ -27,7 +27,7 @@ export async function Header() {
|
|
27 |
return (
|
28 |
<header className="sticky top-0 z-50 flex items-center justify-end w-full h-16 px-8 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
|
29 |
<Button variant="link" asChild className="mr-2">
|
30 |
-
<Link href="/
|
31 |
</Button>
|
32 |
</header>
|
33 |
);
|
@@ -68,7 +68,7 @@ export async function Header() {
|
|
68 |
</Button>
|
69 |
)} */}
|
70 |
<Button variant="link" asChild className="mr-2">
|
71 |
-
<Link href="/
|
72 |
</Button>
|
73 |
<Tooltip>
|
74 |
<TooltipTrigger asChild>
|
|
|
27 |
return (
|
28 |
<header className="sticky top-0 z-50 flex items-center justify-end w-full h-16 px-8 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
|
29 |
<Button variant="link" asChild className="mr-2">
|
30 |
+
<Link href="/">New conversation</Link>
|
31 |
</Button>
|
32 |
</header>
|
33 |
);
|
|
|
68 |
</Button>
|
69 |
)} */}
|
70 |
<Button variant="link" asChild className="mr-2">
|
71 |
+
<Link href="/">New conversation</Link>
|
72 |
</Button>
|
73 |
<Tooltip>
|
74 |
<TooltipTrigger asChild>
|
components/chat/ChatClient.tsx
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
'use client';
|
2 |
|
3 |
// import { ChatList } from '@/components/chat/ChatList';
|
4 |
-
import
|
5 |
import useVisionAgent from '@/lib/hooks/useVisionAgent';
|
6 |
import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
|
7 |
import { Session } from 'next-auth';
|
@@ -17,12 +17,14 @@ export interface ChatClientProps {
|
|
17 |
chat: ChatWithMessages;
|
18 |
}
|
19 |
|
|
|
|
|
20 |
const ChatClient: React.FC<ChatClientProps> = ({ chat }) => {
|
21 |
const { mediaUrl, id } = chat;
|
22 |
const { messages, append, isLoading, reload } = useVisionAgent(chat);
|
23 |
|
24 |
const { messagesRef, scrollRef, visibilityRef, isVisible, scrollToBottom } =
|
25 |
-
useScrollAnchor();
|
26 |
|
27 |
// Scroll to bottom when messages are loading
|
28 |
useEffect(() => {
|
@@ -46,12 +48,15 @@ const ChatClient: React.FC<ChatClientProps> = ({ chat }) => {
|
|
46 |
isLoading={isLoading && index === messages.length - 1}
|
47 |
/>
|
48 |
))}
|
49 |
-
<div
|
|
|
|
|
|
|
|
|
50 |
</div>
|
51 |
-
<div className="absolute bottom-
|
52 |
<Composer
|
53 |
-
|
54 |
-
mediaUrl={mediaUrl}
|
55 |
isLoading={isLoading}
|
56 |
onSubmit={async ({ input, mediaUrl: newMediaUrl }) => {
|
57 |
append({
|
|
|
1 |
'use client';
|
2 |
|
3 |
// import { ChatList } from '@/components/chat/ChatList';
|
4 |
+
import Composer from '@/components/chat/Composer';
|
5 |
import useVisionAgent from '@/lib/hooks/useVisionAgent';
|
6 |
import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
|
7 |
import { Session } from 'next-auth';
|
|
|
17 |
chat: ChatWithMessages;
|
18 |
}
|
19 |
|
20 |
+
export const SCROLL_BOTTOM = 120;
|
21 |
+
|
22 |
const ChatClient: React.FC<ChatClientProps> = ({ chat }) => {
|
23 |
const { mediaUrl, id } = chat;
|
24 |
const { messages, append, isLoading, reload } = useVisionAgent(chat);
|
25 |
|
26 |
const { messagesRef, scrollRef, visibilityRef, isVisible, scrollToBottom } =
|
27 |
+
useScrollAnchor(SCROLL_BOTTOM);
|
28 |
|
29 |
// Scroll to bottom when messages are loading
|
30 |
useEffect(() => {
|
|
|
48 |
isLoading={isLoading && index === messages.length - 1}
|
49 |
/>
|
50 |
))}
|
51 |
+
<div
|
52 |
+
className="w-full"
|
53 |
+
style={{ height: SCROLL_BOTTOM }}
|
54 |
+
ref={visibilityRef}
|
55 |
+
/>
|
56 |
</div>
|
57 |
+
<div className="absolute bottom-4 w-full">
|
58 |
<Composer
|
59 |
+
initMediaUrl={mediaUrl}
|
|
|
60 |
isLoading={isLoading}
|
61 |
onSubmit={async ({ input, mediaUrl: newMediaUrl }) => {
|
62 |
append({
|
components/chat/Composer.tsx
CHANGED
@@ -1,6 +1,12 @@
|
|
1 |
'use client';
|
2 |
|
3 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
|
5 |
import { Button } from '@/components/ui/Button';
|
6 |
import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
|
@@ -10,17 +16,8 @@ import {
|
|
10 |
TooltipContent,
|
11 |
TooltipTrigger,
|
12 |
} from '@/components/ui/Tooltip';
|
13 |
-
import {
|
14 |
-
IconArrowElbow,
|
15 |
-
IconImage,
|
16 |
-
IconArrowUp,
|
17 |
-
IconRefresh,
|
18 |
-
IconStop,
|
19 |
-
IconClose,
|
20 |
-
} from '@/components/ui/Icons';
|
21 |
import { cn } from '@/lib/utils';
|
22 |
-
import { generateInputImageMarkdown } from '@/lib/messageUtils';
|
23 |
-
import { Switch } from '../ui/Switch';
|
24 |
import Chip from '../ui/Chip';
|
25 |
import Textarea from 'react-textarea-autosize';
|
26 |
import useMediaUpload from '@/lib/hooks/useMediaUpload';
|
@@ -28,140 +25,164 @@ import useMediaUpload from '@/lib/hooks/useMediaUpload';
|
|
28 |
export interface ComposerProps {
|
29 |
onSubmit: (params: { input: string; mediaUrl: string }) => Promise<void>;
|
30 |
isLoading?: boolean;
|
31 |
-
id?: string;
|
32 |
title?: string;
|
33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
}
|
35 |
|
36 |
-
|
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 |
<div
|
|
|
68 |
className={cn(
|
69 |
-
'w-
|
70 |
-
|
71 |
)}
|
72 |
>
|
73 |
-
<
|
74 |
-
|
75 |
-
{localMediaUrl ? (
|
76 |
-
<Chip className="mb-0.5">
|
77 |
-
<div className="flex flex-row items-center space-x-2">
|
78 |
-
<Tooltip>
|
79 |
-
<TooltipTrigger>
|
80 |
-
<div className="flex flex-row items-center space-x-2">
|
81 |
-
<IconImage className="size-3" />
|
82 |
-
<p>{mediaName ?? 'unnamed_media'}</p>
|
83 |
-
</div>
|
84 |
-
</TooltipTrigger>
|
85 |
-
<TooltipContent sideOffset={8}>
|
86 |
-
<Img
|
87 |
-
src={localMediaUrl}
|
88 |
-
className="m-1"
|
89 |
-
quality={100}
|
90 |
-
alt="zoomed-in-image"
|
91 |
-
/>
|
92 |
-
</TooltipContent>
|
93 |
-
</Tooltip>
|
94 |
-
<Button
|
95 |
-
size="icon"
|
96 |
-
variant="ghost"
|
97 |
-
className="size-4"
|
98 |
-
onClick={() => setLocalMediaUrl(undefined)}
|
99 |
-
>
|
100 |
-
<IconClose className="size-3" />
|
101 |
-
</Button>
|
102 |
-
</div>
|
103 |
-
</Chip>
|
104 |
-
) : (
|
105 |
-
<Button
|
106 |
-
variant="ghost"
|
107 |
-
size="sm"
|
108 |
className={cn(
|
109 |
-
'
|
110 |
-
|
111 |
)}
|
112 |
-
onClick={openUpload}
|
113 |
>
|
114 |
-
<
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
'use client';
|
2 |
|
3 |
+
import {
|
4 |
+
useState,
|
5 |
+
useEffect,
|
6 |
+
useRef,
|
7 |
+
forwardRef,
|
8 |
+
useImperativeHandle,
|
9 |
+
} from 'react';
|
10 |
|
11 |
import { Button } from '@/components/ui/Button';
|
12 |
import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
|
|
|
16 |
TooltipContent,
|
17 |
TooltipTrigger,
|
18 |
} from '@/components/ui/Tooltip';
|
19 |
+
import { IconImage, IconArrowUp, IconClose } from '@/components/ui/Icons';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
import { cn } from '@/lib/utils';
|
|
|
|
|
21 |
import Chip from '../ui/Chip';
|
22 |
import Textarea from 'react-textarea-autosize';
|
23 |
import useMediaUpload from '@/lib/hooks/useMediaUpload';
|
|
|
25 |
export interface ComposerProps {
|
26 |
onSubmit: (params: { input: string; mediaUrl: string }) => Promise<void>;
|
27 |
isLoading?: boolean;
|
|
|
28 |
title?: string;
|
29 |
+
initMediaUrl?: string;
|
30 |
+
initInput?: string;
|
31 |
+
}
|
32 |
+
|
33 |
+
export interface ComposerRef {
|
34 |
+
setMediaUrl: (url: string) => void;
|
35 |
+
setInput: (input: string) => void;
|
36 |
}
|
37 |
|
38 |
+
const Composer = forwardRef<ComposerRef, ComposerProps>(
|
39 |
+
({ isLoading, onSubmit, initMediaUrl, initInput }, ref) => {
|
40 |
+
const { formRef, onKeyDown } = useEnterSubmit();
|
41 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
42 |
+
const [localMediaUrl, setLocalMediaUrl] = useState<string | undefined>(
|
43 |
+
initMediaUrl,
|
44 |
+
);
|
45 |
+
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
46 |
+
const [input, setLocalInput] = useState(initInput ?? '');
|
47 |
+
const noMediaValidation = !localMediaUrl && !!input;
|
48 |
+
const {
|
49 |
+
getRootProps,
|
50 |
+
getInputProps,
|
51 |
+
isDragActive,
|
52 |
+
isUploading,
|
53 |
+
openUpload,
|
54 |
+
} = useMediaUpload(uploadUrl => setLocalMediaUrl(uploadUrl));
|
55 |
|
56 |
+
const finalLoading = isLoading || isUploading || isSubmitting;
|
57 |
|
58 |
+
useEffect(() => {
|
59 |
+
if (inputRef.current) {
|
60 |
+
inputRef.current.focus();
|
61 |
+
}
|
62 |
+
}, []);
|
63 |
|
64 |
+
useImperativeHandle(ref, () => ({
|
65 |
+
setMediaUrl(mediaUrl) {
|
66 |
+
setLocalMediaUrl(mediaUrl);
|
67 |
+
},
|
68 |
+
setInput(input) {
|
69 |
+
setLocalInput(input);
|
70 |
+
},
|
71 |
+
}));
|
72 |
+
|
73 |
+
const mediaName = localMediaUrl?.split('/').pop();
|
74 |
+
return (
|
75 |
<div
|
76 |
+
{...getRootProps()}
|
77 |
className={cn(
|
78 |
+
'w-full mx-auto max-w-2xl px-6 py-4 bg-zinc-600 rounded-xl relative shadow-lg shadow-zinc-600/40',
|
79 |
+
isDragActive && 'bg-indigo-700/50',
|
80 |
)}
|
81 |
>
|
82 |
+
<input {...getInputProps()} />
|
83 |
+
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
className={cn(
|
85 |
+
'w-1/3 h-1 rounded-full overflow-hidden bg-zinc-700 absolute left-1/2 -translate-x-1/2 top-2',
|
86 |
+
finalLoading ? 'opacity-100' : 'opacity-0',
|
87 |
)}
|
|
|
88 |
>
|
89 |
+
<div className="h-full bg-primary animate-progress origin-left-right" />
|
90 |
+
</div>
|
91 |
+
{localMediaUrl ? (
|
92 |
+
<Chip className="mb-0.5">
|
93 |
+
<div className="flex flex-row items-center space-x-2">
|
94 |
+
<Tooltip>
|
95 |
+
<TooltipTrigger>
|
96 |
+
<div className="flex flex-row items-center space-x-2">
|
97 |
+
<IconImage className="size-3" />
|
98 |
+
<p>{mediaName ?? 'unnamed_media'}</p>
|
99 |
+
</div>
|
100 |
+
</TooltipTrigger>
|
101 |
+
<TooltipContent sideOffset={12}>
|
102 |
+
<Img
|
103 |
+
src={localMediaUrl}
|
104 |
+
className="m-1"
|
105 |
+
quality={100}
|
106 |
+
alt="zoomed-in-image"
|
107 |
+
/>
|
108 |
+
</TooltipContent>
|
109 |
+
</Tooltip>
|
110 |
+
<Button
|
111 |
+
size="icon"
|
112 |
+
variant="ghost"
|
113 |
+
className="size-4"
|
114 |
+
onClick={() => setLocalMediaUrl(undefined)}
|
115 |
+
>
|
116 |
+
<IconClose className="size-3" />
|
117 |
+
</Button>
|
118 |
+
</div>
|
119 |
+
</Chip>
|
120 |
+
) : (
|
121 |
+
<Button
|
122 |
+
variant="ghost"
|
123 |
+
size="sm"
|
124 |
+
className={cn(
|
125 |
+
'ml-[-10px] border border-transparent',
|
126 |
+
noMediaValidation && 'border-red-500/50 text-red-500',
|
127 |
+
)}
|
128 |
+
onClick={openUpload}
|
129 |
+
>
|
130 |
+
<IconImage className="mr-2 size-4" />
|
131 |
+
{noMediaValidation ? 'Select media (required)' : 'Select media'}
|
132 |
+
</Button>
|
133 |
+
)}
|
134 |
+
<form
|
135 |
+
onSubmit={async e => {
|
136 |
+
e.preventDefault();
|
137 |
+
if (!input?.trim() || !localMediaUrl) {
|
138 |
+
return;
|
139 |
+
}
|
140 |
+
setIsSubmitting(true);
|
141 |
+
try {
|
142 |
+
await onSubmit({ input, mediaUrl: localMediaUrl });
|
143 |
+
} finally {
|
144 |
+
setIsSubmitting(false);
|
145 |
+
setLocalInput('');
|
146 |
+
}
|
147 |
+
}}
|
148 |
+
ref={formRef}
|
149 |
+
className="h-full mt-4"
|
150 |
+
>
|
151 |
+
{/* <div className="border-gray-500 flex overflow-hidden size-full flex flex-row items-center"> */}
|
152 |
+
<Textarea
|
153 |
+
ref={inputRef}
|
154 |
+
tabIndex={0}
|
155 |
+
onKeyDown={onKeyDown}
|
156 |
+
rows={1}
|
157 |
+
value={input}
|
158 |
+
disabled={finalLoading}
|
159 |
+
onChange={e => setLocalInput(e.target.value)}
|
160 |
+
placeholder={
|
161 |
+
finalLoading ? '🤖 Agent working ✨' : 'Message Vision Agent'
|
162 |
+
}
|
163 |
+
spellCheck={false}
|
164 |
+
className="w-full grow resize-none bg-transparent focus-within:outline-none"
|
165 |
+
/>
|
166 |
+
{/* Submit Icon */}
|
167 |
+
<Tooltip>
|
168 |
+
<TooltipTrigger asChild>
|
169 |
+
<Button
|
170 |
+
type="submit"
|
171 |
+
size="icon"
|
172 |
+
className={cn('size-6 absolute bottom-3 right-3')}
|
173 |
+
disabled={finalLoading || input === '' || noMediaValidation}
|
174 |
+
>
|
175 |
+
<IconArrowUp className="size-3" />
|
176 |
+
</Button>
|
177 |
+
</TooltipTrigger>
|
178 |
+
<TooltipContent>Message Vision Agent</TooltipContent>
|
179 |
+
</Tooltip>
|
180 |
+
</form>
|
181 |
+
</div>
|
182 |
+
);
|
183 |
+
},
|
184 |
+
);
|
185 |
+
|
186 |
+
Composer.displayName = 'Composer';
|
187 |
+
|
188 |
+
export default Composer;
|
components/project/ProjectChat.tsx
CHANGED
@@ -2,13 +2,6 @@
|
|
2 |
|
3 |
import { MediaDetails } from '@/lib/fetch';
|
4 |
import React, { useState } from 'react';
|
5 |
-
import { ChatList } from '../chat/ChatList';
|
6 |
-
import useVisionAgent from '@/lib/hooks/useVisionAgent';
|
7 |
-
import { nanoid } from '@/lib/utils';
|
8 |
-
import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
|
9 |
-
import { Composer } from '../chat/Composer';
|
10 |
-
import { useAtomValue } from 'jotai';
|
11 |
-
import { selectedMediaIdAtom } from '@/state/media';
|
12 |
|
13 |
export interface ChatProps {
|
14 |
mediaList: MediaDetails[];
|
|
|
2 |
|
3 |
import { MediaDetails } from '@/lib/fetch';
|
4 |
import React, { useState } from 'react';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
export interface ChatProps {
|
7 |
mediaList: MediaDetails[];
|
components/ui/Chip.tsx
CHANGED
@@ -4,23 +4,22 @@ import React from 'react';
|
|
4 |
type ChipProps = {
|
5 |
label?: string;
|
6 |
value?: string;
|
7 |
-
color?: 'gray' | 'blue' | 'yellow' | 'purple';
|
8 |
className?: string;
|
9 |
} & React.ComponentProps<'div'>;
|
10 |
|
11 |
const Chip: React.FC<ChipProps> = ({
|
12 |
value,
|
13 |
className,
|
14 |
-
color = 'gray',
|
15 |
children,
|
|
|
16 |
}) => {
|
17 |
return (
|
18 |
<div
|
19 |
className={cn(
|
20 |
'inline-flex items-center rounded-full text-xs mr-2 bg-gray-100 text-gray-500 px-2 py-1',
|
21 |
-
`bg-${color}-100 text-${color}-500`,
|
22 |
className,
|
23 |
)}
|
|
|
24 |
>
|
25 |
<span>{value}</span>
|
26 |
{children}
|
|
|
4 |
type ChipProps = {
|
5 |
label?: string;
|
6 |
value?: string;
|
|
|
7 |
className?: string;
|
8 |
} & React.ComponentProps<'div'>;
|
9 |
|
10 |
const Chip: React.FC<ChipProps> = ({
|
11 |
value,
|
12 |
className,
|
|
|
13 |
children,
|
14 |
+
...props
|
15 |
}) => {
|
16 |
return (
|
17 |
<div
|
18 |
className={cn(
|
19 |
'inline-flex items-center rounded-full text-xs mr-2 bg-gray-100 text-gray-500 px-2 py-1',
|
|
|
20 |
className,
|
21 |
)}
|
22 |
+
{...props}
|
23 |
>
|
24 |
<span>{value}</span>
|
25 |
{children}
|
components/ui/CodeBlock.tsx
CHANGED
@@ -133,7 +133,7 @@ const CodeBlock: FC<Props> = memo(({ language, value }) => {
|
|
133 |
codeTagProps={{
|
134 |
style: {
|
135 |
fontSize: '0.9rem',
|
136 |
-
fontFamily: 'var(--font-mono)',
|
137 |
},
|
138 |
}}
|
139 |
>
|
|
|
133 |
codeTagProps={{
|
134 |
style: {
|
135 |
fontSize: '0.9rem',
|
136 |
+
fontFamily: 'var(--font-geist-mono)',
|
137 |
},
|
138 |
}}
|
139 |
>
|
components/ui/Icons.tsx
CHANGED
@@ -161,6 +161,29 @@ function IconArrowDown({ className, ...props }: React.ComponentProps<'svg'>) {
|
|
161 |
);
|
162 |
}
|
163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
164 |
function IconArrowRight({ className, ...props }: React.ComponentProps<'svg'>) {
|
165 |
return (
|
166 |
<svg
|
@@ -562,6 +585,7 @@ export {
|
|
562 |
IconArrowDown,
|
563 |
IconArrowRight,
|
564 |
IconArrowUp,
|
|
|
565 |
IconUser,
|
566 |
IconPlus,
|
567 |
IconArrowElbow,
|
|
|
161 |
);
|
162 |
}
|
163 |
|
164 |
+
function IconArrowUpRight({
|
165 |
+
className,
|
166 |
+
...props
|
167 |
+
}: React.ComponentProps<'svg'>) {
|
168 |
+
return (
|
169 |
+
<svg
|
170 |
+
height="16"
|
171 |
+
strokeLinejoin="round"
|
172 |
+
viewBox="0 0 16 16"
|
173 |
+
width="16"
|
174 |
+
className={cn('size-4', className)}
|
175 |
+
{...props}
|
176 |
+
>
|
177 |
+
<path
|
178 |
+
fillRule="evenodd"
|
179 |
+
clipRule="evenodd"
|
180 |
+
d="M5.75001 2H5.00001V3.5H5.75001H11.4393L2.21968 12.7197L1.68935 13.25L2.75001 14.3107L3.28034 13.7803L12.4988 4.56182V10.25V11H13.9988V10.25V3C13.9988 2.44772 13.5511 2 12.9988 2H5.75001Z"
|
181 |
+
fill="currentColor"
|
182 |
+
></path>
|
183 |
+
</svg>
|
184 |
+
);
|
185 |
+
}
|
186 |
+
|
187 |
function IconArrowRight({ className, ...props }: React.ComponentProps<'svg'>) {
|
188 |
return (
|
189 |
<svg
|
|
|
585 |
IconArrowDown,
|
586 |
IconArrowRight,
|
587 |
IconArrowUp,
|
588 |
+
IconArrowUpRight,
|
589 |
IconUser,
|
590 |
IconPlus,
|
591 |
IconArrowElbow,
|
lib/hooks/useScrollAnchor.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
import { useCallback, useEffect, useRef, useState } from 'react';
|
2 |
|
3 |
-
export const useScrollAnchor = () => {
|
4 |
const messagesRef = useRef<HTMLDivElement>(null);
|
5 |
const scrollRef = useRef<HTMLDivElement>(null);
|
6 |
const visibilityRef = useRef<HTMLDivElement>(null);
|
@@ -72,7 +72,7 @@ export const useScrollAnchor = () => {
|
|
72 |
});
|
73 |
},
|
74 |
{
|
75 |
-
rootMargin:
|
76 |
},
|
77 |
);
|
78 |
|
|
|
1 |
import { useCallback, useEffect, useRef, useState } from 'react';
|
2 |
|
3 |
+
export const useScrollAnchor = (scrollBottom: number) => {
|
4 |
const messagesRef = useRef<HTMLDivElement>(null);
|
5 |
const scrollRef = useRef<HTMLDivElement>(null);
|
6 |
const visibilityRef = useRef<HTMLDivElement>(null);
|
|
|
72 |
});
|
73 |
},
|
74 |
{
|
75 |
+
rootMargin: `0px 0px -${scrollBottom}px 0px`,
|
76 |
},
|
77 |
);
|
78 |
|
tailwind.config.ts
CHANGED
@@ -18,6 +18,10 @@ const config = {
|
|
18 |
},
|
19 |
},
|
20 |
extend: {
|
|
|
|
|
|
|
|
|
21 |
colors: {
|
22 |
border: 'hsl(var(--border))',
|
23 |
input: 'hsl(var(--input))',
|
|
|
18 |
},
|
19 |
},
|
20 |
extend: {
|
21 |
+
fontFamily: {
|
22 |
+
sans: ['var(--font-geist-sans)'],
|
23 |
+
mono: ['var(--font-geist-mono)'],
|
24 |
+
},
|
25 |
colors: {
|
26 |
border: 'hsl(var(--border))',
|
27 |
input: 'hsl(var(--input))',
|