MingruiZhang commited on
Commit
96ac62a
·
unverified ·
1 Parent(s): 76503b7

feat: project analysis (#27)

Browse files

<img width="1904" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/5669963/b8e9e7bb-7248-4c8f-8648-8087c7d99b7c">

app/chat/[id]/page.tsx CHANGED
@@ -1,6 +1,5 @@
1
- import ChatDataLoad from '@/components/chat/ChatDataLoad';
2
- import { Suspense } from 'react';
3
- import Loading from '@/components/ui/Loading';
4
 
5
  interface PageProps {
6
  params: {
@@ -10,10 +9,6 @@ interface PageProps {
10
 
11
  export default async function Page({ params }: PageProps) {
12
  const { id: chatId } = params;
13
-
14
- return (
15
- <Suspense fallback={<Loading />}>
16
- <ChatDataLoad chatId={chatId} />
17
- </Suspense>
18
- );
19
  }
 
1
+ import { getKVChat } from '@/lib/kv/chat';
2
+ import { Chat } from '@/components/chat';
 
3
 
4
  interface PageProps {
5
  params: {
 
9
 
10
  export default async function Page({ params }: PageProps) {
11
  const { id: chatId } = params;
12
+ const chat = await getKVChat(chatId);
13
+ return <Chat chat={chat} />;
 
 
 
 
14
  }
app/project/[projectId]/page.tsx CHANGED
@@ -1,8 +1,6 @@
1
  import MediaGrid from '@/components/project/MediaGrid';
2
  import { fetchProjectMedia } from '@/lib/fetch';
3
- import { Suspense } from 'react';
4
- import Loading from '../../../components/ui/Loading';
5
- import Chat from '@/components/project/Chat';
6
 
7
  interface PageProps {
8
  params: {
@@ -16,13 +14,13 @@ export default async function Page({ params }: PageProps) {
16
  const mediaList = await fetchProjectMedia({ projectId: Number(projectId) });
17
 
18
  return (
19
- <div className="pb-[150px] pt-4 md:pt-10 h-full">
20
  <div className="flex h-full">
21
  <div className="w-1/2 relative border-r border-gray-300 overflow-auto">
22
  <MediaGrid mediaList={mediaList} />
23
  </div>
24
  <div className="w-1/2 relative overflow-auto">
25
- <Chat mediaList={mediaList} />
26
  </div>
27
  </div>
28
  </div>
 
1
  import MediaGrid from '@/components/project/MediaGrid';
2
  import { fetchProjectMedia } from '@/lib/fetch';
3
+ import ProjectChat from '@/components/project/ProjectChat';
 
 
4
 
5
  interface PageProps {
6
  params: {
 
14
  const mediaList = await fetchProjectMedia({ projectId: Number(projectId) });
15
 
16
  return (
17
+ <div className="pt-4 md:pt-10 h-full">
18
  <div className="flex h-full">
19
  <div className="w-1/2 relative border-r border-gray-300 overflow-auto">
20
  <MediaGrid mediaList={mediaList} />
21
  </div>
22
  <div className="w-1/2 relative overflow-auto">
23
+ <ProjectChat mediaList={mediaList} />
24
  </div>
25
  </div>
26
  </div>
components/Header.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import * as React from 'react';
2
  import Link from 'next/link';
3
 
4
- import { auth } from '@/auth';
5
  import { Button } from '@/components/ui/Button';
6
  import { UserMenu } from '@/components/UserMenu';
7
  import {
@@ -15,12 +15,10 @@ import { redirect } from 'next/navigation';
15
 
16
  export async function Header() {
17
  const session = await auth();
 
18
  return (
19
  <header className="sticky top-0 z-50 flex items-center justify-end w-full h-16 px-8 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
20
- {/* <Button variant="link" asChild className="mr-2">
21
- <Link href="/project">Projects</Link>
22
- </Button> */}
23
- <Tooltip>
24
  <TooltipTrigger asChild>
25
  <Button variant="link" asChild className="mr-2">
26
  <Link href="/chat">
@@ -29,10 +27,15 @@ export async function Header() {
29
  </Button>
30
  </TooltipTrigger>
31
  <TooltipContent>New chat</TooltipContent>
32
- </Tooltip>
33
- {/* <Button variant="link" asChild className="mr-2">
 
 
 
 
 
34
  <Link href="/chat">Chat</Link>
35
- </Button> */}
36
  <IconSeparator className="size-6 text-muted-foreground/50" />
37
  <div className="flex items-center">
38
  {session?.user ? <UserMenu user={session!.user} /> : <LoginMenu />}
 
1
  import * as React from 'react';
2
  import Link from 'next/link';
3
 
4
+ import { auth, authEmail } from '@/auth';
5
  import { Button } from '@/components/ui/Button';
6
  import { UserMenu } from '@/components/UserMenu';
7
  import {
 
15
 
16
  export async function Header() {
17
  const session = await auth();
18
+ const { isAdmin } = await authEmail();
19
  return (
20
  <header className="sticky top-0 z-50 flex items-center justify-end w-full h-16 px-8 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
21
+ {/* <Tooltip>
 
 
 
22
  <TooltipTrigger asChild>
23
  <Button variant="link" asChild className="mr-2">
24
  <Link href="/chat">
 
27
  </Button>
28
  </TooltipTrigger>
29
  <TooltipContent>New chat</TooltipContent>
30
+ </Tooltip> */}
31
+ {isAdmin && (
32
+ <Button variant="link" asChild className="mr-2">
33
+ <Link href="/project">Projects (Internal)</Link>
34
+ </Button>
35
+ )}
36
+ <Button variant="link" asChild className="mr-2">
37
  <Link href="/chat">Chat</Link>
38
+ </Button>
39
  <IconSeparator className="size-6 text-muted-foreground/50" />
40
  <div className="flex items-center">
41
  {session?.user ? <UserMenu user={session!.user} /> : <LoginMenu />}
components/chat-sidebar/ChatAdminToggle.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client';
2
 
3
- import { chatViewMode } from '@/state';
4
  import { useAtom } from 'jotai';
5
  import React from 'react';
6
 
 
1
  'use client';
2
 
3
+ import { chatViewMode } from '@/state/chat';
4
  import { useAtom } from 'jotai';
5
  import React from 'react';
6
 
components/chat-sidebar/ChatCard.tsx CHANGED
@@ -8,6 +8,7 @@ import { ChatEntity } from '@/lib/types';
8
  import Image from 'next/image';
9
  import clsx from 'clsx';
10
  import Img from '../ui/Img';
 
11
  // import { format } from 'date-fns';
12
 
13
  type ChatCardProps = PropsWithChildren<{
@@ -50,7 +51,7 @@ const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
50
  <div className="flex items-start flex-col h-full ml-3 w-3/4">
51
  <p className="text-sm mb-1">{title}</p>
52
  <p className="text-xs text-gray-500">
53
- {updatedAt ? new Date(1714027100904).toLocaleDateString() : '-'}
54
  </p>
55
  </div>
56
  </div>
 
8
  import Image from 'next/image';
9
  import clsx from 'clsx';
10
  import Img from '../ui/Img';
11
+ import { format } from 'date-fns';
12
  // import { format } from 'date-fns';
13
 
14
  type ChatCardProps = PropsWithChildren<{
 
51
  <div className="flex items-start flex-col h-full ml-3 w-3/4">
52
  <p className="text-sm mb-1">{title}</p>
53
  <p className="text-xs text-gray-500">
54
+ {updatedAt ? format(Number(updatedAt), 'yyyy-MM-dd') : '-'}
55
  </p>
56
  </div>
57
  </div>
components/chat/ChatDataLoad.tsx DELETED
@@ -1,11 +0,0 @@
1
- import { getKVChat } from '@/lib/kv/chat';
2
- import { Chat } from '.';
3
-
4
- export interface ChatDataLoadProps {
5
- chatId: string;
6
- }
7
-
8
- export default async function ChatDataLoad({ chatId }: ChatDataLoadProps) {
9
- const chat = await getKVChat(chatId);
10
- return <Chat chat={chat} />;
11
- }
 
 
 
 
 
 
 
 
 
 
 
 
components/chat/ChatList.tsx CHANGED
@@ -10,7 +10,7 @@ export interface ChatList {
10
 
11
  export function ChatList({ messages }: ChatList) {
12
  return (
13
- <div className="relative mx-auto max-w-5xl px-8 pr-12 pb-[100px]">
14
  {messages
15
  // .filter(message => message.role !== 'system')
16
  .map((message, index) => (
 
10
 
11
  export function ChatList({ messages }: ChatList) {
12
  return (
13
+ <div className="relative mx-auto max-w-5xl px-8 pr-12">
14
  {messages
15
  // .filter(message => message.role !== 'system')
16
  .map((message, index) => (
components/chat/ChatMessage.tsx CHANGED
@@ -11,6 +11,11 @@ import { IconOpenAI, IconUser } from '@/components/ui/Icons';
11
  import { ChatMessageActions } from '@/components/chat/ChatMessageActions';
12
  import { MessageBase } from '../../lib/types';
13
  import { useCleanedUpMessages } from '@/lib/hooks/useCleanedUpMessages';
 
 
 
 
 
14
  import Img from '../ui/Img';
15
 
16
  export interface ChatMessageProps {
@@ -77,14 +82,28 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
77
  },
78
  img(props) {
79
  return (
80
- <Img
81
- src={props.src ?? '/landing.png'}
82
- alt={props.alt ?? 'answer-image'}
83
- quality={100}
84
- sizes="(min-width: 66em) 40vw,
85
- (min-width: 44em) 66vw,
 
 
 
86
  100vw"
87
- />
 
 
 
 
 
 
 
 
 
 
 
88
  );
89
  },
90
  code({ node, inline, className, children, ...props }) {
@@ -120,7 +139,7 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
120
  >
121
  {content}
122
  </MemoizedReactMarkdown>
123
- <ChatMessageActions message={message} />
124
  </div>
125
  </div>
126
  );
 
11
  import { ChatMessageActions } from '@/components/chat/ChatMessageActions';
12
  import { MessageBase } from '../../lib/types';
13
  import { useCleanedUpMessages } from '@/lib/hooks/useCleanedUpMessages';
14
+ import {
15
+ Tooltip,
16
+ TooltipContent,
17
+ TooltipTrigger,
18
+ } from '@/components/ui/Tooltip';
19
  import Img from '../ui/Img';
20
 
21
  export interface ChatMessageProps {
 
82
  },
83
  img(props) {
84
  return (
85
+ <Tooltip>
86
+ <TooltipTrigger asChild>
87
+ <Img
88
+ src={props.src ?? '/landing.png'}
89
+ alt={props.alt ?? 'answer-image'}
90
+ quality={100}
91
+ className="cursor-zoom-in"
92
+ sizes="(min-width: 66em) 25vw,
93
+ (min-width: 44em) 40vw,
94
  100vw"
95
+ />
96
+ </TooltipTrigger>
97
+ <TooltipContent>
98
+ <Img
99
+ className="m-2"
100
+ src={props.src ?? '/landing.png'}
101
+ alt={props.alt ?? 'answer-image'}
102
+ quality={100}
103
+ width={500}
104
+ />
105
+ </TooltipContent>
106
+ </Tooltip>
107
  );
108
  },
109
  code({ node, inline, className, children, ...props }) {
 
139
  >
140
  {content}
141
  </MemoizedReactMarkdown>
142
+ {/* <ChatMessageActions message={message} /> */}
143
  </div>
144
  </div>
145
  );
components/chat/Composer.tsx CHANGED
@@ -57,138 +57,139 @@ export function Composer({
57
  }, []);
58
 
59
  return (
60
- <div className="fixed inset-x-0 bottom-0 w-full animate-in duration-300 ease-in-out peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px] h-[178px]">
61
- <div className="mx-auto sm:max-w-3xl sm:px-4 h-full">
62
- <div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4 h-full">
63
- <form
64
- onSubmit={async e => {
65
- e.preventDefault();
66
- if (!input?.trim()) {
67
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
- setInput('');
70
- await append({
71
- id,
72
- content: input,
73
- role: 'user',
74
- });
75
- scrollToBottom();
76
- }}
77
- ref={formRef}
78
- className="h-full"
79
- >
80
- <div className="relative flex px-8 pl-2 overflow-hidden size-full bg-background sm:rounded-md sm:border sm:px-12 sm:pl-2 items-start">
81
- {url && (
82
- <div className="w-1/5 p-2 h-full flex items-center justify-center relative">
83
- <Tooltip>
84
- <TooltipTrigger asChild>
85
- <Img
86
- src={url}
87
- className="cursor-zoom-in"
88
- alt="preview-image"
89
- />
90
- </TooltipTrigger>
91
- <TooltipContent>
92
- <Img
93
- src={url}
94
- className="m-2"
95
- quality={100}
96
- alt="zoomed-in-image"
97
- />
98
- </TooltipContent>
99
- </Tooltip>
100
- </div>
101
  )}
102
- <Textarea
103
- ref={inputRef}
104
- tabIndex={0}
105
- onKeyDown={onKeyDown}
106
- rows={1}
107
- value={input}
108
- disabled={isLoading}
109
- onChange={e => setInput(e.target.value)}
110
- placeholder={
111
- isLoading
112
- ? 'Vision Agent is thinking...'
113
- : 'Ask questions about the images.'
114
- }
115
- spellCheck={false}
116
- className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3em] focus-within:outline-none sm:text-sm"
117
- />
118
- {/* Scroll to bottom Icon */}
119
- <div
120
- className={cn(
121
- 'absolute top-3 right-4 transition-opacity duration-300',
122
- isAtBottom ? 'opacity-0' : 'opacity-100',
123
- )}
124
- >
125
  <Tooltip>
126
  <TooltipTrigger asChild>
127
  <Button
128
  variant="outline"
129
  size="icon"
130
  className="bg-background"
131
- onClick={() => scrollToBottom()}
132
  >
133
- <IconArrowDown />
134
  </Button>
135
  </TooltipTrigger>
136
- <TooltipContent>Scroll to bottom</TooltipContent>
137
  </Tooltip>
138
- </div>
139
- {/* Stop / Regenerate Icon */}
140
- <div className="absolute bottom-14 right-4">
141
- {isLoading ? (
142
  <Tooltip>
143
  <TooltipTrigger asChild>
144
  <Button
145
  variant="outline"
146
  size="icon"
147
  className="bg-background"
148
- onClick={() => stop()}
149
  >
150
- <IconStop />
151
  </Button>
152
  </TooltipTrigger>
153
- <TooltipContent>Stop generating</TooltipContent>
154
  </Tooltip>
155
- ) : (
156
- messages?.length >= 2 && (
157
- <Tooltip>
158
- <TooltipTrigger asChild>
159
- <Button
160
- variant="outline"
161
- size="icon"
162
- className="bg-background"
163
- onClick={() => reload()}
164
- >
165
- <IconRefresh />
166
- </Button>
167
- </TooltipTrigger>
168
- <TooltipContent>Regenerate response</TooltipContent>
169
- </Tooltip>
170
- )
171
- )}
172
- </div>
173
- {/* Submit Icon */}
174
- <div className="absolute bottom-3 right-4">
175
- <Tooltip>
176
- <TooltipTrigger asChild>
177
- <Button
178
- type="submit"
179
- size="icon"
180
- disabled={isLoading || input === ''}
181
- >
182
- <IconArrowElbow />
183
- </Button>
184
- </TooltipTrigger>
185
- <TooltipContent>Send message</TooltipContent>
186
- </Tooltip>
187
- </div>
188
  </div>
189
- </form>
190
- </div>
191
  </div>
192
  </div>
 
193
  );
194
  }
 
57
  }, []);
58
 
59
  return (
60
+ // <div className="fixed inset-x-0 bottom-0 w-full animate-in duration-300 ease-in-out peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px] h-[178px]">
61
+ <div className="mx-auto sm:max-w-3xl sm:px-4 h-full">
62
+ <div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4 h-full">
63
+ <form
64
+ onSubmit={async e => {
65
+ e.preventDefault();
66
+ if (!input?.trim()) {
67
+ return;
68
+ }
69
+ setInput('');
70
+ await append({
71
+ id,
72
+ content: input,
73
+ role: 'user',
74
+ });
75
+ scrollToBottom();
76
+ }}
77
+ ref={formRef}
78
+ className="h-full"
79
+ >
80
+ <div className="relative flex px-8 pl-2 overflow-hidden size-full bg-background sm:rounded-md sm:border sm:px-12 sm:pl-2 items-start">
81
+ {url && (
82
+ <div className="w-1/5 p-2 h-full flex items-center justify-center relative">
83
+ <Tooltip>
84
+ <TooltipTrigger asChild>
85
+ <Img
86
+ src={url}
87
+ className="cursor-zoom-in"
88
+ alt="preview-image"
89
+ />
90
+ </TooltipTrigger>
91
+ <TooltipContent>
92
+ <Img
93
+ src={url}
94
+ className="m-2"
95
+ quality={100}
96
+ width={500}
97
+ alt="zoomed-in-image"
98
+ />
99
+ </TooltipContent>
100
+ </Tooltip>
101
+ </div>
102
+ )}
103
+ <Textarea
104
+ ref={inputRef}
105
+ tabIndex={0}
106
+ onKeyDown={onKeyDown}
107
+ rows={1}
108
+ value={input}
109
+ disabled={isLoading}
110
+ onChange={e => setInput(e.target.value)}
111
+ placeholder={
112
+ isLoading
113
+ ? 'Vision Agent is thinking...'
114
+ : 'Ask question about the image.'
115
  }
116
+ spellCheck={false}
117
+ className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3em] focus-within:outline-none sm:text-sm"
118
+ />
119
+ {/* Scroll to bottom Icon */}
120
+ <div
121
+ className={cn(
122
+ 'absolute top-3 right-4 transition-opacity duration-300',
123
+ isAtBottom ? 'opacity-0' : 'opacity-100',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  )}
125
+ >
126
+ <Tooltip>
127
+ <TooltipTrigger asChild>
128
+ <Button
129
+ variant="outline"
130
+ size="icon"
131
+ className="bg-background"
132
+ onClick={() => scrollToBottom()}
133
+ >
134
+ <IconArrowDown />
135
+ </Button>
136
+ </TooltipTrigger>
137
+ <TooltipContent>Scroll to bottom</TooltipContent>
138
+ </Tooltip>
139
+ </div>
140
+ {/* Stop / Regenerate Icon */}
141
+ <div className="absolute bottom-14 right-4">
142
+ {isLoading ? (
 
 
 
 
 
143
  <Tooltip>
144
  <TooltipTrigger asChild>
145
  <Button
146
  variant="outline"
147
  size="icon"
148
  className="bg-background"
149
+ onClick={() => stop()}
150
  >
151
+ <IconStop />
152
  </Button>
153
  </TooltipTrigger>
154
+ <TooltipContent>Stop generating</TooltipContent>
155
  </Tooltip>
156
+ ) : (
157
+ messages?.length >= 2 && (
 
 
158
  <Tooltip>
159
  <TooltipTrigger asChild>
160
  <Button
161
  variant="outline"
162
  size="icon"
163
  className="bg-background"
164
+ onClick={() => reload()}
165
  >
166
+ <IconRefresh />
167
  </Button>
168
  </TooltipTrigger>
169
+ <TooltipContent>Regenerate response</TooltipContent>
170
  </Tooltip>
171
+ )
172
+ )}
173
+ </div>
174
+ {/* Submit Icon */}
175
+ <div className="absolute bottom-3 right-4">
176
+ <Tooltip>
177
+ <TooltipTrigger asChild>
178
+ <Button
179
+ type="submit"
180
+ size="icon"
181
+ disabled={isLoading || input === ''}
182
+ >
183
+ <IconArrowElbow />
184
+ </Button>
185
+ </TooltipTrigger>
186
+ <TooltipContent>Send message</TooltipContent>
187
+ </Tooltip>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  </div>
189
+ </div>
190
+ </form>
191
  </div>
192
  </div>
193
+ // </div>
194
  );
195
  }
components/chat/ImageList.tsx DELETED
@@ -1,76 +0,0 @@
1
- import React, { useCallback } from 'react';
2
- import useImageUpload from '../../lib/hooks/useImageUpload';
3
- import { useAtom, useAtomValue } from 'jotai';
4
- import { datasetAtom } from '../../state';
5
- import Image from 'next/image';
6
- import { produce } from 'immer';
7
-
8
- export interface ImageListProps {}
9
-
10
- const ImageList: React.FC<ImageListProps> = () => {
11
- const { getRootProps, getInputProps, isDragActive } = useImageUpload({
12
- noClick: true,
13
- });
14
-
15
- const [dataset, setDataset] = useAtom(datasetAtom);
16
- return (
17
- <div className="relative size-full px-12 max-w-3xl mx-auto">
18
- {/* {dataset.length < 10 ? (
19
- <div className="col-span-full px-8 py-4 rounded-xl bg-blue-100 text-blue-400 mb-8">
20
- You can upload up to 10 images max by dragging image.
21
- </div>
22
- ) : (
23
- <div className="col-span-full px-8 py-4 rounded-xl bg-red-100 text-red-400 mb-8">
24
- You have reached the maximum limit of 10 images.
25
- </div>
26
- )} */}
27
- <div
28
- {...getRootProps()}
29
- className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-4"
30
- >
31
- {dataset.map(entity => {
32
- const { url: imageSrc, name, selected } = entity;
33
- return (
34
- <div
35
- key={name}
36
- onClick={() =>
37
- setDataset(prev =>
38
- produce(prev, draft => {
39
- const index = draft.findIndex(d => d.name === name);
40
- draft[index].selected = !selected;
41
- }),
42
- )
43
- }
44
- className={`relative rounded-xl overflow-hidden shadow-md cursor-pointer transition-transform hover:scale-105 box-content ${selected ? 'border-4 border-blue-500' : ''}`}
45
- >
46
- <Image
47
- src={imageSrc}
48
- draggable={false}
49
- alt="dataset images"
50
- width={500}
51
- height={500}
52
- objectFit="cover"
53
- className="rounded-xl"
54
- />
55
- <div className="absolute bottom-0 left-0 bg-gray-800/50 text-white px-3 py-1 rounded-tr-lg">
56
- <p className="text-xs font-bold">{name}</p>
57
- </div>
58
- </div>
59
- );
60
- })}
61
- </div>
62
-
63
- {isDragActive && (
64
- <div
65
- {...getRootProps()}
66
- className="dropzone border-2 border-dashed border-gray-400 size-full absolute top-0 left-0 flex items-center justify-center rounded-lg cursor-pointer bg-gray-500/50"
67
- >
68
- <input {...getInputProps()} />
69
- <p className="text-white">Drop the files here ...</p>
70
- </div>
71
- )}
72
- </div>
73
- );
74
- };
75
-
76
- export default ImageList;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat/index.tsx CHANGED
@@ -1,10 +1,8 @@
1
  'use client';
2
 
3
- import { cn } from '@/lib/utils';
4
  import { ChatList } from '@/components/chat/ChatList';
5
  import { Composer } from '@/components/chat/Composer';
6
  import { ChatEntity } from '@/lib/types';
7
- import Image from 'next/image';
8
  import useVisionAgent from '@/lib/hooks/useVisionAgent';
9
  import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
10
 
@@ -22,25 +20,27 @@ export function Chat({ chat }: ChatProps) {
22
 
23
  return (
24
  <>
25
- <div className="h-full overflow-auto" ref={scrollRef}>
26
  <div className="pb-[200px] pt-4 md:pt-10" ref={messagesRef}>
27
  <ChatList messages={messages} />
28
  <div className="h-px w-full" ref={visibilityRef} />
29
  </div>
30
  </div>
31
- <Composer
32
- id={id}
33
- url={url}
34
- isLoading={isLoading}
35
- stop={stop}
36
- append={append}
37
- reload={reload}
38
- messages={messages}
39
- input={input}
40
- setInput={setInput}
41
- isAtBottom={isAtBottom}
42
- scrollToBottom={scrollToBottom}
43
- />
 
 
44
  </>
45
  );
46
  }
 
1
  'use client';
2
 
 
3
  import { ChatList } from '@/components/chat/ChatList';
4
  import { Composer } from '@/components/chat/Composer';
5
  import { ChatEntity } from '@/lib/types';
 
6
  import useVisionAgent from '@/lib/hooks/useVisionAgent';
7
  import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
8
 
 
20
 
21
  return (
22
  <>
23
+ <div className="h-full overflow-auto relative" ref={scrollRef}>
24
  <div className="pb-[200px] pt-4 md:pt-10" ref={messagesRef}>
25
  <ChatList messages={messages} />
26
  <div className="h-px w-full" ref={visibilityRef} />
27
  </div>
28
  </div>
29
+ <div className="fixed inset-x-0 bottom-0 w-full animate-in duration-300 ease-in-out peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px] h-[178px]">
30
+ <Composer
31
+ id={id}
32
+ url={url}
33
+ isLoading={isLoading}
34
+ stop={stop}
35
+ append={append}
36
+ reload={reload}
37
+ messages={messages}
38
+ input={input}
39
+ setInput={setInput}
40
+ isAtBottom={isAtBottom}
41
+ scrollToBottom={scrollToBottom}
42
+ />
43
+ </div>
44
  </>
45
  );
46
  }
components/project-sidebar/ProjectCard.tsx CHANGED
@@ -26,14 +26,14 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ projectInfo }) => {
26
  return (
27
  <Link
28
  className={cn(
29
- 'p-4 m-2 bg-white l:h-[250px] rounded-xl shadow-md flex items-center border border-transparent hover:border-gray-500 transition-all cursor-pointer',
30
  Number(projectIdFromParam) === id && 'border-gray-500',
31
  )}
32
  href={`/project/${id}`}
33
  >
34
  <div className="overflow-hidden">
35
  <p className="text-xs text-gray-500">{orgName}</p>
36
- <p className="text-sm font-medium text-black mb-1">{name}</p>
37
  <p className="text-xs text-gray-500">{formattedDate}</p>
38
  </div>
39
  </Link>
 
26
  return (
27
  <Link
28
  className={cn(
29
+ 'p-4 m-2 bg-background l:h-[250px] rounded-xl shadow-md flex items-center border border-transparent hover:border-gray-500 transition-all cursor-pointer',
30
  Number(projectIdFromParam) === id && 'border-gray-500',
31
  )}
32
  href={`/project/${id}`}
33
  >
34
  <div className="overflow-hidden">
35
  <p className="text-xs text-gray-500">{orgName}</p>
36
+ <p className="text-sm font-medium text-gray mb-1">{name}</p>
37
  <p className="text-xs text-gray-500">{formattedDate}</p>
38
  </div>
39
  </Link>
components/project/Chat.tsx DELETED
@@ -1,32 +0,0 @@
1
- 'use client';
2
-
3
- import { MediaDetails } from '@/lib/fetch';
4
- import useChatWithMedia from '@/lib/hooks/useChatWithMedia';
5
- import React from 'react';
6
- import { ChatList } from '../chat/ChatList';
7
- // import { ChatPanel } from '../chat/ChatPanel';
8
-
9
- export interface ChatProps {
10
- mediaList: MediaDetails[];
11
- }
12
-
13
- const Chat: React.FC<ChatProps> = ({ mediaList }) => {
14
- const { messages, append, reload, stop, isLoading, input, setInput } =
15
- useChatWithMedia(mediaList);
16
- return (
17
- <>
18
- <ChatList messages={messages} />
19
- {/* <ChatPanel
20
- isLoading={isLoading}
21
- stop={stop}
22
- append={append}
23
- reload={reload}
24
- messages={messages}
25
- input={input}
26
- setInput={setInput}
27
- /> */}
28
- </>
29
- );
30
- };
31
-
32
- export default Chat;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/project/MediaTile.tsx CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import React from 'react';
2
  import Image from 'next/image';
3
  import {
@@ -6,6 +8,9 @@ import {
6
  TooltipTrigger,
7
  } from '@/components/ui/Tooltip';
8
  import { MediaDetails } from '@/lib/fetch';
 
 
 
9
 
10
  export interface MediaTileProps {
11
  media: MediaDetails;
@@ -19,6 +24,8 @@ export default function MediaTile({ media }: MediaTileProps) {
19
  name,
20
  properties: { width, height },
21
  } = media;
 
 
22
  // const imageSrc = thumbnails.length ? thumbnails[thumbnails.length - 1] : url;
23
  const imageSrc = url;
24
  return (
@@ -30,7 +37,11 @@ export default function MediaTile({ media }: MediaTileProps) {
30
  alt="dataset images"
31
  width={width}
32
  height={height}
33
- className="w-full h-auto relative rounded-xl overflow-hidden shadow-md cursor-pointer transition-transform hover:scale-105 box-content"
 
 
 
 
34
  />
35
  </TooltipTrigger>
36
  <TooltipContent>
 
1
+ 'use client';
2
+
3
  import React from 'react';
4
  import Image from 'next/image';
5
  import {
 
8
  TooltipTrigger,
9
  } from '@/components/ui/Tooltip';
10
  import { MediaDetails } from '@/lib/fetch';
11
+ import { useAtom } from 'jotai';
12
+ import { selectedMediaIdAtom } from '@/state/media';
13
+ import { cn } from '@/lib/utils';
14
 
15
  export interface MediaTileProps {
16
  media: MediaDetails;
 
24
  name,
25
  properties: { width, height },
26
  } = media;
27
+ const [selectedMediaId, setSelectedMediaId] = useAtom(selectedMediaIdAtom);
28
+ const selected = selectedMediaId === id;
29
  // const imageSrc = thumbnails.length ? thumbnails[thumbnails.length - 1] : url;
30
  const imageSrc = url;
31
  return (
 
37
  alt="dataset images"
38
  width={width}
39
  height={height}
40
+ onClick={() => setSelectedMediaId(id)}
41
+ className={cn(
42
+ 'w-full h-auto relative rounded-xl overflow-hidden shadow-md cursor-pointer transition-transform hover:scale-105 box-content',
43
+ selected && 'border-2 border-primary',
44
+ )}
45
  />
46
  </TooltipTrigger>
47
  <TooltipContent>
components/project/ProjectChat.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { MediaDetails } from '@/lib/fetch';
4
+ import React from 'react';
5
+ import { ChatList } from '../chat/ChatList';
6
+ import useVisionAgent from '@/lib/hooks/useVisionAgent';
7
+ import { nanoid } from '@/lib/utils';
8
+ import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
9
+ import { Composer } from '../chat/Composer';
10
+ import { useAtomValue } from 'jotai';
11
+ import { selectedMediaIdAtom } from '@/state/media';
12
+
13
+ export interface ChatProps {
14
+ mediaList: MediaDetails[];
15
+ }
16
+
17
+ const ProjectChat: React.FC<ChatProps> = ({ mediaList }) => {
18
+ const selectedMediaId = useAtomValue(selectedMediaIdAtom);
19
+ // fallback to the first media
20
+ const selectedMedia =
21
+ mediaList.find(media => media.id === selectedMediaId) ?? mediaList[0];
22
+ const { messages, append, reload, stop, isLoading, input, setInput } =
23
+ useVisionAgent({
24
+ url: selectedMedia.url,
25
+ messages: [],
26
+ user: 'does-not-matter@landing.ai',
27
+ updatedAt: Date.now(),
28
+ });
29
+
30
+ const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
31
+ useScrollAnchor();
32
+
33
+ return (
34
+ <>
35
+ <div className="h-full overflow-auto" ref={scrollRef}>
36
+ <div className="pb-[200px] pt-4 md:pt-10" ref={messagesRef}>
37
+ <ChatList messages={messages} />
38
+ <div className="h-px w-full" ref={visibilityRef} />
39
+ </div>
40
+ </div>
41
+ <div className="absolute inset-x-0 bottom-0 w-full h-[178px]">
42
+ <Composer
43
+ url={selectedMedia.url}
44
+ isLoading={isLoading}
45
+ stop={stop}
46
+ append={append}
47
+ reload={reload}
48
+ messages={messages}
49
+ input={input}
50
+ setInput={setInput}
51
+ isAtBottom={isAtBottom}
52
+ scrollToBottom={scrollToBottom}
53
+ />
54
+ </div>
55
+ </>
56
+ );
57
+ };
58
+
59
+ export default ProjectChat;
components/ui/Img.tsx CHANGED
@@ -29,10 +29,11 @@ const Img = React.forwardRef<
29
  className={cn('rounded-md', className)}
30
  onLoad={e => {
31
  const img = e.target as HTMLImageElement;
 
32
  startTransition(() => {
33
  setDimensions({
34
- width: img.naturalWidth,
35
- height: img.naturalHeight,
36
  });
37
  });
38
  return onLoad?.(e);
 
29
  className={cn('rounded-md', className)}
30
  onLoad={e => {
31
  const img = e.target as HTMLImageElement;
32
+ const aspectRatio = img.naturalWidth / img.naturalHeight;
33
  startTransition(() => {
34
  setDimensions({
35
+ width: width ?? img.naturalWidth,
36
+ height: width ? Number(width) / aspectRatio : img.naturalHeight,
37
  });
38
  });
39
  return onLoad?.(e);
lib/fetch/index.ts CHANGED
@@ -51,7 +51,8 @@ const clefApiBuilder = <Params extends object | void, Resp>(
51
  method: options?.method ?? 'GET',
52
  headers: {
53
  sessionuser: JSON.stringify(sessionUser), // faked production user
54
- apikey: 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k', // dev key
 
55
  'X-ROUTE': 'mingruizhang-landing',
56
  },
57
  };
@@ -95,7 +96,7 @@ export type ProjectBaseInfo = {
95
  * @author https://github.com/landing-ai/landing-platform/blob/mingrui-04-08-meaningful-project/packages/server-clef/src/main_app/controllers/admin/get_admin_meaningful_project_controller.ts
96
  */
97
  export const fetchRecentProjectList = clefApiBuilder<void, ProjectBaseInfo[]>(
98
- 'api/admin/projects/recent',
99
  );
100
 
101
  export type MediaDetails = {
@@ -120,4 +121,4 @@ export type MediaDetails = {
120
  export const fetchProjectMedia = clefApiBuilder<
121
  { projectId: number },
122
  MediaDetails[]
123
- >('api/admin/project/media');
 
51
  method: options?.method ?? 'GET',
52
  headers: {
53
  sessionuser: JSON.stringify(sessionUser), // faked production user
54
+ // apikey: 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k', // dev key
55
+ apikey: 'land_sk_Z0AWhSGURwC8UVTjyZDH7VMsYod6jfTK79fC2RTGD85NkV6hwO', // prod key
56
  'X-ROUTE': 'mingruizhang-landing',
57
  },
58
  };
 
96
  * @author https://github.com/landing-ai/landing-platform/blob/mingrui-04-08-meaningful-project/packages/server-clef/src/main_app/controllers/admin/get_admin_meaningful_project_controller.ts
97
  */
98
  export const fetchRecentProjectList = clefApiBuilder<void, ProjectBaseInfo[]>(
99
+ 'api/admin/vision-agent/projects/recent',
100
  );
101
 
102
  export type MediaDetails = {
 
121
  export const fetchProjectMedia = clefApiBuilder<
122
  { projectId: number },
123
  MediaDetails[]
124
+ >('api/admin/vision-agent/project/media');
lib/hooks/useVisionAgent.tsx CHANGED
@@ -3,7 +3,7 @@ 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 { getCleanedUpMessages } from './useCleanedUpMessages';
8
  import { CLEANED_SEPARATOR } from '../constants';
9
 
@@ -58,7 +58,6 @@ const useVisionAgent = (chat: ChatEntity) => {
58
  setMessages,
59
  error,
60
  } = useChat({
61
- sendExtraMessageFields: true,
62
  api: '/api/vision-agent',
63
  onResponse(response) {
64
  if (response.status !== 200) {
@@ -70,7 +69,7 @@ const useVisionAgent = (chat: ChatEntity) => {
70
  if (images?.length) {
71
  const publicUrls = await Promise.all(
72
  images.map((image, index) =>
73
- uploadBase64(image, message.id, id, index),
74
  ),
75
  );
76
  const newContent = publicUrls.reduce((accum, url, index) => {
@@ -83,13 +82,25 @@ const useVisionAgent = (chat: ChatEntity) => {
83
  ...message,
84
  content: logs + CLEANED_SEPARATOR + newContent,
85
  };
86
- setMessages([...messages, newMessage]);
87
- saveKVChatMessage(id, newMessage);
 
 
 
 
 
 
 
 
 
 
88
  } else {
89
- saveKVChatMessage(id, {
90
- ...message,
91
- content: logs + CLEANED_SEPARATOR + content,
92
- });
 
 
93
  }
94
  },
95
  initialMessages: initialMessages,
@@ -99,35 +110,6 @@ const useVisionAgent = (chat: ChatEntity) => {
99
  },
100
  });
101
 
102
- const [loadingDots, setLoadingDots] = useState('');
103
-
104
- useEffect(() => {
105
- let loadingInterval: NodeJS.Timeout;
106
-
107
- if (isLoading) {
108
- loadingInterval = setInterval(() => {
109
- setLoadingDots(prevMessage => {
110
- switch (prevMessage) {
111
- case '':
112
- return '.';
113
- case '.':
114
- return '..';
115
- case '..':
116
- return '...';
117
- case '...':
118
- return '';
119
- default:
120
- return '';
121
- }
122
- });
123
- }, 500);
124
- }
125
-
126
- return () => {
127
- clearInterval(loadingInterval);
128
- };
129
- }, [isLoading]);
130
-
131
  useEffect(() => {
132
  if (
133
  !isLoading &&
@@ -140,19 +122,21 @@ const useVisionAgent = (chat: ChatEntity) => {
140
 
141
  const assistantLoadingMessage = {
142
  id: 'loading',
143
- content: loadingDots,
144
  role: 'assistant',
145
  };
146
 
147
  const messageWithLoading =
148
  isLoading &&
149
- messages.length &&
150
- messages[messages.length - 1].role !== 'assistant'
151
  ? [...messages, assistantLoadingMessage]
152
  : messages;
153
 
154
  const append: UseChatHelpers['append'] = async message => {
155
- await saveKVChatMessage(id, message as MessageBase);
 
 
156
  return appendRaw(message);
157
  };
158
 
 
3
  import { useEffect, useState } from 'react';
4
  import { ChatEntity, MessageBase, SignedPayload } from '../types';
5
  import { saveKVChatMessage } from '../kv/chat';
6
+ import { fetcher, nanoid } from '../utils';
7
  import { getCleanedUpMessages } from './useCleanedUpMessages';
8
  import { CLEANED_SEPARATOR } from '../constants';
9
 
 
58
  setMessages,
59
  error,
60
  } = useChat({
 
61
  api: '/api/vision-agent',
62
  onResponse(response) {
63
  if (response.status !== 200) {
 
69
  if (images?.length) {
70
  const publicUrls = await Promise.all(
71
  images.map((image, index) =>
72
+ uploadBase64(image, message.id, id ?? 'no-id', index),
73
  ),
74
  );
75
  const newContent = publicUrls.reduce((accum, url, index) => {
 
82
  ...message,
83
  content: logs + CLEANED_SEPARATOR + newContent,
84
  };
85
+ /**
86
+ * A workaround to fix the issue of the message not being appended to the chat
87
+ * https://github.com/vercel/ai/issues/550#issuecomment-1712693371
88
+ */
89
+ setMessages([
90
+ ...messages,
91
+ { id: nanoid(), role: 'user', content: input, createdAt: new Date() },
92
+ newMessage,
93
+ ]);
94
+ if (id) {
95
+ saveKVChatMessage(id, newMessage);
96
+ }
97
  } else {
98
+ if (id) {
99
+ saveKVChatMessage(id, {
100
+ ...message,
101
+ content: logs + CLEANED_SEPARATOR + content,
102
+ });
103
+ }
104
  }
105
  },
106
  initialMessages: initialMessages,
 
110
  },
111
  });
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  useEffect(() => {
114
  if (
115
  !isLoading &&
 
122
 
123
  const assistantLoadingMessage = {
124
  id: 'loading',
125
+ content: '...',
126
  role: 'assistant',
127
  };
128
 
129
  const messageWithLoading =
130
  isLoading &&
131
+ messages.length &&
132
+ messages[messages.length - 1].role !== 'assistant'
133
  ? [...messages, assistantLoadingMessage]
134
  : messages;
135
 
136
  const append: UseChatHelpers['append'] = async message => {
137
+ if (id) {
138
+ await saveKVChatMessage(id, message as MessageBase);
139
+ }
140
  return appendRaw(message);
141
  };
142
 
lib/types.ts CHANGED
@@ -31,7 +31,7 @@ export type MessageBase = {
31
 
32
  export type ChatEntity = {
33
  url: string;
34
- id: string;
35
  user: string; // email
36
  messages: MessageBase[];
37
  updatedAt: number;
 
31
 
32
  export type ChatEntity = {
33
  url: string;
34
+ id?: string; // a chat without id is not to be saved
35
  user: string; // email
36
  messages: MessageBase[];
37
  updatedAt: number;
state/{index.ts → chat.ts} RENAMED
@@ -1,8 +1,3 @@
1
  import { atom } from 'jotai';
2
- import { DatasetImageEntity } from '../lib/types';
3
-
4
- // list of image urls or base64 strings
5
- export const datasetAtom = atom<DatasetImageEntity[]>([]);
6
- // export const selectedImagesAtom = atom<number[]>([]);
7
 
8
  export const chatViewMode = atom<'chat' | 'chat-all'>('chat');
 
1
  import { atom } from 'jotai';
 
 
 
 
 
2
 
3
  export const chatViewMode = atom<'chat' | 'chat-all'>('chat');
state/media.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import { atom } from 'jotai';
2
+
3
+ export const selectedMediaIdAtom = atom<number | null>(null);