Spaces:
Sleeping
Sleeping
wuyiqunLu
commited on
feat: encode uri for s3 url when loading (#104)
Browse filesfile name has special characters (%, & etc):
<img width="737" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/132986242/33706c60-f7f5-4f3e-8ace-3d1e6afb77be">
<img width="1524" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/132986242/80edeb03-b10d-4463-bd85-68d1d1d157a8">
normal file name without special characters:
<img width="883" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/132986242/2029ac6b-8605-4849-8661-93b3008acc02">
- app/api/vision-agent/route.ts +17 -3
- components/ChatInterface.tsx +10 -2
- components/chat/ChatList.tsx +7 -15
- components/ui/Img.tsx +6 -3
- lib/hooks/useVisionAgent.ts +44 -26
app/api/vision-agent/route.ts
CHANGED
|
@@ -119,16 +119,16 @@ export const POST = withLogging(
|
|
| 119 |
|
| 120 |
const formData = new FormData();
|
| 121 |
formData.append('input', apiMessages);
|
| 122 |
-
formData.append('image', mediaUrl);
|
| 123 |
|
| 124 |
const agentHost = process.env.LND_TIER
|
| 125 |
? 'http://publicrestapi-app-lndsvc.publicrestapi.svc.cluster.local:5000'
|
| 126 |
: 'https://api.dev.landing.ai';
|
| 127 |
|
| 128 |
const fetchResponse = await fetch(
|
| 129 |
-
`${agentHost}/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
|
| 130 |
// `https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
|
| 131 |
-
|
| 132 |
{
|
| 133 |
method: 'POST',
|
| 134 |
headers: {
|
|
@@ -349,6 +349,20 @@ export const POST = withLogging(
|
|
| 349 |
};
|
| 350 |
|
| 351 |
let timeout = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
for await (const chunk of fetchResponse.body as any) {
|
| 353 |
const data = decoder.decode(chunk);
|
| 354 |
buffer += data;
|
|
|
|
| 119 |
|
| 120 |
const formData = new FormData();
|
| 121 |
formData.append('input', apiMessages);
|
| 122 |
+
formData.append('image', encodeURI(mediaUrl));
|
| 123 |
|
| 124 |
const agentHost = process.env.LND_TIER
|
| 125 |
? 'http://publicrestapi-app-lndsvc.publicrestapi.svc.cluster.local:5000'
|
| 126 |
: 'https://api.dev.landing.ai';
|
| 127 |
|
| 128 |
const fetchResponse = await fetch(
|
| 129 |
+
// `${agentHost}/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
|
| 130 |
// `https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
|
| 131 |
+
`http://localhost:5001/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
|
| 132 |
{
|
| 133 |
method: 'POST',
|
| 134 |
headers: {
|
|
|
|
| 349 |
};
|
| 350 |
|
| 351 |
let timeout = null;
|
| 352 |
+
controller.enqueue(
|
| 353 |
+
encoder.encode(
|
| 354 |
+
'2:' +
|
| 355 |
+
JSON.stringify([
|
| 356 |
+
{
|
| 357 |
+
type: 'init',
|
| 358 |
+
payload: {
|
| 359 |
+
messageId,
|
| 360 |
+
},
|
| 361 |
+
},
|
| 362 |
+
]) +
|
| 363 |
+
'\n',
|
| 364 |
+
),
|
| 365 |
+
);
|
| 366 |
for await (const chunk of fetchResponse.body as any) {
|
| 367 |
const data = decoder.decode(chunk);
|
| 368 |
buffer += data;
|
components/ChatInterface.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import { ChatWithMessages } from '@/lib/types';
|
| 4 |
-
import React from 'react';
|
| 5 |
import ChatList from './chat/ChatList';
|
| 6 |
import { Card } from './ui/Card';
|
| 7 |
import { useAtom, useAtomValue } from 'jotai';
|
|
@@ -14,10 +14,18 @@ export interface ChatInterfaceProps {
|
|
| 14 |
}
|
| 15 |
|
| 16 |
const ChatInterface: React.FC<ChatInterfaceProps> = ({ chat, userId }) => {
|
| 17 |
-
const messageId =
|
| 18 |
const messageCodeResult = chat.messages.find(
|
| 19 |
message => message.id === messageId,
|
| 20 |
)?.result;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
return (
|
| 22 |
<div className="relative flex overflow-hidden space-x-4 size-full">
|
| 23 |
<div
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import { ChatWithMessages } from '@/lib/types';
|
| 4 |
+
import React, { useEffect } from 'react';
|
| 5 |
import ChatList from './chat/ChatList';
|
| 6 |
import { Card } from './ui/Card';
|
| 7 |
import { useAtom, useAtomValue } from 'jotai';
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
const ChatInterface: React.FC<ChatInterfaceProps> = ({ chat, userId }) => {
|
| 17 |
+
const [messageId, setMessageId] = useAtom(selectedMessageId);
|
| 18 |
const messageCodeResult = chat.messages.find(
|
| 19 |
message => message.id === messageId,
|
| 20 |
)?.result;
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
if (messageId) return;
|
| 24 |
+
const lastMessageWithResult =
|
| 25 |
+
chat.messages.findLast(message => !!message.result) ??
|
| 26 |
+
chat.messages[chat.messages.length - 1];
|
| 27 |
+
setMessageId(lastMessageWithResult?.id);
|
| 28 |
+
}, [messageId]);
|
| 29 |
return (
|
| 30 |
<div className="relative flex overflow-hidden space-x-4 size-full">
|
| 31 |
<div
|
components/chat/ChatList.tsx
CHANGED
|
@@ -12,8 +12,6 @@ import { cn } from '@/lib/utils';
|
|
| 12 |
import { IconArrowDown } from '../ui/Icons';
|
| 13 |
import { dbPostCreateMessage } from '@/lib/db/functions';
|
| 14 |
import { Card } from '../ui/Card';
|
| 15 |
-
import { useSetAtom } from 'jotai';
|
| 16 |
-
import { selectedMessageId } from '@/state/chat';
|
| 17 |
|
| 18 |
export interface ChatListProps {
|
| 19 |
chat: ChatWithMessages;
|
|
@@ -24,25 +22,18 @@ export const SCROLL_BOTTOM = 120;
|
|
| 24 |
|
| 25 |
const ChatList: React.FC<ChatListProps> = ({ chat, userId }) => {
|
| 26 |
const { id, messages: dbMessages, userId: chatUserId } = chat;
|
| 27 |
-
const {
|
| 28 |
|
| 29 |
// Only login and chat owner can compose
|
| 30 |
const canCompose = !chatUserId || userId === chatUserId;
|
| 31 |
|
| 32 |
-
const lastDbMessage = dbMessages[dbMessages.length - 1];
|
| 33 |
-
const setMessageId = useSetAtom(selectedMessageId);
|
| 34 |
-
|
| 35 |
const { messagesRef, scrollRef, visibilityRef, isVisible, scrollToBottom } =
|
| 36 |
useScrollAnchor(SCROLL_BOTTOM);
|
| 37 |
|
| 38 |
-
// Scroll to bottom on init
|
| 39 |
useEffect(() => {
|
| 40 |
scrollToBottom();
|
| 41 |
-
|
| 42 |
-
setMessageId(lastDbMessage.id);
|
| 43 |
-
}
|
| 44 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 45 |
-
}, []);
|
| 46 |
|
| 47 |
return (
|
| 48 |
<Card
|
|
@@ -57,7 +48,9 @@ const ChatList: React.FC<ChatListProps> = ({ chat, userId }) => {
|
|
| 57 |
key={message.id}
|
| 58 |
message={message}
|
| 59 |
loading={isLastMessage && isLoading}
|
| 60 |
-
wipAssistantMessage={
|
|
|
|
|
|
|
| 61 |
/>
|
| 62 |
);
|
| 63 |
})}
|
|
@@ -78,8 +71,7 @@ const ChatList: React.FC<ChatListProps> = ({ chat, userId }) => {
|
|
| 78 |
prompt: input,
|
| 79 |
mediaUrl: newMediaUrl,
|
| 80 |
};
|
| 81 |
-
|
| 82 |
-
append(resp);
|
| 83 |
}}
|
| 84 |
/>
|
| 85 |
</div>
|
|
|
|
| 12 |
import { IconArrowDown } from '../ui/Icons';
|
| 13 |
import { dbPostCreateMessage } from '@/lib/db/functions';
|
| 14 |
import { Card } from '../ui/Card';
|
|
|
|
|
|
|
| 15 |
|
| 16 |
export interface ChatListProps {
|
| 17 |
chat: ChatWithMessages;
|
|
|
|
| 22 |
|
| 23 |
const ChatList: React.FC<ChatListProps> = ({ chat, userId }) => {
|
| 24 |
const { id, messages: dbMessages, userId: chatUserId } = chat;
|
| 25 |
+
const { isLoading, data } = useVisionAgent(chat);
|
| 26 |
|
| 27 |
// Only login and chat owner can compose
|
| 28 |
const canCompose = !chatUserId || userId === chatUserId;
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
const { messagesRef, scrollRef, visibilityRef, isVisible, scrollToBottom } =
|
| 31 |
useScrollAnchor(SCROLL_BOTTOM);
|
| 32 |
|
| 33 |
+
// Scroll to bottom on init
|
| 34 |
useEffect(() => {
|
| 35 |
scrollToBottom();
|
| 36 |
+
}, [scrollToBottom]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
return (
|
| 39 |
<Card
|
|
|
|
| 48 |
key={message.id}
|
| 49 |
message={message}
|
| 50 |
loading={isLastMessage && isLoading}
|
| 51 |
+
wipAssistantMessage={
|
| 52 |
+
isLastMessage && data.length > 0 ? data : undefined
|
| 53 |
+
}
|
| 54 |
/>
|
| 55 |
);
|
| 56 |
})}
|
|
|
|
| 71 |
prompt: input,
|
| 72 |
mediaUrl: newMediaUrl,
|
| 73 |
};
|
| 74 |
+
await dbPostCreateMessage(id, messageInput);
|
|
|
|
| 75 |
}}
|
| 76 |
/>
|
| 77 |
</div>
|
components/ui/Img.tsx
CHANGED
|
@@ -21,12 +21,15 @@ const Img = React.forwardRef<
|
|
| 21 |
const isVideo =
|
| 22 |
typeof src === 'string' ? src.toLowerCase().endsWith('.mp4') : false;
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
return (
|
| 25 |
<Image
|
| 26 |
src={
|
| 27 |
-
|
| 28 |
-
? (
|
| 29 |
-
:
|
| 30 |
}
|
| 31 |
placeholder={placeholder}
|
| 32 |
width={dimensions.width}
|
|
|
|
| 21 |
const isVideo =
|
| 22 |
typeof src === 'string' ? src.toLowerCase().endsWith('.mp4') : false;
|
| 23 |
|
| 24 |
+
const srcString = isVideo
|
| 25 |
+
? (src as string).replace('.mp4', '.png').replace('.MP4', '.png')
|
| 26 |
+
: (src as string);
|
| 27 |
return (
|
| 28 |
<Image
|
| 29 |
src={
|
| 30 |
+
srcString.includes('vision-agent-dev.s3')
|
| 31 |
+
? encodeURI(srcString)
|
| 32 |
+
: srcString
|
| 33 |
}
|
| 34 |
placeholder={placeholder}
|
| 35 |
width={dimensions.width}
|
lib/hooks/useVisionAgent.ts
CHANGED
|
@@ -3,22 +3,26 @@ import { toast } from 'react-hot-toast';
|
|
| 3 |
import { useEffect, useRef } from 'react';
|
| 4 |
import { ChatWithMessages } from '../types';
|
| 5 |
import { convertDBMessageToAPIMessage } from '../utils/message';
|
|
|
|
|
|
|
| 6 |
import { useSetAtom } from 'jotai';
|
| 7 |
import { selectedMessageId } from '@/state/chat';
|
| 8 |
-
import { Message } from '@prisma/client';
|
| 9 |
-
import { useRouter } from 'next/navigation';
|
| 10 |
|
| 11 |
const useVisionAgent = (chat: ChatWithMessages) => {
|
| 12 |
const { messages: dbMessages, id, mediaUrl } = chat;
|
| 13 |
const latestDbMessage = dbMessages[dbMessages.length - 1];
|
| 14 |
-
const setMessageId = useSetAtom(selectedMessageId);
|
| 15 |
|
| 16 |
// Temporary solution for now while single we have to pass mediaUrl separately outside of the messages
|
| 17 |
-
const currMediaUrl = useRef<string>(mediaUrl);
|
| 18 |
-
const currMessageId = useRef<string>(latestDbMessage?.id);
|
| 19 |
const router = useRouter();
|
|
|
|
| 20 |
|
| 21 |
-
const {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
api: '/api/vision-agent',
|
| 23 |
onResponse(response) {
|
| 24 |
if (response.status !== 200) {
|
|
@@ -26,48 +30,62 @@ const useVisionAgent = (chat: ChatWithMessages) => {
|
|
| 26 |
}
|
| 27 |
},
|
| 28 |
onFinish: () => {
|
|
|
|
| 29 |
router.refresh();
|
| 30 |
-
setMessageId(currMessageId.current);
|
| 31 |
},
|
| 32 |
body: {
|
| 33 |
-
mediaUrl:
|
| 34 |
chatId: id,
|
| 35 |
-
messageId:
|
| 36 |
// for some reason, the messages has to be stringified to be sent to the API
|
| 37 |
apiMessages: JSON.stringify(convertDBMessageToAPIMessage(dbMessages)),
|
| 38 |
},
|
| 39 |
onError: err => {
|
| 40 |
err && toast.error(err.message);
|
| 41 |
},
|
| 42 |
-
initialMessages: convertDBMessageToAPIMessage(dbMessages),
|
| 43 |
});
|
| 44 |
|
| 45 |
/**
|
| 46 |
* If case this is first time user navigated with init message, we need to reload the chat for the first response
|
| 47 |
*/
|
| 48 |
const once = useRef(true);
|
|
|
|
| 49 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if (
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
once.current
|
| 54 |
) {
|
| 55 |
-
|
| 56 |
-
reload();
|
| 57 |
}
|
| 58 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
return {
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
id,
|
| 66 |
-
role: 'user',
|
| 67 |
-
content: message.prompt,
|
| 68 |
-
});
|
| 69 |
-
},
|
| 70 |
-
data: data as unknown as PrismaJson.MessageBody[],
|
| 71 |
reload,
|
| 72 |
isLoading,
|
| 73 |
};
|
|
|
|
| 3 |
import { useEffect, useRef } from 'react';
|
| 4 |
import { ChatWithMessages } from '../types';
|
| 5 |
import { convertDBMessageToAPIMessage } from '../utils/message';
|
| 6 |
+
import { useRouter } from 'next/navigation';
|
| 7 |
+
import { Message } from '@prisma/client';
|
| 8 |
import { useSetAtom } from 'jotai';
|
| 9 |
import { selectedMessageId } from '@/state/chat';
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const useVisionAgent = (chat: ChatWithMessages) => {
|
| 12 |
const { messages: dbMessages, id, mediaUrl } = chat;
|
| 13 |
const latestDbMessage = dbMessages[dbMessages.length - 1];
|
|
|
|
| 14 |
|
| 15 |
// Temporary solution for now while single we have to pass mediaUrl separately outside of the messages
|
|
|
|
|
|
|
| 16 |
const router = useRouter();
|
| 17 |
+
const setMessageId = useSetAtom(selectedMessageId);
|
| 18 |
|
| 19 |
+
const {
|
| 20 |
+
data = [],
|
| 21 |
+
reload,
|
| 22 |
+
append,
|
| 23 |
+
messages,
|
| 24 |
+
isLoading,
|
| 25 |
+
} = useChat({
|
| 26 |
api: '/api/vision-agent',
|
| 27 |
onResponse(response) {
|
| 28 |
if (response.status !== 200) {
|
|
|
|
| 30 |
}
|
| 31 |
},
|
| 32 |
onFinish: () => {
|
| 33 |
+
setMessageId(latestDbMessage.id);
|
| 34 |
router.refresh();
|
|
|
|
| 35 |
},
|
| 36 |
body: {
|
| 37 |
+
mediaUrl: latestDbMessage.mediaUrl,
|
| 38 |
chatId: id,
|
| 39 |
+
messageId: latestDbMessage.id,
|
| 40 |
// for some reason, the messages has to be stringified to be sent to the API
|
| 41 |
apiMessages: JSON.stringify(convertDBMessageToAPIMessage(dbMessages)),
|
| 42 |
},
|
| 43 |
onError: err => {
|
| 44 |
err && toast.error(err.message);
|
| 45 |
},
|
|
|
|
| 46 |
});
|
| 47 |
|
| 48 |
/**
|
| 49 |
* If case this is first time user navigated with init message, we need to reload the chat for the first response
|
| 50 |
*/
|
| 51 |
const once = useRef(true);
|
| 52 |
+
|
| 53 |
useEffect(() => {
|
| 54 |
+
const appendDbMessage = async (latestDbMessage: Message) => {
|
| 55 |
+
await append({
|
| 56 |
+
id: latestDbMessage.id + '-user',
|
| 57 |
+
content: latestDbMessage.prompt,
|
| 58 |
+
role: 'user',
|
| 59 |
+
});
|
| 60 |
+
};
|
| 61 |
+
if (isLoading || latestDbMessage.response || latestDbMessage.responseBody) {
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
if (messages.length === 0) {
|
| 65 |
+
if (once.current) {
|
| 66 |
+
once.current = false;
|
| 67 |
+
appendDbMessage(latestDbMessage);
|
| 68 |
+
}
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
if (
|
| 72 |
+
messages.findIndex(message => message.id.includes(latestDbMessage.id)) ===
|
| 73 |
+
-1
|
|
|
|
| 74 |
) {
|
| 75 |
+
appendDbMessage(latestDbMessage);
|
|
|
|
| 76 |
}
|
| 77 |
+
}, [latestDbMessage, messages, isLoading]);
|
| 78 |
+
|
| 79 |
+
const initDataIndex = data.findIndex(
|
| 80 |
+
(m: any) =>
|
| 81 |
+
m.type === 'init' && m.payload?.messageId === latestDbMessage.id,
|
| 82 |
+
);
|
| 83 |
|
| 84 |
return {
|
| 85 |
+
data:
|
| 86 |
+
initDataIndex >= 0
|
| 87 |
+
? (data.slice(initDataIndex + 1) as unknown as PrismaJson.MessageBody[])
|
| 88 |
+
: [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
reload,
|
| 90 |
isLoading,
|
| 91 |
};
|