MingruiZhang commited on
Commit
c232e44
1 Parent(s): ca7a659

feat: Assistant Chat (#68)

Browse files

![image](https://github.com/landing-ai/vision-agent-ui/assets/5669963/6b8fe3d4-4b92-4869-926b-3811b0dca96e)

app/chat/page.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { redirect } from 'next/navigation';
2
+
3
+ export interface PageProps {}
4
+
5
+ export default async function Page({}: PageProps) {
6
+ redirect('/');
7
+ }
app/globals.css CHANGED
@@ -75,35 +75,6 @@
75
  }
76
  }
77
 
78
- table {
79
- border-spacing: 0;
80
- border-collapse: collapse;
81
- display: block;
82
- margin-top: 0;
83
- margin-bottom: 16px;
84
- width: max-content;
85
- max-width: 100%;
86
- overflow: auto;
87
- }
88
-
89
- tr {
90
- border-top: 1px solid #21262d;
91
- }
92
-
93
- td,
94
- th {
95
- padding: 6px 13px;
96
- border: 1px solid #21262d;
97
- }
98
-
99
- th {
100
- font-weight: 600;
101
- }
102
-
103
- table img {
104
- background-color: transparent;
105
- }
106
-
107
  h1 {
108
  font-size: 3.75rem; /* 48px */
109
  font-family: var(--font-geist-sans);
 
75
  }
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  h1 {
79
  font-size: 3.75rem; /* 48px */
80
  font-family: var(--font-geist-sans);
components/chat/ChatClient.tsx CHANGED
@@ -38,7 +38,7 @@ const ChatClient: React.FC<ChatClientProps> = ({ chat }) => {
38
  className="h-full overflow-auto mx-auto w-[1024px] max-w-full border rounded-lg relative"
39
  ref={scrollRef}
40
  >
41
- <div className="overflow-auto h-full pt-6 px-6" ref={messagesRef}>
42
  {messages
43
  // .filter(message => message.role !== 'system')
44
  .map((message, index) => (
 
38
  className="h-full overflow-auto mx-auto w-[1024px] max-w-full border rounded-lg relative"
39
  ref={scrollRef}
40
  >
41
+ <div className="overflow-auto h-full pt-6 px-6 z-10" ref={messagesRef}>
42
  {messages
43
  // .filter(message => message.role !== 'system')
44
  .map((message, index) => (
components/chat/ChatMessage.tsx CHANGED
@@ -9,25 +9,39 @@ import { useMemo, useState } from 'react';
9
  import { cn } from '@/lib/utils';
10
  import { CodeBlock } from '@/components/ui/CodeBlock';
11
  import { MemoizedReactMarkdown } from '@/components/chat/MemoizedReactMarkdown';
12
- import { IconLandingAI, IconUser } from '@/components/ui/Icons';
13
- import type { ChatMessage } from '@/lib/db/types';
 
 
 
 
 
 
 
 
 
14
  import Img from '../ui/Img';
15
- import { getFormattedMessage } from '@/lib/messageUtils';
16
- import Loading from '../ui/Loading';
17
  import {
18
  Table,
 
19
  TableCell,
20
  TableHead,
21
  TableHeader,
22
  TableRow,
23
  } from '../ui/Table';
24
  import { Button } from '../ui/Button';
25
- import { Dialog, DialogContent } from '../ui/Dialog';
26
- import { Message } from 'ai';
27
- import { MessageRole } from '@prisma/client';
 
 
 
 
 
28
 
29
  export interface ChatMessageProps {
30
- message: Message;
31
  isLoading: boolean;
32
  }
33
 
@@ -82,9 +96,7 @@ const Markdown: React.FC<{
82
  <p className="flex flex-wrap gap-2 items-start">{children}</p>
83
  );
84
  }
85
- return (
86
- <p className="mb-2 last:mb-0 whitespace-pre-line">{children}</p>
87
- );
88
  },
89
  img(props) {
90
  if (props.src?.endsWith('.mp4')) {
@@ -104,24 +116,7 @@ const Markdown: React.FC<{
104
  );
105
  },
106
  code({ node, inline, className, children, ...props }) {
107
- // if (children.length) {
108
- // if (children[0] == '▍') {
109
- // return (
110
- // <span className="mt-1 cursor-default animate-pulse">▍</span>
111
- // );
112
- // }
113
-
114
- // children[0] = (children[0] as string).replace('`▍`', '▍');
115
- // }
116
-
117
  const match = /language-(\w+)/.exec(className || '');
118
- // if (inline) {
119
- // return (
120
- // <code className={className} {...props}>
121
- // {children}
122
- // </code>
123
- // );
124
- // }
125
 
126
  return (
127
  <CodeBlock
@@ -141,39 +136,161 @@ const Markdown: React.FC<{
141
  };
142
 
143
  export function ChatMessage({ message, isLoading }: ChatMessageProps) {
144
- const { content } = useMemo(() => {
145
- return getFormattedMessage({
146
- content: message.content,
147
- role: message.role as MessageRole,
148
- });
149
- }, [message.content, message.role]);
150
- const [details, setDetails] = useState<string>('');
 
 
 
 
 
151
  return (
152
- <div
153
- className={cn(
154
- 'group relative mb-6 flex rounded-md bg-muted p-4',
155
- message.role === 'user' ? 'ml-auto mr-0 w-3/5' : 'w-4/5',
156
- )}
157
- >
158
- <div
159
- className={cn(
160
- 'flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow',
161
- message.role === 'user'
162
- ? 'bg-background'
163
- : 'bg-primary text-primary-foreground',
164
- )}
165
- >
166
- {message.role === 'user' ? <IconUser /> : <IconLandingAI />}
167
  </div>
168
  <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
169
- {content && <Markdown content={content} setDetails={setDetails} />}
170
- {isLoading && <Loading />}
171
  </div>
172
- <Dialog open={!!details} onOpenChange={open => !open && setDetails('')}>
173
- <DialogContent className="w-11/12">
174
- <Markdown content={details} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  </DialogContent>
176
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  </div>
178
  );
179
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  import { cn } from '@/lib/utils';
10
  import { CodeBlock } from '@/components/ui/CodeBlock';
11
  import { MemoizedReactMarkdown } from '@/components/chat/MemoizedReactMarkdown';
12
+ import {
13
+ IconCheckCircle,
14
+ IconChevronDoubleRight,
15
+ IconCodeWrap,
16
+ IconCrossCircle,
17
+ IconLandingAI,
18
+ IconListUnordered,
19
+ IconTerminalWindow,
20
+ IconUser,
21
+ } from '@/components/ui/Icons';
22
+ import { MessageBase } from '../../lib/types';
23
  import Img from '../ui/Img';
24
+ import { ChunkBody, CodeResult, formatStreamLogs } from '@/lib/messageUtils';
 
25
  import {
26
  Table,
27
+ TableBody,
28
  TableCell,
29
  TableHead,
30
  TableHeader,
31
  TableRow,
32
  } from '../ui/Table';
33
  import { Button } from '../ui/Button';
34
+ import { Separator } from '../ui/Separator';
35
+ import {
36
+ Tooltip,
37
+ TooltipContent,
38
+ TooltipTrigger,
39
+ } from '@/components/ui/Tooltip';
40
+
41
+ import { Dialog, DialogContent, DialogTrigger } from '../ui/Dialog';
42
 
43
  export interface ChatMessageProps {
44
+ message: MessageBase;
45
  isLoading: boolean;
46
  }
47
 
 
96
  <p className="flex flex-wrap gap-2 items-start">{children}</p>
97
  );
98
  }
99
+ return <p className="mb-2 whitespace-pre-line">{children}</p>;
 
 
100
  },
101
  img(props) {
102
  if (props.src?.endsWith('.mp4')) {
 
116
  );
117
  },
118
  code({ node, inline, className, children, ...props }) {
 
 
 
 
 
 
 
 
 
 
119
  const match = /language-(\w+)/.exec(className || '');
 
 
 
 
 
 
 
120
 
121
  return (
122
  <CodeBlock
 
136
  };
137
 
138
  export function ChatMessage({ message, isLoading }: ChatMessageProps) {
139
+ const { role, content } = message;
140
+
141
+ return role === 'user' ? (
142
+ <UserChatMessage content={content} />
143
+ ) : (
144
+ <AssistantChatMessage content={content} />
145
+ );
146
+ }
147
+
148
+ const UserChatMessage: React.FC<{
149
+ content: string;
150
+ }> = ({ content }) => {
151
  return (
152
+ <div className="group relative mb-6 flex rounded-md bg-muted p-4 ml-auto mr-0 w-3/5">
153
+ <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background">
154
+ <IconUser />
 
 
 
 
 
 
 
 
 
 
 
 
155
  </div>
156
  <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
157
+ {content && <Markdown content={content} />}
 
158
  </div>
159
+ </div>
160
+ );
161
+ };
162
+
163
+ const ChunkStatusToIconDict: Record<ChunkBody['status'], React.ReactElement> = {
164
+ started: <IconChevronDoubleRight className="text-cyan-500" />,
165
+ completed: <IconCheckCircle className="text-green-500" />,
166
+ running: <IconTerminalWindow className="text-teal-500" />,
167
+ failed: <IconCrossCircle className="text-red-500" />,
168
+ };
169
+ const ChunkTypeToTextDict: Record<ChunkBody['type'], string> = {
170
+ plans: 'Creating instructions',
171
+ tools: 'Retrieving tools',
172
+ code: 'Generating code',
173
+ final_code: 'Final result',
174
+ };
175
+ const ChunkPayloadAction: React.FC<{
176
+ payload: ChunkBody['payload'];
177
+ }> = ({ payload }) => {
178
+ if (Array.isArray(payload)) {
179
+ // [{title: 123, content, 345}, {title: ..., content: ...}] => ['title', 'content']
180
+ const keyArray = Array.from(
181
+ payload.reduce((acc, curr) => {
182
+ Object.keys(curr).forEach(key => acc.add(key));
183
+ return acc;
184
+ }, new Set<string>()),
185
+ );
186
+
187
+ return (
188
+ <Dialog>
189
+ <DialogTrigger asChild>
190
+ <Button variant="ghost" size="icon">
191
+ <IconListUnordered />
192
+ </Button>
193
+ </DialogTrigger>
194
+ <DialogContent className="max-w-5xl">
195
+ <Table className="border rounded-lg bg-zinc-700 overflow-hidden">
196
+ <TableHeader>
197
+ <TableRow className="border-primary/50">
198
+ {keyArray.map(header => (
199
+ <TableHead key={header}>{header}</TableHead>
200
+ ))}
201
+ </TableRow>
202
+ </TableHeader>
203
+ <TableBody>
204
+ {payload.map((line, index) => (
205
+ <TableRow className="border-primary/50" key={index}>
206
+ {keyArray.map(header => (
207
+ <TableCell key={header}>{line[header]}</TableCell>
208
+ ))}
209
+ </TableRow>
210
+ ))}
211
+ </TableBody>
212
+ </Table>
213
  </DialogContent>
214
  </Dialog>
215
+ );
216
+ } else {
217
+ return (
218
+ <Dialog>
219
+ <DialogTrigger asChild>
220
+ <Button variant="ghost" size="icon">
221
+ <IconCodeWrap />
222
+ </Button>
223
+ </DialogTrigger>
224
+ <DialogContent className="max-w-5xl">
225
+ <CodeResultDisplay codeResult={payload as CodeResult} />
226
+ </DialogContent>
227
+ </Dialog>
228
+ );
229
+ }
230
+ };
231
+
232
+ const CodeResultDisplay: React.FC<{
233
+ codeResult: CodeResult;
234
+ }> = ({ codeResult }) => {
235
+ const { code, test, result } = codeResult;
236
+ return (
237
+ <div className="rounded-lg overflow-hidden relative max-w-5xl">
238
+ <CodeBlock language="python" value={code} />
239
+ <div className="rounded-lg relative">
240
+ <Separator />
241
+ <Tooltip>
242
+ <TooltipTrigger asChild>
243
+ <Button
244
+ variant="ghost"
245
+ size="icon"
246
+ className="size-8 absolute left-1/2 -translate-x-1/2 -top-4 z-10"
247
+ >
248
+ <IconTerminalWindow className="text-teal-500 size-4" />
249
+ </Button>
250
+ </TooltipTrigger>
251
+ <TooltipContent>
252
+ <CodeBlock language="python" value={test} />
253
+ </TooltipContent>
254
+ </Tooltip>
255
+ </div>
256
+ <CodeBlock language="output" value={result} />
257
  </div>
258
  );
259
+ };
260
+
261
+ const AssistantChatMessage: React.FC<{
262
+ content: string;
263
+ }> = ({ content }) => {
264
+ const [formattedSections, codeResult] = useMemo(
265
+ () => formatStreamLogs(content),
266
+ [content],
267
+ );
268
+
269
+ return (
270
+ <div className="group relative mb-6 flex rounded-md bg-muted p-4 w-full">
271
+ <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-primary text-primary-foreground">
272
+ <IconLandingAI />
273
+ </div>
274
+ <div className="flex-1 px-1 space-y-4 ml-4 overflow-hidden">
275
+ <Table className="border rounded-lg bg-zinc-700 overflow-hidden w-[400px]">
276
+ <TableBody>
277
+ {formattedSections.map(section => (
278
+ <TableRow className="border-primary/50" key={section.type}>
279
+ <TableCell>{ChunkStatusToIconDict[section.status]}</TableCell>
280
+ <TableCell className="font-medium">
281
+ {ChunkTypeToTextDict[section.type]}
282
+ </TableCell>
283
+ <TableCell className="text-right">
284
+ <ChunkPayloadAction payload={section.payload} />
285
+ </TableCell>
286
+ </TableRow>
287
+ ))}
288
+ </TableBody>
289
+ </Table>
290
+ {codeResult && <CodeResultDisplay codeResult={codeResult} />}
291
+ </div>
292
+ </div>
293
+ );
294
+ };
295
+
296
+ export default UserChatMessage;
components/chat/Composer.tsx CHANGED
@@ -75,7 +75,7 @@ const Composer = forwardRef<ComposerRef, ComposerProps>(
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
  >
 
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 z-50',
79
  isDragActive && 'bg-indigo-700/50',
80
  )}
81
  >
components/ui/CodeBlock.tsx CHANGED
@@ -92,7 +92,7 @@ const CodeBlock: FC<Props> = memo(({ language, value }) => {
92
  copyToClipboard(value);
93
  };
94
  return (
95
- <div className="relative w-full font-sans codeblock bg-zinc-950 rounded-lg overflow-hidden">
96
  <div className="flex items-center justify-between w-full pl-8 pr-4 pt-2 text-zinc-100">
97
  <span className="text-xs lowercase">{language}</span>
98
  <div className="flex items-center space-x-1">
 
92
  copyToClipboard(value);
93
  };
94
  return (
95
+ <div className="relative w-full codeblock bg-zinc-900 overflow-hidden">
96
  <div className="flex items-center justify-between w-full pl-8 pr-4 pt-2 text-zinc-100">
97
  <span className="text-xs lowercase">{language}</span>
98
  <div className="flex items-center space-x-1">
components/ui/Dialog.tsx CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import * as React from 'react';
4
  import * as DialogPrimitive from '@radix-ui/react-dialog';
5
- import { X } from 'lucide-react';
6
 
7
  import { cn } from '@/lib/utils';
8
 
@@ -38,14 +38,14 @@ const DialogContent = React.forwardRef<
38
  <DialogPrimitive.Content
39
  ref={ref}
40
  className={cn(
41
- 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-3xl max-h-[85vh] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
42
  className,
43
  )}
44
  {...props}
45
  >
46
  {children}
47
  <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
48
- <X className="h-4 w-4" />
49
  <span className="sr-only">Close</span>
50
  </DialogPrimitive.Close>
51
  </DialogPrimitive.Content>
@@ -112,8 +112,8 @@ export {
112
  Dialog,
113
  DialogPortal,
114
  DialogOverlay,
115
- DialogClose,
116
  DialogTrigger,
 
117
  DialogContent,
118
  DialogHeader,
119
  DialogFooter,
 
2
 
3
  import * as React from 'react';
4
  import * as DialogPrimitive from '@radix-ui/react-dialog';
5
+ import { Cross2Icon } from '@radix-ui/react-icons';
6
 
7
  import { cn } from '@/lib/utils';
8
 
 
38
  <DialogPrimitive.Content
39
  ref={ref}
40
  className={cn(
41
+ 'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
42
  className,
43
  )}
44
  {...props}
45
  >
46
  {children}
47
  <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
48
+ <Cross2Icon className="size-4" />
49
  <span className="sr-only">Close</span>
50
  </DialogPrimitive.Close>
51
  </DialogPrimitive.Content>
 
112
  Dialog,
113
  DialogPortal,
114
  DialogOverlay,
 
115
  DialogTrigger,
116
+ DialogClose,
117
  DialogContent,
118
  DialogHeader,
119
  DialogFooter,
components/ui/Icons.tsx CHANGED
@@ -198,6 +198,46 @@ function IconArrowRight({ className, ...props }: React.ComponentProps<'svg'>) {
198
  );
199
  }
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  function IconUser({ className, ...props }: React.ComponentProps<'svg'>) {
202
  return (
203
  <svg
@@ -577,6 +617,117 @@ function IconImage({ className, ...props }: React.ComponentProps<'svg'>) {
577
  );
578
  }
579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  export {
581
  IconEdit,
582
  IconLandingAI,
@@ -610,4 +761,11 @@ export {
610
  IconDiscord,
611
  IconExclamationTriangle,
612
  IconImage,
 
 
 
 
 
 
 
613
  };
 
198
  );
199
  }
200
 
201
+ function IconCheckCircle({ className, ...props }: React.ComponentProps<'svg'>) {
202
+ return (
203
+ <svg
204
+ height="16"
205
+ strokeLinejoin="round"
206
+ viewBox="0 0 16 16"
207
+ width="16"
208
+ className={cn('size-4', className)}
209
+ {...props}
210
+ >
211
+ <path
212
+ fillRule="evenodd"
213
+ clipRule="evenodd"
214
+ d="M14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8ZM16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM11.5303 6.53033L12.0607 6L11 4.93934L10.4697 5.46967L6.5 9.43934L5.53033 8.46967L5 7.93934L3.93934 9L4.46967 9.53033L5.96967 11.0303C6.26256 11.3232 6.73744 11.3232 7.03033 11.0303L11.5303 6.53033Z"
215
+ fill="currentColor"
216
+ ></path>
217
+ </svg>
218
+ );
219
+ }
220
+
221
+ function IconCrossCircle({ className, ...props }: React.ComponentProps<'svg'>) {
222
+ return (
223
+ <svg
224
+ height="16"
225
+ stroke-linejoin="round"
226
+ viewBox="0 0 16 16"
227
+ width="16"
228
+ className={cn('size-4', className)}
229
+ {...props}
230
+ >
231
+ <path
232
+ fillRule="evenodd"
233
+ clipRule="evenodd"
234
+ d="M14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8ZM16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM5.5 11.5607L6.03033 11.0303L8 9.06066L9.96967 11.0303L10.5 11.5607L11.5607 10.5L11.0303 9.96967L9.06066 8L11.0303 6.03033L11.5607 5.5L10.5 4.43934L9.96967 4.96967L8 6.93934L6.03033 4.96967L5.5 4.43934L4.43934 5.5L4.96967 6.03033L6.93934 8L4.96967 9.96967L4.43934 10.5L5.5 11.5607Z"
235
+ fill="currentColor"
236
+ ></path>
237
+ </svg>
238
+ );
239
+ }
240
+
241
  function IconUser({ className, ...props }: React.ComponentProps<'svg'>) {
242
  return (
243
  <svg
 
617
  );
618
  }
619
 
620
+ function IconChevronRight({
621
+ className,
622
+ ...props
623
+ }: React.ComponentProps<'svg'>) {
624
+ return (
625
+ <svg
626
+ height="16"
627
+ strokeLinejoin="round"
628
+ viewBox="0 0 16 16"
629
+ width="16"
630
+ className={cn('size-4', className)}
631
+ {...props}
632
+ >
633
+ <path
634
+ fillRule="evenodd"
635
+ clipRule="evenodd"
636
+ d="M5.50001 1.93933L6.03034 2.46966L10.8536 7.29288C11.2441 7.68341 11.2441 8.31657 10.8536 8.7071L6.03034 13.5303L5.50001 14.0607L4.43935 13L4.96968 12.4697L9.43935 7.99999L4.96968 3.53032L4.43935 2.99999L5.50001 1.93933Z"
637
+ fill="currentColor"
638
+ ></path>
639
+ </svg>
640
+ );
641
+ }
642
+
643
+ function IconChevronDoubleRight({
644
+ className,
645
+ ...props
646
+ }: React.ComponentProps<'svg'>) {
647
+ return (
648
+ <svg
649
+ height="16"
650
+ strokeLinejoin="round"
651
+ viewBox="0 0 16 16"
652
+ width="16"
653
+ className={cn('size-4', className)}
654
+ {...props}
655
+ >
656
+ <path
657
+ fillRule="evenodd"
658
+ clipRule="evenodd"
659
+ d="M12.8536 8.7071C13.2441 8.31657 13.2441 7.68341 12.8536 7.29288L9.03034 3.46966L8.50001 2.93933L7.43935 3.99999L7.96968 4.53032L11.4393 7.99999L7.96968 11.4697L7.43935 12L8.50001 13.0607L9.03034 12.5303L12.8536 8.7071ZM7.85356 8.7071C8.24408 8.31657 8.24408 7.68341 7.85356 7.29288L4.03034 3.46966L3.50001 2.93933L2.43935 3.99999L2.96968 4.53032L6.43935 7.99999L2.96968 11.4697L2.43935 12L3.50001 13.0607L4.03034 12.5303L7.85356 8.7071Z"
660
+ fill="currentColor"
661
+ ></path>
662
+ </svg>
663
+ );
664
+ }
665
+
666
+ function IconTerminalWindow({
667
+ className,
668
+ ...props
669
+ }: React.ComponentProps<'svg'>) {
670
+ return (
671
+ <svg
672
+ height="16"
673
+ strokeLinejoin="round"
674
+ viewBox="0 0 16 16"
675
+ width="16"
676
+ className={cn('size-4', className)}
677
+ {...props}
678
+ >
679
+ <path
680
+ fillRule="evenodd"
681
+ clipRule="evenodd"
682
+ d="M1.5 2.5H14.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V2.5ZM0 1H1.5H14.5H16V2.5V12.5C16 13.8807 14.8807 15 13.5 15H2.5C1.11929 15 0 13.8807 0 12.5V2.5V1ZM4 11.1339L4.44194 10.6919L6.51516 8.61872C6.85687 8.27701 6.85687 7.72299 6.51517 7.38128L4.44194 5.30806L4 4.86612L3.11612 5.75L3.55806 6.19194L5.36612 8L3.55806 9.80806L3.11612 10.25L4 11.1339ZM8 9.75494H8.6225H11.75H12.3725V10.9999H11.75H8.6225H8V9.75494Z"
683
+ fill="currentColor"
684
+ ></path>
685
+ </svg>
686
+ );
687
+ }
688
+
689
+ function IconCodeWrap({ className, ...props }: React.ComponentProps<'svg'>) {
690
+ return (
691
+ <svg
692
+ height="16"
693
+ strokeLinejoin="round"
694
+ viewBox="0 0 16 16"
695
+ width="16"
696
+ className={cn('size-4', className)}
697
+ {...props}
698
+ >
699
+ <path
700
+ fillRule="evenodd"
701
+ clipRule="evenodd"
702
+ d="M7.22763 14.1819L10.2276 2.18193L10.4095 1.45432L8.95432 1.09052L8.77242 1.81812L5.77242 13.8181L5.59051 14.5457L7.04573 14.9095L7.22763 14.1819ZM3.75002 12.0607L3.21969 11.5304L0.39647 8.70713C0.00594559 8.31661 0.00594559 7.68344 0.39647 7.29292L3.21969 4.46969L3.75002 3.93936L4.81068 5.00002L4.28035 5.53035L1.81068 8.00003L4.28035 10.4697L4.81068 11L3.75002 12.0607ZM12.25 12.0607L12.7804 11.5304L15.6036 8.70713C15.9941 8.31661 15.9941 7.68344 15.6036 7.29292L12.7804 4.46969L12.25 3.93936L11.1894 5.00002L11.7197 5.53035L14.1894 8.00003L11.7197 10.4697L11.1894 11L12.25 12.0607Z"
703
+ fill="currentColor"
704
+ ></path>
705
+ </svg>
706
+ );
707
+ }
708
+
709
+ function IconListUnordered({
710
+ className,
711
+ ...props
712
+ }: React.ComponentProps<'svg'>) {
713
+ return (
714
+ <svg
715
+ height="16"
716
+ strokeLinejoin="round"
717
+ viewBox="0 0 16 16"
718
+ width="16"
719
+ className={cn('size-4', className)}
720
+ {...props}
721
+ >
722
+ <path
723
+ fillRule="evenodd"
724
+ clipRule="evenodd"
725
+ d="M2.5 4C3.19036 4 3.75 3.44036 3.75 2.75C3.75 2.05964 3.19036 1.5 2.5 1.5C1.80964 1.5 1.25 2.05964 1.25 2.75C1.25 3.44036 1.80964 4 2.5 4ZM2.5 9.25C3.19036 9.25 3.75 8.69036 3.75 8C3.75 7.30964 3.19036 6.75 2.5 6.75C1.80964 6.75 1.25 7.30964 1.25 8C1.25 8.69036 1.80964 9.25 2.5 9.25ZM3.75 13.25C3.75 13.9404 3.19036 14.5 2.5 14.5C1.80964 14.5 1.25 13.9404 1.25 13.25C1.25 12.5596 1.80964 12 2.5 12C3.19036 12 3.75 12.5596 3.75 13.25ZM6.75 2H6V3.5H6.75H14.25H15V2H14.25H6.75ZM6.75 7.25H6V8.75H6.75H14.25H15V7.25H14.25H6.75ZM6.75 12.5H6V14H6.75H14.25H15V12.5H14.25H6.75Z"
726
+ fill="currentColor"
727
+ ></path>
728
+ </svg>
729
+ );
730
+ }
731
  export {
732
  IconEdit,
733
  IconLandingAI,
 
761
  IconDiscord,
762
  IconExclamationTriangle,
763
  IconImage,
764
+ IconCheckCircle,
765
+ IconCrossCircle,
766
+ IconChevronRight,
767
+ IconChevronDoubleRight,
768
+ IconTerminalWindow,
769
+ IconCodeWrap,
770
+ IconListUnordered,
771
  };
lib/hooks/useVisionAgent.ts CHANGED
@@ -1,7 +1,6 @@
1
  import { useChat, UseChatHelpers } from 'ai/react';
2
  import { toast } from 'react-hot-toast';
3
- import { useEffect } from 'react';
4
- import { useSearchParams } from 'next/navigation';
5
  import { ChatWithMessages } from '../db/types';
6
  import { dbPostCreateMessage } from '../db/functions';
7
  import {
@@ -42,8 +41,15 @@ const useVisionAgent = (chat: ChatWithMessages) => {
42
  /**
43
  * If case this is first time user navigated with init message, we need to reload the chat for the first response
44
  */
 
45
  useEffect(() => {
46
- if (!isLoading && messages.length === 1 && messages[0].role === 'user') {
 
 
 
 
 
 
47
  reload();
48
  }
49
  }, [isLoading, messages, reload]);
 
1
  import { useChat, UseChatHelpers } from 'ai/react';
2
  import { toast } from 'react-hot-toast';
3
+ import { useEffect, useRef } from 'react';
 
4
  import { ChatWithMessages } from '../db/types';
5
  import { dbPostCreateMessage } from '../db/functions';
6
  import {
 
41
  /**
42
  * If case this is first time user navigated with init message, we need to reload the chat for the first response
43
  */
44
+ const once = useRef(true);
45
  useEffect(() => {
46
+ if (
47
+ !isLoading &&
48
+ messages.length === 1 &&
49
+ messages[0].role === 'user' &&
50
+ once.current
51
+ ) {
52
+ once.current = false;
53
  reload();
54
  }
55
  }, [isLoading, messages, reload]);
lib/messageUtils.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import type { ChatMessage } from '@/lib/db/types';
2
  import { Message } from 'ai';
3
 
@@ -325,3 +326,51 @@ export const convertMessageToDbMessage = (
325
  result,
326
  };
327
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import toast from 'react-hot-toast';
2
  import type { ChatMessage } from '@/lib/db/types';
3
  import { Message } from 'ai';
4
 
 
326
  result,
327
  };
328
  };
329
+
330
+ export type CodeResult = {
331
+ code: string;
332
+ test: string;
333
+ result: string;
334
+ };
335
+
336
+ export type ChunkBody = {
337
+ type: 'plans' | 'tools' | 'code' | 'final_code';
338
+ status: 'started' | 'completed' | 'failed' | 'running';
339
+ payload: Array<Record<string, string>> | CodeResult;
340
+ };
341
+
342
+ /**
343
+ * Formats the stream logs and returns an array of grouped sections.
344
+ *
345
+ * @param content - The content of the stream logs.
346
+ * @returns An array of grouped sections and an optional final code result.
347
+ */
348
+ export const formatStreamLogs = (
349
+ content: string,
350
+ ): [ChunkBody[], CodeResult?] => {
351
+ const streamLogs = content.split('\n').filter(log => !!log);
352
+
353
+ let parsedStreamLogs: ChunkBody[] = [];
354
+ try {
355
+ parsedStreamLogs = streamLogs.map(streamLog => JSON.parse(streamLog));
356
+ } catch {
357
+ toast.error('Error parsing stream logs');
358
+ return [[], undefined];
359
+ }
360
+
361
+ // Merge consecutive logs of the same type to the latest status
362
+ const groupedSections = parsedStreamLogs.reduce((acc, curr) => {
363
+ if (acc.length > 0 && acc[acc.length - 1].type === curr.type) {
364
+ acc[acc.length - 1] = curr;
365
+ } else {
366
+ acc.push(curr);
367
+ }
368
+ return acc;
369
+ }, [] as ChunkBody[]);
370
+
371
+ return [
372
+ groupedSections.filter(section => section.type !== 'final_code'),
373
+ groupedSections.find(section => section.type === 'final_code')
374
+ ?.payload as CodeResult,
375
+ ];
376
+ };