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 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
- import { auth } from '@/auth';
2
- import { redirect } from 'next/navigation';
3
 
4
- export default async function Page() {
5
- redirect('/chat');
6
 
7
- // return (
8
- // <div className="flex flex-col h-[calc(100vh-theme(spacing.16))] items-center justify-center py-10 space-y-2">
9
- // Welcome to Insight Playground
10
- // </div>
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(`/chat${id === 'new' ? '' : '/' + id}`)}
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="/chat">New conversation</Link>
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="/chat">New conversation</Link>
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 { 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,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 className="h-[108px] w-full" ref={visibilityRef} />
 
 
 
 
50
  </div>
51
- <div className="absolute bottom-3 w-full">
52
  <Composer
53
- id={id}
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 { useState, useEffect, useRef } from 'react';
 
 
 
 
 
 
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
- mediaUrl?: string;
 
 
 
 
 
 
34
  }
35
 
36
- export function Composer({ id, isLoading, onSubmit, mediaUrl }: ComposerProps) {
37
- const { formRef, onKeyDown } = useEnterSubmit();
38
- const inputRef = useRef<HTMLTextAreaElement>(null);
39
- const [localMediaUrl, setLocalMediaUrl] = useState<string | undefined>(
40
- mediaUrl,
41
- );
42
- // For local loading state such as submitting
43
- const [localLoading, setLocalLoading] = useState<boolean>(false);
44
- const [input, setInput] = useState('');
45
- const noMediaValidation = !localMediaUrl && !!input;
46
- const { getRootProps, getInputProps, isDragActive, isUploading, openUpload } =
47
- useMediaUpload(uploadUrl => setLocalMediaUrl(uploadUrl));
 
 
 
 
 
48
 
49
- const finalLoading = isLoading || isUploading || localLoading;
50
 
51
- useEffect(() => {
52
- if (inputRef.current) {
53
- inputRef.current.focus();
54
- }
55
- }, []);
56
 
57
- const mediaName = localMediaUrl?.split('/').pop();
58
- return (
59
- <div
60
- {...getRootProps()}
61
- className={cn(
62
- 'w-full mx-auto max-w-2xl px-6 py-4 bg-zinc-700 rounded-xl relative shadow-lg shadow-zinc-700/40',
63
- isDragActive && 'bg-indigo-700/50',
64
- )}
65
- >
66
- <input {...getInputProps()} />
 
67
  <div
 
68
  className={cn(
69
- 'w-1/3 h-1 rounded-full overflow-hidden bg-zinc-700 absolute left-1/2 -translate-x-1/2',
70
- finalLoading ? 'opacity-100' : 'opacity-0',
71
  )}
72
  >
73
- <div className="h-full bg-primary animate-progress origin-left-right" />
74
- </div>
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
- 'ml-[-10px] border-2 border-transparent',
110
- noMediaValidation && 'border-red-500/50 border-2 text-red-500',
111
  )}
112
- onClick={openUpload}
113
  >
114
- <IconImage className="mr-2 size-4" />
115
- {noMediaValidation ? 'Select media (required)' : 'Select media'}
116
- </Button>
117
- )}
118
- <form
119
- onSubmit={async e => {
120
- e.preventDefault();
121
- if (!input?.trim() || !localMediaUrl) {
122
- return;
123
- }
124
- setLocalLoading(true);
125
- try {
126
- await onSubmit({ input, mediaUrl: localMediaUrl });
127
- } finally {
128
- setLocalLoading(false);
129
- setInput('');
130
- }
131
- }}
132
- ref={formRef}
133
- className="h-full mt-4"
134
- >
135
- {/* <div className="border-gray-500 flex overflow-hidden size-full flex flex-row items-center"> */}
136
- <Textarea
137
- ref={inputRef}
138
- tabIndex={0}
139
- onKeyDown={onKeyDown}
140
- rows={1}
141
- value={input}
142
- disabled={finalLoading}
143
- onChange={e => setInput(e.target.value)}
144
- placeholder={
145
- finalLoading ? '🤖 Agent working ✨' : 'Message Vision Agent'
146
- }
147
- spellCheck={false}
148
- className="w-full grow resize-none bg-transparent focus-within:outline-none"
149
- />
150
- {/* Submit Icon */}
151
- <Tooltip>
152
- <TooltipTrigger asChild>
153
- <Button
154
- type="submit"
155
- size="icon"
156
- className={cn('size-6 absolute bottom-3 right-3')}
157
- disabled={finalLoading || input === '' || noMediaValidation}
158
- >
159
- <IconArrowUp className="size-3" />
160
- </Button>
161
- </TooltipTrigger>
162
- <TooltipContent>Message Vision Agent</TooltipContent>
163
- </Tooltip>
164
- </form>
165
- </div>
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: '0px 0px -108px 0px',
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))',