wuyiqunLu commited on
Commit
1a37272
1 Parent(s): f2de1e7

feat: support png and mp4 rendering (#73)

Browse files

<img width="1164" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/132986242/2311e390-fa0c-4acf-bcb7-5da1f7d34c36">

app/api/sign/route.ts CHANGED
@@ -25,14 +25,8 @@ export const POST = withLogging(
25
  try {
26
  const { fileName, fileType, id = nanoid() } = json;
27
 
28
- const signedFileName = `${user}/${id}/${fileName}`;
29
- const res = await getPresignedUrl(signedFileName, fileType);
30
- return Response.json({
31
- id,
32
- signedUrl: res.url,
33
- publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`,
34
- fields: res.fields,
35
- });
36
  } catch (error) {
37
  return new Response((error as Error).message, {
38
  status: 400,
 
25
  try {
26
  const { fileName, fileType, id = nanoid() } = json;
27
 
28
+ const res = await getPresignedUrl(fileName, fileType, id, user);
29
+ return Response.json(res);
 
 
 
 
 
 
30
  } catch (error) {
31
  return new Response((error as Error).message, {
32
  status: 400,
app/api/vision-agent/route.ts CHANGED
@@ -6,7 +6,7 @@ import { MessageUI, SignedPayload } from '@/lib/types';
6
  import { logger, withLogging } from '@/lib/logger';
7
  import { CLEANED_SEPARATOR } from '@/lib/constants';
8
  import { cleanAnswerMessage, cleanInputMessage } from '@/lib/utils/content';
9
- import { fetcher } from '@/lib/utils';
10
 
11
  // export const runtime = 'edge';
12
  export const dynamic = 'force-dynamic';
@@ -17,21 +17,15 @@ const uploadBase64 = async (
17
  messageId: string,
18
  chatId: string,
19
  index: number,
 
20
  ) => {
21
- const res = await fetch(
22
- 'data:image/png;base64,' + base64.replace('base:64', ''),
23
- );
24
  const blob = await res.blob();
25
- const { signedUrl, publicUrl, fields } = await fetcher<SignedPayload>(
26
- '/api/sign',
27
- {
28
- method: 'POST',
29
- body: JSON.stringify({
30
- id: `${chatId}/${messageId}`,
31
- fileType: blob.type,
32
- fileName: `answer-${index}.${blob.type.split('/')[1]}`,
33
- }),
34
- },
35
  );
36
  const formData = new FormData();
37
  Object.entries(fields).forEach(([key, value]) => {
@@ -61,6 +55,7 @@ export const POST = withLogging(
61
  request,
62
  ) => {
63
  const { messages, mediaUrl } = json;
 
64
 
65
  // const session = await auth();
66
  // if (!session?.user?.email) {
@@ -152,60 +147,85 @@ export const POST = withLogging(
152
  const encoder = new TextEncoder();
153
  const decoder = new TextDecoder('utf-8');
154
  let maxChunkSize = 0;
 
155
  const stream = new ReadableStream({
156
  async start(controller) {
157
  // const parser = createParser(streamParser);
158
  for await (const chunk of fetchResponse.body as any) {
159
  const data = decoder.decode(chunk);
 
160
  maxChunkSize = Math.max(data.length, maxChunkSize);
161
- const lines = data.split('\n');
162
- const results = [];
 
 
163
  let done = false;
164
- for (let line of lines) {
165
- if (!line.trim()) {
166
- continue;
167
- }
168
  try {
169
- const msg = JSON.parse(line.trim());
170
  if (msg.type !== 'final_code') {
171
- results.push(line);
172
- continue;
173
  }
174
  const result = JSON.parse(
175
  msg.payload.result,
176
  ) as PrismaJson.FinalChatResult['payload']['result'];
177
  for (let index = 0; index < result.results.length; index++) {
178
- const png = result.results[index].png;
179
- if (!png) continue;
 
180
  const resp = await uploadBase64(
181
- png,
 
 
182
  messages[messages.length - 1].id,
183
  json.id,
184
  index,
 
185
  );
186
- result.results[index].png = resp;
 
187
  }
188
  msg.payload.result = JSON.stringify(result);
189
- results.push(JSON.stringify(msg));
190
  done = true;
 
191
  } catch (e) {
 
 
 
 
 
 
 
 
192
  console.error(e);
193
  logger.error(
194
  session,
195
  {
196
- message: (e as Error).message,
 
197
  },
198
  request,
199
  );
200
  controller.error(e);
 
 
 
 
 
 
 
 
 
 
 
 
201
  }
202
  }
203
- controller.enqueue(
204
- encoder.encode(
205
- results.length === 0 ? '' : results.join('\n') + '\n',
206
- ),
207
- );
208
  if (done) {
 
209
  logger.info(
210
  session,
211
  {
 
6
  import { logger, withLogging } from '@/lib/logger';
7
  import { CLEANED_SEPARATOR } from '@/lib/constants';
8
  import { cleanAnswerMessage, cleanInputMessage } from '@/lib/utils/content';
9
+ import { getPresignedUrl } from '@/lib/aws';
10
 
11
  // export const runtime = 'edge';
12
  export const dynamic = 'force-dynamic';
 
17
  messageId: string,
18
  chatId: string,
19
  index: number,
20
+ user: string,
21
  ) => {
22
+ const res = await fetch(base64);
 
 
23
  const blob = await res.blob();
24
+ const { signedUrl, publicUrl, fields } = await getPresignedUrl(
25
+ `answer-${index}.${blob.type.split('/')[1]}`,
26
+ blob.type,
27
+ `${chatId}/${messageId}`,
28
+ user,
 
 
 
 
 
29
  );
30
  const formData = new FormData();
31
  Object.entries(fields).forEach(([key, value]) => {
 
55
  request,
56
  ) => {
57
  const { messages, mediaUrl } = json;
58
+ const user = session?.user?.email ?? 'anonymous';
59
 
60
  // const session = await auth();
61
  // if (!session?.user?.email) {
 
147
  const encoder = new TextEncoder();
148
  const decoder = new TextDecoder('utf-8');
149
  let maxChunkSize = 0;
150
+ let buffer = '';
151
  const stream = new ReadableStream({
152
  async start(controller) {
153
  // const parser = createParser(streamParser);
154
  for await (const chunk of fetchResponse.body as any) {
155
  const data = decoder.decode(chunk);
156
+ buffer += data;
157
  maxChunkSize = Math.max(data.length, maxChunkSize);
158
+ const lines = buffer
159
+ .split('\n')
160
+ .filter(line => line.trim().length > 0);
161
+ buffer = lines.pop() ?? ''; // Save the last incomplete line back to the buffer
162
  let done = false;
163
+ const parseLine = async (
164
+ line: string,
165
+ errorCallback?: (e: Error) => void,
166
+ ) => {
167
  try {
168
+ const msg = JSON.parse(line);
169
  if (msg.type !== 'final_code') {
170
+ return line;
 
171
  }
172
  const result = JSON.parse(
173
  msg.payload.result,
174
  ) as PrismaJson.FinalChatResult['payload']['result'];
175
  for (let index = 0; index < result.results.length; index++) {
176
+ const png = result.results[index].png ?? '';
177
+ const mp4 = result.results[index].mp4 ?? '';
178
+ if (!png && !mp4) continue;
179
  const resp = await uploadBase64(
180
+ png
181
+ ? 'data:image/png;base64,' + png
182
+ : 'data:video/mp4;base64,' + mp4,
183
  messages[messages.length - 1].id,
184
  json.id,
185
  index,
186
+ user,
187
  );
188
+ if (png) result.results[index].png = resp;
189
+ if (mp4) result.results[index].mp4 = resp;
190
  }
191
  msg.payload.result = JSON.stringify(result);
 
192
  done = true;
193
+ return JSON.stringify(msg);
194
  } catch (e) {
195
+ errorCallback?.(e as Error);
196
+ }
197
+ };
198
+ for (let line of lines) {
199
+ if (!line.trim()) {
200
+ continue;
201
+ }
202
+ const parsedLine = await parseLine(line, (e: Error) => {
203
  console.error(e);
204
  logger.error(
205
  session,
206
  {
207
+ line,
208
+ message: e.message,
209
  },
210
  request,
211
  );
212
  controller.error(e);
213
+ });
214
+ controller.enqueue(
215
+ encoder.encode(
216
+ parsedLine?.trim() ? parsedLine?.trim() + '\n' : '',
217
+ ),
218
+ );
219
+ }
220
+ if (buffer) {
221
+ const parsedBuffer = await parseLine(buffer);
222
+ if (parsedBuffer?.trim()) {
223
+ buffer = '';
224
+ controller.enqueue(encoder.encode(parsedBuffer.trim() + '\n'));
225
  }
226
  }
 
 
 
 
 
227
  if (done) {
228
+ console.log(done);
229
  logger.info(
230
  session,
231
  {
components/chat/ChatMessage.tsx CHANGED
@@ -225,10 +225,44 @@ const CodeResultDisplay: React.FC<{
225
  <CodeBlock language="print" value={stdout.join('').trim()} />
226
  </>
227
  )}
228
- {!!results.length && (
229
  <>
230
  <Separator />
231
- <CodeBlock language="output" value={results} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  </>
233
  )}
234
  <Separator />
 
225
  <CodeBlock language="print" value={stdout.join('').trim()} />
226
  </>
227
  )}
228
+ {Array.isArray(results) && !!results.length && (
229
  <>
230
  <Separator />
231
+ {results.map((result, index) => {
232
+ if (result.png) {
233
+ return (
234
+ <Img
235
+ key={'png' + index}
236
+ src={result.png}
237
+ alt={'answer-image'}
238
+ quality={100}
239
+ sizes="(min-width: 66em) 15vw,
240
+ (min-width: 44em) 20vw,
241
+ 100vw"
242
+ />
243
+ );
244
+ } else if (result.mp4) {
245
+ return (
246
+ <video
247
+ key={'mp4' + index}
248
+ src={result.mp4}
249
+ controls
250
+ width={500}
251
+ height={500}
252
+ />
253
+ );
254
+ } else if (result.text) {
255
+ return (
256
+ <CodeBlock
257
+ key={'text' + index}
258
+ language="output"
259
+ value={result.text}
260
+ />
261
+ );
262
+ } else {
263
+ return null;
264
+ }
265
+ })}
266
  </>
267
  )}
268
  <Separator />
lib/aws.ts CHANGED
@@ -9,10 +9,16 @@ const s3Client = new S3Client({
9
  credentials: fromEnv(),
10
  });
11
 
12
- export const getPresignedUrl = async (fileName: string, fileType: string) => {
13
- return createPresignedPost(s3Client, {
 
 
 
 
 
 
14
  Bucket: process.env.AWS_BUCKET_NAME ?? 'vision-agent-dev',
15
- Key: fileName,
16
  Conditions: [
17
  ['content-length-range', 0, FILE_SIZE_LIMIT],
18
  ['starts-with', '$Content-Type', fileType],
@@ -23,24 +29,10 @@ export const getPresignedUrl = async (fileName: string, fileType: string) => {
23
  },
24
  Expires: 600,
25
  });
26
- };
27
-
28
- export const upload = async (
29
- base64: string,
30
- fileName: string,
31
- fileType: string,
32
- ) => {
33
- const { url, fields } = await getPresignedUrl(fileName, fileType);
34
- const formData = new FormData();
35
- Object.entries(fields).forEach(([key, value]) => {
36
- formData.append(key, value as string);
37
- });
38
- const res = await fetch(base64);
39
- const blob = await res.blob();
40
- formData.append('file', blob);
41
-
42
- return fetch(url, {
43
- method: 'POST',
44
- body: formData,
45
- });
46
  };
 
9
  credentials: fromEnv(),
10
  });
11
 
12
+ export const getPresignedUrl = async (
13
+ fileName: string,
14
+ fileType: string,
15
+ id: string,
16
+ user: string,
17
+ ) => {
18
+ const signedFileName = `${user}/${id}/${fileName}`;
19
+ const res = await createPresignedPost(s3Client, {
20
  Bucket: process.env.AWS_BUCKET_NAME ?? 'vision-agent-dev',
21
+ Key: signedFileName,
22
  Conditions: [
23
  ['content-length-range', 0, FILE_SIZE_LIMIT],
24
  ['starts-with', '$Content-Type', fileType],
 
29
  },
30
  Expires: 600,
31
  });
32
+ return {
33
+ id,
34
+ signedUrl: res.url,
35
+ publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`,
36
+ fields: res.fields,
37
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  };
lib/db/prisma.ts CHANGED
@@ -17,6 +17,7 @@ declare global {
17
  };
18
  results: Array<{
19
  png?: string;
 
20
  text: string;
21
  is_main_result: boolean;
22
  }>;
 
17
  };
18
  results: Array<{
19
  png?: string;
20
+ mp4?: string;
21
  text: string;
22
  is_main_result: boolean;
23
  }>;
next.config.js CHANGED
@@ -12,10 +12,8 @@ module.exports = {
12
  },
13
  experimental: {
14
  serverActions: {
15
- bodySizeLimit: '10mb',
16
  },
17
- },
18
- experimental: {
19
  serverComponentsExternalPackages: ['pino', 'pino-loki'],
20
  },
21
  ...(process.env.USE_STANDALONE_BUILD ? { output: 'standalone' } : {}),
 
12
  },
13
  experimental: {
14
  serverActions: {
15
+ bodySizeLimit: '30mb',
16
  },
 
 
17
  serverComponentsExternalPackages: ['pino', 'pino-loki'],
18
  },
19
  ...(process.env.USE_STANDALONE_BUILD ? { output: 'standalone' } : {}),