Spaces:
Sleeping
Sleeping
wuyiqunLu
commited on
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 +2 -8
- app/api/vision-agent/route.ts +54 -34
- components/chat/ChatMessage.tsx +36 -2
- lib/aws.ts +15 -23
- lib/db/prisma.ts +1 -0
- next.config.js +1 -3
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
|
29 |
-
|
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 {
|
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
|
26 |
-
'/
|
27 |
-
|
28 |
-
|
29 |
-
|
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 =
|
162 |
-
|
|
|
|
|
163 |
let done = false;
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
try {
|
169 |
-
const msg = JSON.parse(line
|
170 |
if (msg.type !== 'final_code') {
|
171 |
-
|
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 |
-
|
|
|
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 |
-
|
|
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
Bucket: process.env.AWS_BUCKET_NAME ?? 'vision-agent-dev',
|
15 |
-
Key:
|
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 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
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: '
|
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' } : {}),
|