wuyiqunLu commited on
Commit
5d7d435
1 Parent(s): 8e3dbd3

feat: support image rendering on answer (#19)

Browse files

https://app.asana.com/0/1204554785675703/1207155143920392/f

<img width="769" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/132986242/e20eac86-73ec-4dc2-8764-afb0f0c153da">

app/api/sign/route.ts CHANGED
@@ -16,14 +16,13 @@ export async function POST(req: Request): Promise<Response> {
16
  // }
17
 
18
  try {
19
- const { fileName, fileType } = (await req.json()) as {
 
20
  fileName: string;
21
  fileType: string;
22
  };
23
 
24
- const id = nanoid();
25
-
26
- const signedFileName = `${user}/${id}/${fileName}`;
27
  const res = await getPresignedUrl(signedFileName, fileType);
28
  return Response.json({
29
  id,
 
16
  // }
17
 
18
  try {
19
+ const { fileName, fileType, id } = (await req.json()) as {
20
+ id?: string;
21
  fileName: string;
22
  fileType: string;
23
  };
24
 
25
+ const signedFileName = `${user}/${id ?? nanoid()}/${fileName}`;
 
 
26
  const res = await getPresignedUrl(signedFileName, fileType);
27
  return Response.json({
28
  id,
app/api/vision-agent/route.ts CHANGED
@@ -27,7 +27,7 @@ export async function POST(req: Request) {
27
  formData.append('image', url);
28
 
29
  const fetchResponse = await fetch(
30
- 'https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent',
31
  // 'http://localhost:5050/v1/agent/chat?agent_class=vision_agent',
32
  {
33
  method: 'POST',
 
27
  formData.append('image', url);
28
 
29
  const fetchResponse = await fetch(
30
+ 'https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent&visualize_output=true',
31
  // 'http://localhost:5050/v1/agent/chat?agent_class=vision_agent',
32
  {
33
  method: 'POST',
components/chat/index.tsx CHANGED
@@ -20,7 +20,6 @@ export function Chat({ chat }: ChatProps) {
20
 
21
  const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
22
  useScrollAnchor();
23
- console.log('[Ming] ~ Chat ~ isAtBottom:', isAtBottom);
24
 
25
  return (
26
  <>
 
20
 
21
  const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
22
  useScrollAnchor();
 
23
 
24
  return (
25
  <>
lib/hooks/useCleanedUpMessages.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { useMemo } from 'react';
2
- import { MessageBase } from '../types';
 
3
 
4
  const PAIRS: Record<string, string> = {
5
  '┍': '┑',
@@ -10,54 +11,68 @@ const PAIRS: Record<string, string> = {
10
 
11
  const MIDDLE_STARTER = '┝';
12
  const MIDDLE_SEPARATOR = '┿';
 
13
 
14
- export const useCleanedUpMessages = ({ content, role }: MessageBase) => {
15
- return useMemo(() => {
16
- if (role === 'user') {
17
- return {
18
- content,
19
- };
20
- }
21
- const [logs = '', answer = ''] = content.split('<ANSWER>');
22
- const cleanedLogs = [];
23
- let left = 0;
24
- let right = 0;
25
- while (right < logs.length) {
26
- if (Object.keys(PAIRS).includes(content[right])) {
27
- cleanedLogs.push(content.substring(left, right));
28
- left = right++;
29
- while (
30
- right < content.length &&
31
- PAIRS[content[left]] !== content[right]
32
- ) {
33
- right++;
34
- }
35
- if (content[left] === MIDDLE_STARTER) {
36
- // add the text alignment so it can be shown as a table
37
- const separators = logs
38
- .substring(left, right)
39
- .split(MIDDLE_SEPARATOR).length;
40
- if (separators > 0) {
41
- cleanedLogs.push(
42
- Array(separators + 1)
43
- .fill('|')
44
- .join(' :- '),
45
- );
46
- }
47
- }
48
- left = ++right;
49
- } else {
50
  right++;
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
- cleanedLogs.push(content.substring(left, right));
54
- return {
55
- logs: cleanedLogs
56
- .join('')
57
- .replace(/│/g, '|')
58
- .split('|\n\n|')
59
- .join('|\n|'),
60
- content: answer.replace('</</ANSWER>', '').replace('</ANSWER>', ''),
61
- };
 
 
 
 
 
62
  }, [content, role]);
 
63
  };
 
1
+ import { useMemo, useEffect } from 'react';
2
+ import { MessageBase, SignedPayload } from '../types';
3
+ import { fetcher } from '../utils';
4
 
5
  const PAIRS: Record<string, string> = {
6
  '┍': '┑',
 
11
 
12
  const MIDDLE_STARTER = '┝';
13
  const MIDDLE_SEPARATOR = '┿';
14
+ export const CLEANED_SEPARATOR = '|CLEANED|';
15
 
16
+ export const getCleanedUpMessages = ({
17
+ content,
18
+ role,
19
+ }: Pick<MessageBase, 'role' | 'content'>) => {
20
+ if (role === 'user') {
21
+ return {
22
+ content,
23
+ };
24
+ }
25
+ if (content.split(CLEANED_SEPARATOR).length === 2) {
26
+ return {
27
+ logs: content.split(CLEANED_SEPARATOR)[0],
28
+ content: content.split(CLEANED_SEPARATOR)[1],
29
+ };
30
+ }
31
+ const [logs = '', answer = ''] = content.split('<ANSWER>');
32
+ const cleanedLogs = [];
33
+ let left = 0;
34
+ let right = 0;
35
+ while (right < logs.length) {
36
+ if (Object.keys(PAIRS).includes(content[right])) {
37
+ cleanedLogs.push(content.substring(left, right));
38
+ left = right++;
39
+ while (
40
+ right < content.length &&
41
+ PAIRS[content[left]] !== content[right]
42
+ ) {
 
 
 
 
 
 
 
 
 
43
  right++;
44
  }
45
+ if (content[left] === MIDDLE_STARTER) {
46
+ // add the text alignment so it can be shown as a table
47
+ const separators = logs
48
+ .substring(left, right)
49
+ .split(MIDDLE_SEPARATOR).length;
50
+ if (separators > 0) {
51
+ cleanedLogs.push(
52
+ Array(separators + 1)
53
+ .fill('|')
54
+ .join(' :- '),
55
+ );
56
+ }
57
+ }
58
+ left = ++right;
59
+ } else {
60
+ right++;
61
  }
62
+ }
63
+ cleanedLogs.push(content.substring(left, right));
64
+ const [answerText, imagesStr = ''] = answer.split('<VIZ>');
65
+ const images = imagesStr.split('</IMG>').map(str => str.replace('<IMG>', ''));
66
+ return {
67
+ logs: cleanedLogs.join('').replace(/│/g, '|').split('|\n\n|').join('|\n|'),
68
+ content: answerText.replace('</</ANSWER>', '').replace('</ANSWER>', ''),
69
+ images: images.slice(0, -1),
70
+ };
71
+ };
72
+
73
+ export const useCleanedUpMessages = ({ content, role }: MessageBase) => {
74
+ const cleanedMessage = useMemo(() => {
75
+ return getCleanedUpMessages({ content, role });
76
  }, [content, role]);
77
+ return cleanedMessage;
78
  };
lib/hooks/useVisionAgent.tsx CHANGED
@@ -1,8 +1,49 @@
1
  import { useChat, type Message, UseChatHelpers } from 'ai/react';
2
  import { toast } from 'react-hot-toast';
3
  import { useEffect, useState } from 'react';
4
- import { ChatEntity, MessageBase } from '../types';
5
  import { saveKVChatMessage } from '../kv/chat';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  const useVisionAgent = (chat: ChatEntity) => {
8
  const { messages: initialMessages, id, url } = chat;
@@ -23,8 +64,32 @@ const useVisionAgent = (chat: ChatEntity) => {
23
  toast.error(response.statusText);
24
  }
25
  },
26
- onFinish(message) {
27
- saveKVChatMessage(id, message);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  },
29
  initialMessages: initialMessages,
30
  body: {
 
1
  import { useChat, type Message, UseChatHelpers } from 'ai/react';
2
  import { toast } from 'react-hot-toast';
3
  import { useEffect, useState } from 'react';
4
+ import { ChatEntity, MessageBase, SignedPayload } from '../types';
5
  import { saveKVChatMessage } from '../kv/chat';
6
+ import { fetcher } from '../utils';
7
+ import {
8
+ getCleanedUpMessages,
9
+ CLEANED_SEPARATOR,
10
+ } from './useCleanedUpMessages';
11
+
12
+ const uploadBase64 = async (
13
+ base64: string,
14
+ messageId: string,
15
+ chatId: string,
16
+ index: number,
17
+ ) => {
18
+ const res = await fetch('data:image/png;base64,' + base64);
19
+ const blob = await res.blob();
20
+ const { signedUrl, publicUrl, fields } = await fetcher<SignedPayload>(
21
+ '/api/sign',
22
+ {
23
+ method: 'POST',
24
+ body: JSON.stringify({
25
+ id: `${chatId}/${messageId}`,
26
+ fileType: blob.type,
27
+ fileName: `answer-${index}.${blob.type.split('/')[1]}`,
28
+ }),
29
+ },
30
+ );
31
+ const formData = new FormData();
32
+ Object.entries(fields).forEach(([key, value]) => {
33
+ formData.append(key, value as string);
34
+ });
35
+ formData.append('file', blob);
36
+
37
+ const uploadResponse = await fetch(signedUrl, {
38
+ method: 'POST',
39
+ body: formData,
40
+ });
41
+ if (uploadResponse.ok) {
42
+ return publicUrl;
43
+ } else {
44
+ throw new Error('Upload failed');
45
+ }
46
+ };
47
 
48
  const useVisionAgent = (chat: ChatEntity) => {
49
  const { messages: initialMessages, id, url } = chat;
 
64
  toast.error(response.statusText);
65
  }
66
  },
67
+ onFinish: async message => {
68
+ const { logs = '', content, images } = getCleanedUpMessages(message);
69
+ if (images?.length) {
70
+ const publicUrls = await Promise.all(
71
+ images.map((image, index) =>
72
+ uploadBase64(image, message.id, id, index),
73
+ ),
74
+ );
75
+ const newMessage = {
76
+ ...message,
77
+ content:
78
+ logs +
79
+ CLEANED_SEPARATOR +
80
+ content +
81
+ '\n' +
82
+ publicUrls
83
+ .map((url, index) => `![image-${index}](${url})`)
84
+ .join('\n'),
85
+ };
86
+ saveKVChatMessage(id, newMessage);
87
+ } else {
88
+ saveKVChatMessage(id, {
89
+ ...message,
90
+ content: logs + CLEANED_SEPARATOR + content,
91
+ });
92
+ }
93
  },
94
  initialMessages: initialMessages,
95
  body: {