MingruiZhang commited on
Commit
3c8b24f
1 Parent(s): 6fb9264

feat: Final error display and step duration (#88)

Browse files

![image](https://github.com/landing-ai/vision-agent-ui/assets/5669963/38c2a493-0650-466a-b7ec-e279a3fe7180)

app/api/vision-agent/route.ts CHANGED
@@ -161,8 +161,8 @@ export const POST = withLogging(
161
  // default to dev apikey
162
  apikey:
163
  process.env.LND_TIER === 'production'
164
- ? 'land_sk_nMnUf8xiJJUjyw1l5QaIJJ4ZyrvPthzVmPAIG7TtJY7F9CW6lu'
165
- : 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k',
166
  },
167
  body: formData,
168
  },
 
161
  // default to dev apikey
162
  apikey:
163
  process.env.LND_TIER === 'production'
164
+ ? 'land_sk_nMnUf8xiJJUjyw1l5QaIJJ4ZyrvPthzVmPAIG7TtJY7F9CW6lu' // prod key
165
+ : 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k', // dev key
166
  },
167
  body: formData,
168
  },
components/ChatInterface.tsx CHANGED
@@ -25,12 +25,12 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({ chat, userId }) => {
25
  className="pl-4 peer absolute right-0 inset-y-0 hidden translate-x-full data-[state=open]:translate-x-0 z-30 duration-300 ease-in-out xl:flex flex-col items-start xl:w-1/2 h-full dark:bg-zinc-950 overflow-auto"
26
  >
27
  {messageCodeResult?.payload && (
28
- <Card className="w-full">
29
  <CodeResultDisplay codeResult={messageCodeResult.payload} />
30
  </Card>
31
  )}
32
  </div>
33
- <div className="w-full flex justify-center overflow-auto pr-0 animate-in duration-300 ease-in-out peer-[[data-state=open]]:xl:pr-[50%]">
34
  <ChatList chat={chat} userId={userId} />
35
  </div>
36
  </div>
 
25
  className="pl-4 peer absolute right-0 inset-y-0 hidden translate-x-full data-[state=open]:translate-x-0 z-30 duration-300 ease-in-out xl:flex flex-col items-start xl:w-1/2 h-full dark:bg-zinc-950 overflow-auto"
26
  >
27
  {messageCodeResult?.payload && (
28
+ <Card className="size-full overflow-auto">
29
  <CodeResultDisplay codeResult={messageCodeResult.payload} />
30
  </Card>
31
  )}
32
  </div>
33
+ <div className="w-full flex justify-center pr-0 animate-in duration-300 ease-in-out peer-[[data-state=open]]:xl:pr-[50%]">
34
  <ChatList chat={chat} userId={userId} />
35
  </div>
36
  </div>
components/CodeResultDisplay.tsx CHANGED
@@ -21,7 +21,6 @@ import {
21
  CarouselNext,
22
  CarouselPrevious,
23
  } from './ui/carousel';
24
- import Link from 'next/link';
25
 
26
  export interface CodeResultDisplayProps {}
27
 
 
21
  CarouselNext,
22
  CarouselPrevious,
23
  } from './ui/carousel';
 
24
 
25
  export interface CodeResultDisplayProps {}
26
 
components/chat/ChatMessage.tsx CHANGED
@@ -11,7 +11,11 @@ import {
11
  IconGlowingDot,
12
  } from '@/components/ui/Icons';
13
  import { MessageUI } from '@/lib/types';
