MingruiZhang commited on
Commit
ae074fc
1 Parent(s): 4af6326

feat: Code result image display (#76)

Browse files

![image](https://github.com/landing-ai/vision-agent-ui/assets/5669963/84c88af9-a6bf-4796-b4b2-4a85df88e5bf)


![image](https://github.com/landing-ai/vision-agent-ui/assets/5669963/299da094-ad1e-48c2-adba-18108534025d)

components/CodeResultDisplay.tsx CHANGED
@@ -29,13 +29,18 @@ const CodeResultDisplay: React.FC<{
29
  results: detail.results,
30
  stderr: detail.logs.stderr,
31
  stdout: detail.logs.stdout,
 
32
  };
33
  } catch {
34
  return {};
35
  }
36
  };
37
 
38
- const { results, stderr, stdout } = getDetail();
 
 
 
 
39
 
40
  return (
41
  <div className="rounded-lg overflow-hidden relative max-w-5xl">
@@ -50,7 +55,7 @@ const CodeResultDisplay: React.FC<{
50
  </DialogTrigger>
51
  <DialogContent className="max-w-5xl">
52
  <DialogHeader>
53
- <DialogTitle>Test Code</DialogTitle>
54
  </DialogHeader>
55
  <CodeBlock language="python" value={test} />
56
  </DialogContent>
@@ -75,47 +80,68 @@ const CodeResultDisplay: React.FC<{
75
  <CodeBlock language="print" value={stdout.join('').trim()} />
76
  </>
77
  )}
78
- {Array.isArray(results) && !!results.length && (
79
  <>
80
  <Separator />
81
- {results.map((result, index) => {
82
- if (result.png) {
83
- return (
84
- <Img
85
- key={'png' + index}
86
- src={result.png}
87
- alt={'answer-image'}
88
- quality={100}
89
- sizes="(min-width: 66em) 15vw,
90
- (min-width: 44em) 20vw,
91
- 100vw"
92
- />
93
- );
94
- } else if (result.mp4) {
95
- return (
96
- <video
97
- key={'mp4' + index}
98
- src={result.mp4}
99
- controls
100
- width={500}
101
- height={500}
102
- />
103
- );
104
- } else if (result.text) {
105
- return (
106
- <CodeBlock
107
- key={'text' + index}
108
- language="output"
109
- value={result.text}
110
- />
111
- );
112
- } else {
113
- return null;
114
  }
115
- })}
116
  </>
117
  )}
118
- <Separator />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  </div>
120
  );
121
  };
 
29
  results: detail.results,
30
  stderr: detail.logs.stderr,
31
  stdout: detail.logs.stdout,
32
+ error: detail.error,
33
  };
34
  } catch {
35
  return {};
36
  }
37
  };
38
 
39
+ const { results = [], stderr, stdout, error } = getDetail();
40
+
41
+ const imageResults = results?.filter(_ => !!_.png).map(_ => _.png);
42
+ const videoResults = results?.filter(_ => !!_.mp4).map(_ => _.mp4);
43
+ const finalResult = results?.find(_ => _.is_main_result)?.text;
44
 
45
  return (
46
  <div className="rounded-lg overflow-hidden relative max-w-5xl">
 
55
  </DialogTrigger>
56
  <DialogContent className="max-w-5xl">
57
  <DialogHeader>
58
+ <DialogTitle>Test code</DialogTitle>
59
  </DialogHeader>
60
  <CodeBlock language="python" value={test} />
61
  </DialogContent>
 
80
  <CodeBlock language="print" value={stdout.join('').trim()} />
81
  </>
82
  )}
83
+ {!!error && (
84
  <>
85
  <Separator />
86
+ <CodeBlock
87
+ language="error"
88
+ value={
89
+ error.name +
90
+ '\n' +
91
+ error.value +
92
+ '\n' +
93
+ error.traceback_raw.join('\n')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  }
95
+ />
96
  </>
97
  )}
