MingruiZhang commited on
Commit
c2f566c
2 Parent(s): 006a5fc 973f0d8

Merge branch 'main' of github.com:landing-ai/vision-agent-ui

Browse files
app/api/upload/route.ts CHANGED
@@ -1,8 +1,9 @@
1
  import { auth } from '@/auth';
2
  import { upload } from '@/lib/aws';
 
3
  import { ChatEntity, MessageBase } from '@/lib/types';
4
  import { nanoid } from '@/lib/utils';
5
- import { kv } from '@vercel/kv';
6
 
7
  /**
8
  * TODO: this should be replaced with actual upload to S3
@@ -11,7 +12,7 @@ import { kv } from '@vercel/kv';
11
  */
12
  export async function POST(req: Request): Promise<Response> {
13
  const session = await auth();
14
- const email = session?.user?.email;
15
  // if (!email) {
16
  // return new Response('Unauthorized', {
17
  // status: 401,
@@ -37,7 +38,7 @@ export async function POST(req: Request): Promise<Response> {
37
 
38
  let urlToSave = url;
39
  if (base64) {
40
- const fileName = `${email}/${id}/${Date.now()}-image.jpg`;
41
  const res = await upload(base64, fileName, fileType ?? 'image/png');
42
  if (res.ok) {
43
  console.log('Image uploaded successfully');
@@ -50,21 +51,13 @@ export async function POST(req: Request): Promise<Response> {
50
  const payload: ChatEntity = {
51
  url: urlToSave!, // TODO can be uploaded as well
52
  id,
53
- user: email || 'anonymous',
54
  messages: initMessages ?? [],
55
  };
56
 
57
- await kv.hmset(`chat:${id}`, payload);
58
- if (email) {
59
- await kv.zadd(`user:chat:${email}`, {
60
- score: Date.now(),
61
- member: `chat:${id}`,
62
- });
63
- }
64
- await kv.zadd('user:chat:all', {
65
- score: Date.now(),
66
- member: `chat:${id}`,
67
- });
68
 
69
  return Response.json(payload);
70
  } catch (error) {
 
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
 
12
  */
13
  export async function POST(req: Request): Promise<Response> {
14
  const session = await auth();
15
+ const user = session?.user?.email ?? 'anonymous';
16
  // if (!email) {
17
  // return new Response('Unauthorized', {
18
  // status: 401,
 
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');
 
51
  const payload: ChatEntity = {
52
  url: urlToSave!, // TODO can be uploaded as well
53
  id,
54
+ user,
55
  messages: initMessages ?? [],
56
  };
57
 
58
+ await createKVChat(payload);
59
+
60
+ revalidatePath('/chat', 'layout');
 
 
 
 
 
 
 
 
61
 
62
  return Response.json(payload);
63
  } catch (error) {
app/chat/[id]/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import { nanoid } from '@/lib/utils';
2
- import { Chat } from '@/components/chat';
3
- import { getKVChat } from '@/lib/kv/chat';
4
 
5
  interface PageProps {
6
  params: {
@@ -11,7 +11,9 @@ interface PageProps {
11
  export default async function Page({ params }: PageProps) {
12
  const { id: chatId } = params;
13
 
14
- const chat = await getKVChat(chatId);
15
-
16
- return <Chat chat={chat} />;
 
 
17
  }
 
1
+ import ChatDataLoad from '@/components/chat/ChatDataLoad';
2
+ import { Suspense } from 'react';
3
+ import Loading from '@/components/ui/Loading';
4
 
5
  interface PageProps {
6
  params: {
 
11
  export default async function Page({ params }: PageProps) {
12
  const { id: chatId } = params;
13
 
14
+ return (
15
+ <Suspense fallback={<Loading />}>
16
+ <ChatDataLoad chatId={chatId} />
17
+ </Suspense>
18
+ );
19
  }
app/globals.css CHANGED
@@ -110,3 +110,54 @@
110
  pointer-events: none;
111
  }
112
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  pointer-events: none;
111
  }
112
  }
113
+
114
+ /* Light theme. */
115
+ :root {
116
+ --color-canvas-default: #ffffff;
117
+ --color-canvas-subtle: #f6f8fa;
118
+ --color-border-default: #d0d7de;
119
+ --color-border-muted: hsla(210, 18%, 87%, 1);
120
+ }
121
+
122
+ /* Dark theme. */
123
+ @media (prefers-color-scheme: dark) {
124
+ :root {
125
+ --color-canvas-default: #0d1117;
126
+ --color-canvas-subtle: #161b22;
127
+ --color-border-default: #30363d;
128
+ --color-border-muted: #21262d;
129
+ }
130
+ }
131
+
132
+ table {
133
+ border-spacing: 0;
134
+ border-collapse: collapse;
135
+ display: block;
136
+ margin-top: 0;
137
+ margin-bottom: 16px;
138
+ width: max-content;
139
+ max-width: 100%;
140
+ overflow: auto;
141
+ }
142
+
143
+ tr {
144
+ border-top: 1px solid var(--color-border-muted);
145
+ }
146
+
147
+ tr:nth-child(2n) {
148
+ background-color: var(--color-canvas-subtle);
149
+ }
150
+
151
+ td,
152
+ th {
153
+ padding: 6px 13px;
154
+ border: 1px solid var(--color-border-default);
155
+ }
156
+
157
+ th {
158
+ font-weight: 600;
159
+ }
160
+
161
+ table img {
162
+ background-color: transparent;
163
+ }
components/chat-sidebar/ChatCard.tsx CHANGED
@@ -18,7 +18,7 @@ export const ChatCardLayout: React.FC<
18
  return (
19
  <Link
20
  className={cn(
21
- 'p-2 m-2 bg-background l:h-[250px] rounded-xl shadow-md flex items-center border border-transparent hover:border-gray-500 transition-all cursor-pointer',
22
  classNames,
23
  )}
24
  href={link}
@@ -31,7 +31,12 @@ export const ChatCardLayout: React.FC<
31
  const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
32
  const { id: chatIdFromParam } = useParams();
33
  const { id, url, messages, user } = chat;
34
- const firstMessage = messages?.[0]?.content.slice(0, 50);
 
 
 
 
 
35
  return (
36
  <ChatCardLayout
37
  link={`/chat/${id}`}
@@ -45,9 +50,9 @@ const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
45
  height={50}
46
  className="rounded w-1/4 "
47
  />
48
- <p className="text-sm w-3/4 ml-3">
49
- {firstMessage ? firstMessage + ' ...' : '(No messages yet)'}
50
- </p>
51
  </div>
52
  </ChatCardLayout>
53
  );
 
18
  return (
19
  <Link
20
  className={cn(
21
+ 'p-2 m-2 bg-background max-h-[100px] rounded-xl shadow-md flex items-center border border-transparent hover:border-gray-500 transition-all cursor-pointer',
22
  classNames,
23
  )}
24
  href={link}
 
31
  const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
32
  const { id: chatIdFromParam } = useParams();
33
  const { id, url, messages, user } = chat;
34
+ const firstMessage = messages?.[0]?.content;
35
+ const title = firstMessage
36
+ ? firstMessage.length > 50
37
+ ? firstMessage.slice(0, 50) + '...'
38
+ : firstMessage
39
+ : '(No messages yet)';
40
  return (
41
  <ChatCardLayout
42
  link={`/chat/${id}`}
 
50
  height={50}
51
  className="rounded w-1/4 "
52
  />
53
+ <div className="flex items-start h-full">
54
+ <p className="text-sm w-3/4 ml-3">{title}</p>
55
+ </div>
56
  </div>
57
  </ChatCardLayout>
58
  );
components/chat/ChatDataLoad.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getKVChat } from '@/lib/kv/chat';
2
+ import { Chat } from '.';
3
+
4
+ export interface ChatDataLoadProps {
5
+ chatId: string;
6
+ }
7
+
8
+ export default async function ChatDataLoad({ chatId }: ChatDataLoadProps) {
9
+ const chat = await getKVChat(chatId);
10
+ return <Chat chat={chat} />;
11
+ }
components/chat/ChatMessage.tsx CHANGED
@@ -15,7 +15,67 @@ export interface ChatMessageProps {
15
  message: MessageBase;
16
  }
17
 
 
 
 
 
 
 
 
 
 
 
18
  export function ChatMessage({ message, ...props }: ChatMessageProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  return (
20
  <div className={cn('group relative mb-4 flex items-start')} {...props}>
21
  <div
@@ -29,12 +89,39 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
29
  {message.role === 'user' ? <IconUser /> : <IconOpenAI />}
30
  </div>
31
  <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  <MemoizedReactMarkdown
33
  className="break-words"
34
  remarkPlugins={[remarkGfm, remarkMath]}
35
  components={{
36
  p({ children }) {
37
- return <p className="mb-2 last:mb-0">{children}</p>;
 
 
38
  },
39
  code({ node, inline, className, children, ...props }) {
40
  if (children.length) {
@@ -48,7 +135,6 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
48
  }
49
 
50
  const match = /language-(\w+)/.exec(className || '');
51
-
52
  if (inline) {
53
  return (
54
  <code className={className} {...props}>
@@ -68,7 +154,7 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
68
  },
69
  }}
70
  >
71
- {message.content}
72
  </MemoizedReactMarkdown>
73
  <ChatMessageActions message={message} />
74
  </div>
 
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 cleanupMessage = ({ content, role }: MessageBase) => {
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
 
89
  {message.role === 'user' ? <IconUser /> : <IconOpenAI />}
90
  </div>
91
  <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
92
+ {logs && message.role !== 'user' && (
93
+ <div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-auto">
94
+ <div className="text-xl font-bold">Thinking Process</div>
95
+ <MemoizedReactMarkdown
96
+ className="break-words text-sm"
97
+ remarkPlugins={[remarkGfm, remarkMath]}
98
+ components={{
99
+ p({ children }) {
100
+ return (
101
+ <p className="my-2 last:mb-0 whitespace-pre-line">
102
+ {children}
103
+ </p>
104
+ );
105
+ },
106
+ code({ children, ...props }) {
107
+ return (
108
+ <code className="whitespace-pre-line">{children}</code>
109
+ );
110
+ },
111
+ }}
112
+ >
113
+ {logs}
114
+ </MemoizedReactMarkdown>
115
+ </div>
116
+ )}
117
  <MemoizedReactMarkdown
118
  className="break-words"
119
  remarkPlugins={[remarkGfm, remarkMath]}
120
  components={{
121
  p({ children }) {
122
+ return (
123
+ <p className="my-2 last:mb-0 whitespace-pre-line">{children}</p>
124
+ );
125
  },
126
  code({ node, inline, className, children, ...props }) {
127
  if (children.length) {
 
135
  }
136
 
137
  const match = /language-(\w+)/.exec(className || '');
 
138
  if (inline) {
139
  return (
140
  <code className={className} {...props}>
 
154
  },
155
  }}
156
  >
157
+ {content}
158
  </MemoizedReactMarkdown>
159
  <ChatMessageActions message={message} />
160
  </div>
components/chat/ImageSelector.tsx CHANGED
@@ -51,13 +51,13 @@ const ImageSelector: React.FC<ImageSelectorProps> = () => {
51
  )}
52
  >
53
  <input {...getInputProps()} />
54
- <p className="text-gray-400 text-lg">
55
  {isUploading ? (
56
  <Loading />
57
  ) : (
58
  'Drag or drop image here, or click to select images'
59
  )}
60
- </p>
61
  </div>
62
  );
63
  };
 
51
  )}
52
  >
53
  <input {...getInputProps()} />
54
+ <div className="text-gray-400 text-lg">
55
  {isUploading ? (
56
  <Loading />
57
  ) : (
58
  'Drag or drop image here, or click to select images'
59
  )}
60
+ </div>
61
  </div>
62
  );
63
  };
components/chat/PromptForm.tsx CHANGED
@@ -54,8 +54,8 @@ export function PromptForm({
54
  <TooltipTrigger asChild>
55
  <Image
56
  src={url}
57
- width={60}
58
- height={60}
59
  alt="chosen image"
60
  className="w-1/5 my-4 mx-2 rounded-md"
61
  />
 
54
  <TooltipTrigger asChild>
55
  <Image
56
  src={url}
57
+ width={250}
58
+ height={250}
59
  alt="chosen image"
60
  className="w-1/5 my-4 mx-2 rounded-md"
61
  />
components/ui/ImageLoader.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // import Image from 'next/image';
2
+ // import React from 'react';
3
+
4
+ // type ImageLoaderProps = typeof Image;
5
+
6
+ // const ImageLoader: React.FC<ImageLoaderProps> = props => {
7
+ // const { alt, width, height } = props;
8
+ // return <Image alt={alt} />;
9
+ // };
10
+
11
+ // export default ImageLoader;
lib/hooks/useVisionAgent.tsx CHANGED
@@ -1,13 +1,14 @@
1
- import { useChat, type Message } from 'ai/react';
2
  import { toast } from 'react-hot-toast';
3
  import { useEffect, useState } from 'react';
4
  import { ChatEntity, MessageBase } from '../types';
 
5
 
6
  const useVisionAgent = (chat: ChatEntity) => {
7
  const { messages: initialMessages, id, url } = chat;
8
  const {
9
  messages,
10
- append,
11
  reload,
12
  stop,
13
  isLoading,
@@ -22,6 +23,9 @@ const useVisionAgent = (chat: ChatEntity) => {
22
  toast.error(response.statusText);
23
  }
24
  },
 
 
 
25
  initialMessages: initialMessages,
26
  body: {
27
  url,
@@ -58,6 +62,16 @@ const useVisionAgent = (chat: ChatEntity) => {
58
  };
59
  }, [isLoading]);
60
 
 
 
 
 
 
 
 
 
 
 
61
  const assistantLoadingMessage = {
62
  id: 'loading',
63
  content: loadingDots,
@@ -71,6 +85,11 @@ const useVisionAgent = (chat: ChatEntity) => {
71
  ? [...messages, assistantLoadingMessage]
72
  : messages;
73
 
 
 
 
 
 
74
  return {
75
  messages: messageWithLoading as MessageBase[],
76
  append,
 
1
+ import { useChat, type Message, UseChatHelpers } from 'ai/react';
2
  import { toast } from 'react-hot-toast';
3
  import { useEffect, useState } from 'react';
4
  import { ChatEntity, MessageBase } from '../types';
5
+ import { saveKVChatMessage } from '../kv/chat';
6
 
7
  const useVisionAgent = (chat: ChatEntity) => {
8
  const { messages: initialMessages, id, url } = chat;
9
  const {
10
  messages,
11
+ append: appendRaw,
12
  reload,
13
  stop,
14
  isLoading,
 
23
  toast.error(response.statusText);
24
  }
25
  },
26
+ onFinish(message) {
27
+ saveKVChatMessage(id, message);
28
+ },
29
  initialMessages: initialMessages,
30
  body: {
31
  url,
 
62
  };
63
  }, [isLoading]);
64
 
65
+ useEffect(() => {
66
+ if (
67
+ !isLoading &&
68
+ messages.length &&
69
+ messages[messages.length - 1].role === 'user'
70
+ ) {
71
+ reload();
72
+ }
73
+ }, [isLoading, messages, reload]);
74
+
75
  const assistantLoadingMessage = {
76
  id: 'loading',
77
  content: loadingDots,
 
85
  ? [...messages, assistantLoadingMessage]
86
  : messages;
87
 
88
+ const append: UseChatHelpers['append'] = async message => {
89
+ await saveKVChatMessage(id, message as MessageBase);
90
+ return appendRaw(message);
91
+ };
92
+
93
  return {
94
  messages: messageWithLoading as MessageBase[],
95
  append,
lib/kv/chat.ts CHANGED
@@ -4,16 +4,17 @@ import { revalidatePath } from 'next/cache';
4
  import { kv } from '@vercel/kv';
5
 
6
  import { auth } from '@/auth';
7
- import { ChatEntity } from '@/lib/types';
8
- import { redirect } from 'next/navigation';
 
9
 
10
  async function authCheck() {
11
  const session = await auth();
12
  const email = session?.user?.email;
13
- if (!email) {
14
- redirect('/');
15
- }
16
- return { email, isAdmin: email.endsWith('landing.ai') };
17
  }
18
 
19
  export async function getKVChats() {
@@ -48,6 +49,37 @@ export async function getKVChat(id: string) {
48
  return chat;
49
  }
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  export async function removeKVChat({ id, path }: { id: string; path: string }) {
52
  const session = await auth();
53
 
@@ -69,6 +101,6 @@ export async function removeKVChat({ id, path }: { id: string; path: string }) {
69
  await kv.del(`chat:${id}`);
70
  await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`);
71
 
72
- revalidatePath('/');
73
  return revalidatePath(path);
74
  }
 
4
  import { kv } from '@vercel/kv';
5
 
6
  import { auth } from '@/auth';
7
+ import { ChatEntity, MessageBase } from '@/lib/types';
8
+ import { notFound, redirect } from 'next/navigation';
9
+ import { nanoid } from '../utils';
10
 
11
  async function authCheck() {
12
  const session = await auth();
13
  const email = session?.user?.email;
14
+ // if (!email) {
15
+ // redirect('/');
16
+ // }
17
+ return { email, isAdmin: !!email?.endsWith('landing.ai') };
18
  }
19
 
20
  export async function getKVChats() {
 
49
  return chat;
50
  }
51
 
52
+ export async function createKVChat(chat: ChatEntity) {
53
+ // const { email, isAdmin } = await authCheck();
54
+ const { email } = await authCheck();
55
+
56
+ await kv.hmset(`chat:${chat.id}`, chat);
57
+ if (email) {
58
+ await kv.zadd(`user:chat:${email}`, {
59
+ score: Date.now(),
60
+ member: `chat:${chat.id}`,
61
+ });
62
+ }
63
+ await kv.zadd('user:chat:all', {
64
+ score: Date.now(),
65
+ member: `chat:${chat.id}`,
66
+ });
67
+ revalidatePath('/chat', 'layout');
68
+ }
69
+
70
+ export async function saveKVChatMessage(id: string, message: MessageBase) {
71
+ const chat = await kv.hgetall<ChatEntity>(`chat:${id}`);
72
+ if (!chat) {
73
+ notFound();
74
+ }
75
+ const { messages } = chat;
76
+ await kv.hmset(`chat:${id}`, {
77
+ ...chat,
78
+ messages: [...messages, message],
79
+ });
80
+ revalidatePath('/chat', 'layout');
81
+ }
82
+
83
  export async function removeKVChat({ id, path }: { id: string; path: string }) {
84
  const session = await auth();
85
 
 
101
  await kv.del(`chat:${id}`);
102
  await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`);
103
 
104
+ revalidatePath('/chat/layout', 'layout');
105
  return revalidatePath(path);
106
  }