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' } : {}),
|