14
- import { ChunkBody, CodeResult, formatStreamLogs } from '@/lib/utils/content';
 
 
 
 
15
  import {
16
  Table,
17
  TableBody,
@@ -43,10 +47,12 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
43
  }) => {
44
  const [messageId, setMessageId] = useAtom(selectedMessageId);
45
  const { id, mediaUrl, prompt, response, result } = message;
46
- const [formattedSections, codeResult] = useMemo(
47
  () => formatStreamLogs(response ?? wipAssistantMessage?.content),
48
  [response, wipAssistantMessage?.content],
49
  );
 
 
50
  return (
51
  <div
52
  className={cn(
@@ -103,7 +109,10 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
103
  {ChunkStatusToIconDict[section.status]}
104
  </TableCell>
105
  <TableCell className="font-medium">
106
- <ChunkTypeToText chunk={section} />
 
 
 
107
  </TableCell>
108
  <TableCell className="text-right">
109
  <ChunkPayloadAction payload={section.payload} />
@@ -113,11 +122,30 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
113
  </TableBody>
114
  </Table>
115
  {codeResult && (
116
- <div className="xl:hidden">
117
- <CodeResultDisplay codeResult={codeResult} />
118
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  )}
120
- {codeResult && <p>✨ Coding complete</p>}
121
  </div>
122
  </div>
123
  </>
@@ -134,42 +162,54 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
134
  );
135
  };
136
 
137
- const ChunkStatusToIconDict: Record<ChunkBody['status'], React.ReactElement> = {
 
 
 
138
  started: <IconGlowingDot className="bg-yellow-500/80" />,
139
  completed: <IconCheckCircle className="text-green-500" />,
140
  running: <IconGlowingDot className="bg-teal-500/80" />,
141
  failed: <IconCrossCircle className="text-red-500" />,
142
  };
143
  const ChunkTypeToText: React.FC<{
144
- chunk: ChunkBody;
145
- }> = ({ chunk }) => {
146
- const { status, type } = chunk;
 
147
 
148
- const [seconds, setSeconds] = useState(0);
149
- const isExecution = type === 'code' && status === 'running';
150
 
151
  useEffect(() => {
152
- if (isExecution) {
153
  const timerId = setInterval(() => {
154
- setSeconds(prevSeconds => Math.round((prevSeconds + 0.2) * 10) / 10);
155
  }, 200);
156
  return () => clearInterval(timerId);
157
  }
158
- }, [isExecution]);
 
 
 
 
 
 
159
 
160
- if (type === 'plans') return <p>Creating instructions</p>;
161
- if (type === 'tools') return <p>Retrieving tools</p>;
162
- if (type === 'code' && status === 'started') return <p>Generating code</p>;
163
- if (isExecution) return <p>Executing code ({seconds}s)</p>;
 
 
164
  if (type === 'code' && status === 'completed')
165
- return <p>Code execution success ({seconds}s)</p>;
166
  if (type === 'code' && status === 'failed')
167
- return <p>Code execution failure ({seconds}s)</p>;
168
 
169
  return null;
170
  };
171
  const ChunkPayloadAction: React.FC<{
172
- payload: ChunkBody['payload'];
173
  }> = ({ payload }) => {
174
  if (!payload) return null;
175
  if (Array.isArray(payload)) {
 
11
  IconGlowingDot,
12
  } from '@/components/ui/Icons';
13
  import { MessageUI } from '@/lib/types';
14
+ import {
15
+ WIPChunkBodyGroup,
16
+ CodeResult,
17
+ formatStreamLogs,
18
+ } from '@/lib/utils/content';
19
  import {
20
  Table,
21
  TableBody,
 
47
  }) => {
48
  const [messageId, setMessageId] = useAtom(selectedMessageId);
49
  const { id, mediaUrl, prompt, response, result } = message;
50
+ const [formattedSections, finalResult, finalError] = useMemo(
51
  () => formatStreamLogs(response ?? wipAssistantMessage?.content),
52
  [response, wipAssistantMessage?.content],
53
  );
54
+ // prioritize the result from the message over the WIP message
55
+ const codeResult = result?.payload ?? finalResult;
56
  return (
57
  <div
58
  className={cn(
 
109
  {ChunkStatusToIconDict[section.status]}
110
  </TableCell>
111
  <TableCell className="font-medium">
112
+ <ChunkTypeToText
113
+ useTimer={!codeResult && !finalError}
114
+ chunk={section}
115
+ />
116
  </TableCell>
117
  <TableCell className="text-right">
118
  <ChunkPayloadAction payload={section.payload} />
 
122
  </TableBody>
123
  </Table>
124
  {codeResult && (
125
+ <>
126
+ <div className="xl:hidden">
127
+ <CodeResultDisplay codeResult={codeResult} />
128
+ </div>
129
+ <p>✨ Coding complete</p>
130
+ </>
131
+ )}
132
+ {!codeResult && finalError && (
133
+ <>
134
+ <p>❌ Coding failed </p>
135
+ <div>
136
+ <CodeBlock
137
+ language="error"
138
+ value={
139
+ finalError.name +
140
+ '\n' +
141
+ finalError.value +
142
+ '\n' +
143
+ finalError.traceback_raw.join('\n')
144
+ }
145
+ />
146
+ </div>
147
+ </>
148
  )}
 
149
  </div>
150
  </div>
151
  </>
 
162
  );
163
  };
164
 
165
+ const ChunkStatusToIconDict: Record<
166
+ WIPChunkBodyGroup['status'],
167
+ React.ReactElement
168
+ > = {
169
  started: <IconGlowingDot className="bg-yellow-500/80" />,
170
  completed: <IconCheckCircle className="text-green-500" />,
171
  running: <IconGlowingDot className="bg-teal-500/80" />,
172
  failed: <IconCrossCircle className="text-red-500" />,
173
  };
174
  const ChunkTypeToText: React.FC<{
175
+ chunk: WIPChunkBodyGroup;
176
+ useTimer: boolean;
177
+ }> = ({ chunk, useTimer }) => {
178
+ const { status, type, timestamp, duration } = chunk;
179
 
180
+ const [mSeconds, setMSeconds] = useState(0);
181
+ const isExecuting = !['completed', 'failed'].includes(status);
182
 
183
  useEffect(() => {
184
+ if (isExecuting && timestamp && !useTimer) {
185
  const timerId = setInterval(() => {
186
+ setMSeconds(Date.now() - Date.parse(timestamp));
187
  }, 200);
188
  return () => clearInterval(timerId);
189
  }
190
+ }, [isExecuting, timestamp, useTimer]);
191
+
192
+ const displayMs = isExecuting && useTimer ? mSeconds : duration;
193
+
194
+ const durationDisplay = displayMs
195
+ ? `(${Math.round(displayMs / 100) / 10}s)`
196
+ : '';
197
 
198
+ if (type === 'plans') return <p>Creating instructions {durationDisplay}</p>;
199
+ if (type === 'tools') return <p>Retrieving tools {durationDisplay}</p>;
200
+ if (type === 'code' && status === 'started')
201
+ return <p>Generating code {durationDisplay}</p>;
202
+ if (type === 'code' && status === 'running')
203
+ return <p>Executing code {durationDisplay}</p>;
204
  if (type === 'code' && status === 'completed')
205
+ return <p>Code execution success {durationDisplay}</p>;
206
  if (type === 'code' && status === 'failed')
207
+ return <p>Code execution failure {durationDisplay}</p>;
208
 
209
  return null;
210
  };
211
  const ChunkPayloadAction: React.FC<{
212
+ payload: WIPChunkBodyGroup['payload'];
213
  }> = ({ payload }) => {
214
  if (!payload) return null;
215
  if (Array.isArray(payload)) {
components/chat/Composer.tsx CHANGED
@@ -111,6 +111,7 @@ const Composer = forwardRef<ComposerRef, ComposerProps>(
111
  <Button
112
  size="icon"
113
  variant="ghost"
 
114
  className="size-4"
115
  onClick={() => setLocalMediaUrl(undefined)}
116
  >
 
111
  <Button
112
  size="icon"
113
  variant="ghost"
114
+ disabled={finalDisabled}
115
  className="size-4"
116
  onClick={() => setLocalMediaUrl(undefined)}
117
  >
lib/db/functions.ts CHANGED
@@ -42,6 +42,12 @@ export async function dbFindOrCreateUser(
42
 
43
  // If the user doesn't exist, create it
44
  if (user) {
 
 
 
 
 
 
45
  return user;
46
  } else {
47
  return prisma.user.create({
 
42
 
43
  // If the user doesn't exist, create it
44
  if (user) {
45
+ if (!user.avatar && avatar) {
46
+ await prisma.user.update({
47
+ where: { email: email },
48
+ data: { avatar: avatar },
49
+ });
50
+ }
51
  return user;
52
  } else {
53
  return prisma.user.create({
lib/utils/content.ts CHANGED
@@ -1,4 +1,5 @@
1
  import toast from 'react-hot-toast';
 
2
  const ANSWERS_PREFIX = 'answers';
3
 
4
  export const generateAnswersImageMarkdown = (index: number, url: string) => {
@@ -15,80 +16,34 @@ export const cleanAnswerMessage = (content: string) => {
15
  return content.replace(/!\[answers.*?\.png\)/g, '');
16
  };
17
 
18
- type PlansBody =
19
- | {
20
- type: 'plans';
21
- status: 'started';
22
- }
23
- | {
24
- type: 'plans';
25
- status: 'completed';
26
- payload: Array<Record<string, string>>;
27
- };
28
-
29
- type ToolsBody =
30
- | {
31
- type: 'tools';
32
- status: 'started';
33
- }
34
- | {
35
- type: 'tools';
36
- status: 'completed';
37
- payload: Array<Record<string, string>>;
38
- };
39
-
40
- type CodeBody =
41
- | {
42
- type: 'code';
43
- status: 'started';
44
- }
45
- | {
46
- type: 'code';
47
- status: 'running';
48
- payload: {
49
- code: string;
50
- test: string;
51
- };
52
- }
53
- | {
54
- type: 'code';
55
- status: 'completed' | 'failed';
56
- payload: {
57
- code: string;
58
- test: string;
59
- result: string;
60
- };
61
- };
62
-
63
- // this will return if self_reflection flag is true
64
- type ReflectionBody =
65
- | {
66
- type: 'self_reflection';
67
- status: 'started';
68
- }
69
- | {
70
- type: 'self_reflection';
71
- status: 'completed' | 'failed';
72
- payload: { feedback: string; success: boolean };
73
- };
74
-
75
-
76
  export type CodeResult = {
77
  code: string;
78
  test: string;
79
  result: string;
80
  };
81
 
82
- export type ChunkBody =
83
- | {
84
- type: 'plans' | 'tools' | 'code' | 'final_code' | 'final_error';
85
- status: 'started' | 'completed' | 'failed' | 'running';
86
- timestamp: string;
87
- payload:
88
- | Array<Record<string, string>> // PlansBody | ToolsBody
89
- | CodeResult; // CodeBody
90
- }
91
- | PrismaJson.FinalChatResult;
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  /**
94
  * Formats the stream logs and returns an array of grouped sections.
@@ -98,7 +53,7 @@ export type ChunkBody =
98
  */
99
  export const formatStreamLogs = (
100
  content: string | null | undefined,
101
- ): [ChunkBody[], CodeResult?] => {
102
  if (!content) return [[], undefined];
103
  const streamLogs = content.split('\n').filter(log => !!log);
104
 
@@ -124,21 +79,33 @@ export const formatStreamLogs = (
124
 
125
  // Merge consecutive logs of the same type to the latest status
126
  const groupedSections = parsedStreamLogs.reduce((acc, curr) => {
 
127
  if (
128
  acc.length > 0 &&
129
- acc[acc.length - 1].type === curr.type &&
130
  curr.status !== 'started'
131
  ) {
132
- acc[acc.length - 1] = curr;
 
 
 
 
 
 
 
 
 
133
  } else {
134
  acc.push(curr);
135
  }
136
  return acc;
137
- }, [] as ChunkBody[]);
138
 
139
  return [
140
- groupedSections.filter(section => section.type !== 'final_code'),
141
  groupedSections.find(section => section.type === 'final_code')
142
  ?.payload as CodeResult,
 
 
143
  ];
144
  };
 
1
  import toast from 'react-hot-toast';
2
+ import { ResultPayload } from '../types';
3
  const ANSWERS_PREFIX = 'answers';
4
 
5
  export const generateAnswersImageMarkdown = (index: number, url: string) => {
 
16
  return content.replace(/!\[answers.*?\.png\)/g, '');
17
  };
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  export type CodeResult = {
20
  code: string;
21
  test: string;
22
  result: string;
23
  };
24
 
25
+ const WIPLogTypes = ['plans', 'tools', 'code'];
26
+ const AllLogTypes = [
27
+ 'plans',
28
+ 'tools',
29
+ 'code',
30
+ 'final_code',
31
+ 'final_error',
32
+ ] as const;
33
+
34
+ export type ChunkBody = {
35
+ type: (typeof AllLogTypes)[number];
36
+ status: 'started' | 'completed' | 'failed' | 'running';
37
+ timestamp?: string;
38
+ payload:
39
+ | Array<Record<string, string>> // PlansBody | ToolsBody
40
+ | CodeResult // CodeBody
41
+ | ResultPayload['error']; // ErrorBody
42
+ };
43
+
44
+ export type WIPChunkBodyGroup = ChunkBody & {
45
+ duration?: number;
46
+ };
47
 
48
  /**
49
  * Formats the stream logs and returns an array of grouped sections.
 
53
  */
54
  export const formatStreamLogs = (
55
  content: string | null | undefined,
56
+ ): [WIPChunkBodyGroup[], CodeResult?, ResultPayload['error']?] => {
57
  if (!content) return [[], undefined];
58
  const streamLogs = content.split('\n').filter(log => !!log);
59
 
 
79
 
80
  // Merge consecutive logs of the same type to the latest status
81
  const groupedSections = parsedStreamLogs.reduce((acc, curr) => {
82
+ const lastGroup = acc[acc.length - 1];
83
  if (
84
  acc.length > 0 &&
85
+ lastGroup.type === curr.type &&
86
  curr.status !== 'started'
87
  ) {
88
+ acc[acc.length - 1] = {
89
+ ...curr,
90
+ // always use the timestamp of the first log
91
+ timestamp: lastGroup?.timestamp,
92
+ // duration is the difference between the last log and the first log
93
+ duration:
94
+ lastGroup?.timestamp && curr.timestamp
95
+ ? Date.parse(curr.timestamp) - Date.parse(lastGroup.timestamp)
96
+ : undefined,
97
+ };
98
  } else {
99
  acc.push(curr);
100
  }
101
  return acc;
102
+ }, [] as WIPChunkBodyGroup[]);
103
 
104
  return [
105
+ groupedSections.filter(section => WIPLogTypes.includes(section.type)),
106
  groupedSections.find(section => section.type === 'final_code')
107
  ?.payload as CodeResult,
108
+ groupedSections.find(section => section.type === 'final_error')
109
+ ?.payload as ResultPayload['error'],
110
  ];
111
  };