98
+ {!!imageResults.length && (
99
+ <div className="p-4 text-xs lowercase bg-zinc-900 space-y-4 border-t border-muted">
100
+ <p>image output</p>
101
+ <div className="flex flex-row space-x-4 overflow-auto">
102
+ {imageResults.map((png, index) => (
103
+ <Dialog key={'png' + index}>
104
+ <DialogTrigger asChild>
105
+ <Img
106
+ key={'png' + index}
107
+ src={png!}
108
+ width={200}
109
+ alt="result-image"
110
+ className="cursor-zoom-in"
111
+ />
112
+ </DialogTrigger>
113
+ <DialogContent className="max-w-5xl">
114
+ <Img
115
+ src={png!}
116
+ width={1200}
117
+ height={800}
118
+ alt="result-image"
119
+ quality={100}
120
+ />
121
+ </DialogContent>
122
+ </Dialog>
123
+ ))}
124
+ </div>
125
+ </div>
126
+ )}
127
+ {!!videoResults.length && (
128
+ <div className="p-4 text-xs lowercase bg-zinc-900 space-y-4">
129
+ <p>video output</p>
130
+ <div className="flex flex-row space-x-4 overflow-auto">
131
+ {videoResults.map((mp4, index) => (
132
+ <Dialog key={'png' + index}>
133
+ <DialogTrigger asChild>
134
+ <video src={mp4} controls width={400} height={400} />
135
+ </DialogTrigger>
136
+ <DialogContent className="max-w-5xl">
137
+ <video src={mp4} controls width={400} height={400} />
138
+ </DialogContent>
139
+ </Dialog>
140
+ ))}
141
+ </div>
142
+ </div>
143
+ )}
144
+ {!!finalResult && <CodeBlock language="output" value={finalResult} />}
145
  </div>
146
  );
147
  };
components/chat/ChatList.tsx CHANGED
@@ -57,10 +57,11 @@ const ChatList: React.FC<ChatListProps> = ({ chat }) => {
57
  <div className="overflow-auto h-full p-4 z-10" ref={messagesRef}>
58
  {dbMessages.map((message, index) => (
59
  <ChatMessage
60
- key={index}
61
  message={message}
62
  wipAssistantMessage={
63
- isLoading && lastMessage.role === 'assistant'
 
64
  ? lastMessage
65
  : undefined
66
  }
@@ -78,10 +79,12 @@ const ChatList: React.FC<ChatListProps> = ({ chat }) => {
78
  initMediaUrl={dbMessages[dbMessages.length - 1]?.mediaUrl}
79
  isLoading={isLoading}
80
  onSubmit={async ({ input, mediaUrl: newMediaUrl }) => {
81
- append({
82
  prompt: input,
83
  mediaUrl: newMediaUrl,
84
- });
 
 
85
  }}
86
  />
87
  </div>
 
57
  <div className="overflow-auto h-full p-4 z-10" ref={messagesRef}>
58
  {dbMessages.map((message, index) => (
59
  <ChatMessage
60
+ key={message.id}
61
  message={message}
62
  wipAssistantMessage={
63
+ lastMessage.role === 'assistant' &&
64
+ index === dbMessages.length - 1
65
  ? lastMessage
66
  : undefined
67
  }
 
79
  initMediaUrl={dbMessages[dbMessages.length - 1]?.mediaUrl}
80
  isLoading={isLoading}
81
  onSubmit={async ({ input, mediaUrl: newMediaUrl }) => {
82
+ const messageInput = {
83
  prompt: input,
84
  mediaUrl: newMediaUrl,
85
+ };
86
+ const resp = await dbPostCreateMessage(id, messageInput);
87
+ append(resp);
88
  }}
89
  />
90
  </div>
components/chat/ChatMessage.tsx CHANGED
@@ -67,48 +67,59 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
67
  {mediaUrl && (
68
  <>
69
  {mediaUrl?.endsWith('.mp4') ? (
70
- <video src={mediaUrl} controls width={500} height={500} />
71
  ) : (
72
- <Img src={mediaUrl} alt={mediaUrl} quality={100} width={300} />
 
 
 
 
 
 
 
73
  )}
74
  </>
75
  )}
76
  </div>
77
  </div>
78
- <Separator className="bg-primary/30 my-4" />
79
- <div className="flex">
80
- <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-primary text-primary-foreground">
81
- <IconLandingAI />
82
- </div>
83
- <div className="flex-1 px-1 space-y-4 ml-4 overflow-hidden">
84
- <Table className="w-[400px]">
85
- <TableBody>
86
- {formattedSections.map(section => (
87
- <TableRow
88
- className="border-primary/50 h-[56px]"
89
- key={section.type}
90
- >
91
- <TableCell className="text-center text-webkit-center">
92
- {ChunkStatusToIconDict[section.status]}
93
- </TableCell>
94
- <TableCell className="font-medium">
95
- {ChunkTypeToTextDict[section.type]}
96
- </TableCell>
97
- <TableCell className="text-right">
98
- <ChunkPayloadAction payload={section.payload} />
99
- </TableCell>
100
- </TableRow>
101
- ))}
102
- </TableBody>
103
- </Table>
104
- {codeResult && (
105
- <div className="xl:hidden">
106
- <CodeResultDisplay codeResult={codeResult} />
107
  </div>
108
- )}
109
- {codeResult && <p>✨ Coding complete</p>}
110
- </div>
111
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  </div>
113
  );
114
  };
 
67
  {mediaUrl && (
68
  <>
69
  {mediaUrl?.endsWith('.mp4') ? (
70
+ <video src={mediaUrl} controls width={400} height={400} />
71
  ) : (
72
+ <Dialog>
73
+ <DialogTrigger asChild>
74
+ <Img src={mediaUrl} alt={mediaUrl} width={300} />
75
+ </DialogTrigger>
76
+ <DialogContent className="max-w-5xl">
77
+ <Img src={mediaUrl} alt={mediaUrl} quality={100} />
78
+ </DialogContent>
79
+ </Dialog>
80
  )}
81
  </>
82
  )}
83
  </div>
84
  </div>
85
+ {!!formattedSections.length && (
86
+ <>
87
+ <Separator className="bg-primary/30 my-4" />
88
+ <div className="flex">
89
+ <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-primary text-primary-foreground">
90
+ <IconLandingAI />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  </div>
92
+ <div className="flex-1 px-1 space-y-4 ml-4 overflow-hidden">
93
+ <Table className="w-[400px]">
94
+ <TableBody>
95
+ {formattedSections.map(section => (
96
+ <TableRow
97
+ className="border-primary/50 h-[56px]"
98
+ key={section.type}
99
+ >
100
+ <TableCell className="text-center text-webkit-center">
101
+ {ChunkStatusToIconDict[section.status]}
102
+ </TableCell>
103
+ <TableCell className="font-medium">
104
+ {ChunkTypeToTextDict[section.type]}
105
+ </TableCell>
106
+ <TableCell className="text-right">
107
+ <ChunkPayloadAction payload={section.payload} />
108
+ </TableCell>
109
+ </TableRow>
110
+ ))}
111
+ </TableBody>
112
+ </Table>
113
+ {codeResult && (
114
+ <div className="xl:hidden">
115
+ <CodeResultDisplay codeResult={codeResult} />
116
+ </div>
117
+ )}
118
+ {codeResult && <p>✨ Coding complete</p>}
119
+ </div>
120
+ </div>
121
+ </>
122
+ )}
123
  </div>
124
  );
125
  };
components/chat/Composer.tsx CHANGED
@@ -98,7 +98,7 @@ const Composer = forwardRef<ComposerRef, ComposerProps>(
98
  <p>{mediaName ?? 'unnamed_media'}</p>
99
  </div>
100
  </TooltipTrigger>
101
- <TooltipContent sideOffset={12}>
102
  <Img
103
  src={localMediaUrl}
104
  className="m-1"
 
98
  <p>{mediaName ?? 'unnamed_media'}</p>
99
  </div>
100
  </TooltipTrigger>
101
+ <TooltipContent sideOffset={12} className="max-w-2xl">
102
  <Img
103
  src={localMediaUrl}
104
  className="m-1"
components/ui/CodeBlock.tsx CHANGED
@@ -45,12 +45,16 @@ export const programmingLanguages: languageMap = {
45
  sql: '.sql',
46
  html: '.html',
47
  css: '.css',
 
48
  print: '.txt',
 
49
  // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
50
  };
51
 
52
  const customSyntax: languageMap = {
53
  print: 'vim',
 
 
54
  };
55
 
56
  export const generateRandomString = (length: number, lowercase = false) => {
@@ -99,7 +103,7 @@ const CodeBlock: FC<Props> = memo(({ language, value }) => {
99
  };
100
  return (
101
  <div className="relative w-full codeblock bg-zinc-900 overflow-hidden">
102
- <div className="flex items-center justify-between w-full pl-8 pr-4 pt-2 text-zinc-100">
103
  <span className="text-xs lowercase">{language}</span>
104
  <div className="flex items-center space-x-1">
105
  <Button
@@ -131,7 +135,7 @@ const CodeBlock: FC<Props> = memo(({ language, value }) => {
131
  margin: 0,
132
  width: '100%',
133
  background: 'transparent',
134
- padding: '0.5rem 1rem 1.5rem 1rem',
135
  }}
136
  lineNumberStyle={{
137
  userSelect: 'none',
 
45
  sql: '.sql',
46
  html: '.html',
47
  css: '.css',
48
+ // custom titles
49
  print: '.txt',
50
+ error: 'vim',
51
  // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
52
  };
53
 
54
  const customSyntax: languageMap = {
55
  print: 'vim',
56
+ output: 'vim',
57
+ error: 'vim',
58
  };
59
 
60
  export const generateRandomString = (length: number, lowercase = false) => {
 
103
  };
104
  return (
105
  <div className="relative w-full codeblock bg-zinc-900 overflow-hidden">
106
+ <div className="flex items-center justify-between w-full px-4 pt-2 text-zinc-100">
107
  <span className="text-xs lowercase">{language}</span>
108
  <div className="flex items-center space-x-1">
109
  <Button
 
135
  margin: 0,
136
  width: '100%',
137
  background: 'transparent',
138
+ padding: '0.5rem 1rem 1rem 1rem',
139
  }}
140
  lineNumberStyle={{
141
  userSelect: 'none',
components/ui/Dialog.tsx CHANGED
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
21
  <DialogPrimitive.Overlay
22
  ref={ref}
23
  className={cn(
24
- 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
25
  className,
26
  )}
27
  {...props}
@@ -38,7 +38,7 @@ const DialogContent: React.ForwardRefExoticComponent<
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}
 
21
  <DialogPrimitive.Overlay
22
  ref={ref}
23
  className={cn(
24
+ 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
25
  className,
26
  )}
27
  {...props}
 
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 max-h-[1000px] overflow-auto -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}
lib/hooks/useVisionAgent.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
  } from '../utils/message';
13
  import { useSetAtom } from 'jotai';
14
  import { selectedMessageId } from '@/state/chat';
 
15
 
16
  const useVisionAgent = (chat: ChatWithMessages) => {
17
  const { messages: dbMessages, id, mediaUrl } = chat;
@@ -66,16 +67,15 @@ const useVisionAgent = (chat: ChatWithMessages) => {
66
 
67
  return {
68
  messages: messages as MessageUI[],
69
- append: async (messageInput: MessageUserInput) => {
70
- const resp = await dbPostCreateMessage(id, messageInput);
71
- currMediaUrl.current = messageInput.mediaUrl;
72
- currMessageId.current = resp.id;
73
  append({
74
  id,
75
  role: 'user',
76
- content: messageInput.prompt,
77
  // @ts-ignore valid when setting sendExtraMessageFields
78
- mediaUrl: messageInput.mediaUrl,
79
  });
80
  },
81
  reload,
 
12
  } from '../utils/message';
13
  import { useSetAtom } from 'jotai';
14
  import { selectedMessageId } from '@/state/chat';
15
+ import { Message } from '@prisma/client';
16
 
17
  const useVisionAgent = (chat: ChatWithMessages) => {
18
  const { messages: dbMessages, id, mediaUrl } = chat;
 
67
 
68
  return {
69
  messages: messages as MessageUI[],
70
+ append: (message: Message) => {
71
+ currMediaUrl.current = message.mediaUrl;
72
+ currMessageId.current = message.id;
 
73
  append({
74
  id,
75
  role: 'user',
76
+ content: message.prompt,
77
  // @ts-ignore valid when setting sendExtraMessageFields
78
+ mediaUrl: message.mediaUrl,
79
  });
80
  },
81
  reload,
lib/types.ts CHANGED
@@ -30,4 +30,9 @@ export type ResultPayload = {
30
  text: string;
31
  is_main_result: boolean;
32
  }>;
 
 
 
 
 
33
  };
 
30
  text: string;
31
  is_main_result: boolean;
32
  }>;
33
+ error: {
34
+ name: string;
35
+ value: string;
36
+ traceback_raw: string[];
37
+ };
38
  };
lib/utils/content.ts CHANGED
@@ -1,16 +1,4 @@
1
  import toast from 'react-hot-toast';
2
- import { Message } from 'ai';
3
-
4
- const PAIRS: Record<string, string> = {
5
- '┍': '┑',
6
- '┝': '┥',
7
- '├': '┤',
8
- '┕': '┙',
9
- };
10
-
11
- const MIDDLE_STARTER = '┝';
12
- const MIDDLE_SEPARATOR = '┿';
13
-
14
  const ANSWERS_PREFIX = 'answers';
15
 
16
  export const generateAnswersImageMarkdown = (index: number, url: string) => {
@@ -27,56 +15,6 @@ export const cleanAnswerMessage = (content: string) => {
27
  return content.replace(/!\[answers.*?\.png\)/g, '');
28
  };
29
 
30
- const generateJSONArrayMarkdown = (
31
- message: string,
32
- payload: Array<Record<string, string | boolean>>,
33
- ) => {
34
- if (payload.length === 0) return '';
35
- const keys = Object.keys(payload[0]);
36
- message += '\n';
37
- message += '| ' + keys.join(' | ') + ' |' + '\n';
38
- message += new Array(keys.length + 1).fill('|').join(' :- ') + '\n';
39
- payload.forEach((obj: any) => {
40
- message +=
41
- '| ' +
42
- keys
43
- .map(key => {
44
- if (key === 'documentation') {
45
- const doc = `\`\`\`\n${obj[key]}\n\`\`\`\n`;
46
- return `<button data-details=${JSON.stringify(encodeURI(doc))}>Show</button>`;
47
- } else {
48
- return obj[key];
49
- }
50
- })
51
- .join(' | ') +
52
- ' |' +
53
- '\n';
54
- });
55
- message += '\n';
56
- return message;
57
- };
58
-
59
- const generateCodeExecutionMarkdown = (
60
- message: string,
61
- payload: {
62
- code: string;
63
- test: string;
64
- result?: string;
65
- },
66
- ) => {
67
- let Details = 'Code: \n';
68
- Details += `\`\`\`python\n${payload.code}\n\`\`\`\n`;
69
- Details += 'Test: \n';
70
- Details += `\`\`\`python\n${payload.test}\n\`\`\`\n`;
71
- if (payload.result) {
72
- Details += 'Execution result: \n';
73
- Details += `\`\`\`python\n${payload.result}\n\`\`\`\n`;
74
- }
75
- message += `<button data-details=${JSON.stringify(encodeURI(Details))}>View details</button> \n`;
76
-
77
- return message;
78
- };
79
-
80
  type PlansBody =
81
  | {
82
  type: 'plans';
@@ -183,26 +121,6 @@ const getMessageTitle = (json: MessageBody) => {
183
  throw 'Not supported type';
184
  }
185
  };
186
- const parseLine = (json: MessageBody) => {
187
- const title = getMessageTitle(json);
188
- if (json.status === 'started') {
189
- return title;
190
- }
191
- switch (json.type) {
192
- case 'plans':
193
- return generateJSONArrayMarkdown(title, json.payload);
194
- case 'tools':
195
- return generateJSONArrayMarkdown(title, json.payload);
196
- case 'code':
197
- return generateCodeExecutionMarkdown(title, json.payload);
198
- case 'self_reflection':
199
- return generateJSONArrayMarkdown(title, [json.payload]);
200
- case 'final_code':
201
- return '';
202
- default:
203
- throw 'Not supported type';
204
- }
205
- };
206
 
207
  export type CodeResult = {
208
  code: string;
@@ -254,7 +172,11 @@ export const formatStreamLogs = (
254
 
255
  // Merge consecutive logs of the same type to the latest status
256
  const groupedSections = parsedStreamLogs.reduce((acc, curr) => {
257
- if (acc.length > 0 && acc[acc.length - 1].type === curr.type) {
 
 
 
 
258
  acc[acc.length - 1] = curr;
259
  } else {
260
  acc.push(curr);
 
1
  import toast from 'react-hot-toast';
 
 
 
 
 
 
 
 
 
 
 
 
2
  const ANSWERS_PREFIX = 'answers';
3
 
4
  export const generateAnswersImageMarkdown = (index: number, url: string) => {
 
15
  return content.replace(/!\[answers.*?\.png\)/g, '');
16
  };
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  type PlansBody =
19
  | {
20
  type: 'plans';
 
121
  throw 'Not supported type';
122
  }
123
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  export type CodeResult = {
126
  code: string;
 
172
 
173
  // Merge consecutive logs of the same type to the latest status
174
  const groupedSections = parsedStreamLogs.reduce((acc, curr) => {
175
+ if (
176
+ acc.length > 0 &&
177
+ acc[acc.length - 1].type === curr.type &&
178
+ curr.status !== 'started'
179
+ ) {
180
  acc[acc.length - 1] = curr;
181
  } else {
182
  acc.push(curr);