Spaces:
Sleeping
Sleeping
MingruiZhang
commited on
Commit
•
bfbf1a7
1
Parent(s):
3a22cf3
feat: Component UI and lastUpdated timestamp (#21)
Browse files<img width="1904" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/5669963/0565c7e2-a481-4246-8329-343a6c079fce">
- app/api/upload/route.ts +1 -0
- app/chat/layout.tsx +2 -3
- auth.ts +6 -0
- components/Header.tsx +17 -1
- components/chat-sidebar/ChatAdminToggle.tsx +14 -0
- components/chat-sidebar/ChatCard.tsx +9 -4
- components/chat/ChatMessage.tsx +1 -1
- components/chat/ChatPanel.tsx +0 -55
- components/chat/Composer.tsx +194 -0
- components/chat/PromptForm.tsx +0 -124
- components/chat/index.tsx +2 -5
- components/project/Chat.tsx +3 -3
- components/ui/ButtonScrollToBottom.tsx +0 -36
- lib/kv/chat.ts +33 -16
- lib/types.ts +4 -3
- lib/utils.ts +1 -0
- state/index.ts +2 -0
app/api/upload/route.ts
CHANGED
@@ -29,6 +29,7 @@ export async function POST(req: Request): Promise<Response> {
|
|
29 |
id: id ?? nanoid(),
|
30 |
user,
|
31 |
messages: initMessages ?? [],
|
|
|
32 |
};
|
33 |
|
34 |
await createKVChat(payload);
|
|
|
29 |
id: id ?? nanoid(),
|
30 |
user,
|
31 |
messages: initMessages ?? [],
|
32 |
+
updatedAt: Date.now(),
|
33 |
};
|
34 |
|
35 |
await createKVChat(payload);
|
app/chat/layout.tsx
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import { auth } from '@/auth';
|
2 |
import ChatSidebarList from '@/components/chat-sidebar/ChatListSidebar';
|
3 |
import Loading from '@/components/ui/Loading';
|
4 |
import { Suspense } from 'react';
|
@@ -8,8 +8,7 @@ interface ChatLayoutProps {
|
|
8 |
}
|
9 |
|
10 |
export default async function Layout({ children }: ChatLayoutProps) {
|
11 |
-
const
|
12 |
-
const email = session?.user?.email;
|
13 |
return (
|
14 |
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
|
15 |
<div
|
|
|
1 |
+
import { auth, authEmail } from '@/auth';
|
2 |
import ChatSidebarList from '@/components/chat-sidebar/ChatListSidebar';
|
3 |
import Loading from '@/components/ui/Loading';
|
4 |
import { Suspense } from 'react';
|
|
|
8 |
}
|
9 |
|
10 |
export default async function Layout({ children }: ChatLayoutProps) {
|
11 |
+
const { email, isAdmin } = await authEmail();
|
|
|
12 |
return (
|
13 |
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
|
14 |
<div
|
auth.ts
CHANGED
@@ -58,3 +58,9 @@ export const {
|
|
58 |
signIn: '/sign-in', // overrides the next-auth default signin page https://authjs.dev/guides/basics/pages
|
59 |
},
|
60 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
signIn: '/sign-in', // overrides the next-auth default signin page https://authjs.dev/guides/basics/pages
|
59 |
},
|
60 |
});
|
61 |
+
|
62 |
+
export async function authEmail() {
|
63 |
+
const session = await auth();
|
64 |
+
const email = session?.user?.email;
|
65 |
+
return { email, isAdmin: !!email?.endsWith('landing.ai') };
|
66 |
+
}
|
components/Header.tsx
CHANGED
@@ -4,8 +4,14 @@ import Link from 'next/link';
|
|
4 |
import { auth } from '@/auth';
|
5 |
import { Button } from '@/components/ui/Button';
|
6 |
import { UserMenu } from '@/components/UserMenu';
|
7 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
8 |
import { LoginMenu } from './LoginMenu';
|
|
|
9 |
|
10 |
export async function Header() {
|
11 |
const session = await auth();
|
@@ -14,6 +20,16 @@ export async function Header() {
|
|
14 |
{/* <Button variant="link" asChild className="mr-2">
|
15 |
<Link href="/project">Projects</Link>
|
16 |
</Button> */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
{/* <Button variant="link" asChild className="mr-2">
|
18 |
<Link href="/chat">Chat</Link>
|
19 |
</Button> */}
|
|
|
4 |
import { auth } from '@/auth';
|
5 |
import { Button } from '@/components/ui/Button';
|
6 |
import { UserMenu } from '@/components/UserMenu';
|
7 |
+
import {
|
8 |
+
Tooltip,
|
9 |
+
TooltipContent,
|
10 |
+
TooltipTrigger,
|
11 |
+
} from '@/components/ui/Tooltip';
|
12 |
+
import { IconPlus, IconSeparator } from '@/components/ui/Icons';
|
13 |
import { LoginMenu } from './LoginMenu';
|
14 |
+
import { redirect } from 'next/navigation';
|
15 |
|
16 |
export async function Header() {
|
17 |
const session = await auth();
|
|
|
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">
|
27 |
+
<IconPlus />
|
28 |
+
</Link>
|
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> */}
|
components/chat-sidebar/ChatAdminToggle.tsx
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use client';
|
2 |
+
|
3 |
+
import { chatViewMode } from '@/state';
|
4 |
+
import { useAtom } from 'jotai';
|
5 |
+
import React from 'react';
|
6 |
+
|
7 |
+
export interface ChatAdminToggleProps {}
|
8 |
+
|
9 |
+
const ChatAdminToggle: React.FC<ChatAdminToggleProps> = () => {
|
10 |
+
const modeAtom = useAtom(chatViewMode);
|
11 |
+
return null;
|
12 |
+
};
|
13 |
+
|
14 |
+
export default ChatAdminToggle;
|
components/chat-sidebar/ChatCard.tsx
CHANGED
@@ -2,12 +2,13 @@
|
|
2 |
|
3 |
import { PropsWithChildren } from 'react';
|
4 |
import Link from 'next/link';
|
5 |
-
import { useParams } from 'next/navigation';
|
6 |
import { cn } from '@/lib/utils';
|
7 |
import { ChatEntity } from '@/lib/types';
|
8 |
import Image from 'next/image';
|
9 |
import clsx from 'clsx';
|
10 |
import Img from '../ui/Img';
|
|
|
11 |
|
12 |
type ChatCardProps = PropsWithChildren<{
|
13 |
chat: ChatEntity;
|
@@ -31,7 +32,8 @@ export const ChatCardLayout: React.FC<
|
|
31 |
|
32 |
const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
|
33 |
const { id: chatIdFromParam } = useParams();
|
34 |
-
const
|
|
|
35 |
const firstMessage = messages?.[0]?.content;
|
36 |
const title = firstMessage
|
37 |
? firstMessage.length > 50
|
@@ -45,8 +47,11 @@ const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
|
|
45 |
>
|
46 |
<div className="overflow-hidden flex items-center size-full">
|
47 |
<Img src={url} alt={`chat-${id}-card-image`} className="w-1/4 " />
|
48 |
-
<div className="flex items-start h-full">
|
49 |
-
<p className="text-sm
|
|
|
|
|
|
|
50 |
</div>
|
51 |
</div>
|
52 |
</ChatCardLayout>
|
|
|
2 |
|
3 |
import { PropsWithChildren } from 'react';
|
4 |
import Link from 'next/link';
|
5 |
+
import { useParams, usePathname, useRouter } from 'next/navigation';
|
6 |
import { cn } from '@/lib/utils';
|
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<{
|
14 |
chat: ChatEntity;
|
|
|
32 |
|
33 |
const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
|
34 |
const { id: chatIdFromParam } = useParams();
|
35 |
+
const pathname = usePathname();
|
36 |
+
const { id, url, messages, user, updatedAt } = chat;
|
37 |
const firstMessage = messages?.[0]?.content;
|
38 |
const title = firstMessage
|
39 |
? firstMessage.length > 50
|
|
|
47 |
>
|
48 |
<div className="overflow-hidden flex items-center size-full">
|
49 |
<Img src={url} alt={`chat-${id}-card-image`} className="w-1/4 " />
|
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>
|
57 |
</ChatCardLayout>
|
components/chat/ChatMessage.tsx
CHANGED
@@ -33,7 +33,7 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
|
33 |
</div>
|
34 |
<div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
|
35 |
{logs && message.role !== 'user' && (
|
36 |
-
<div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-
|
37 |
<div className="text-xl font-bold">Thinking Process</div>
|
38 |
<MemoizedReactMarkdown
|
39 |
className="break-words text-sm"
|
|
|
33 |
</div>
|
34 |
<div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
|
35 |
{logs && message.role !== 'user' && (
|
36 |
+
<div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-auto">
|
37 |
<div className="text-xl font-bold">Thinking Process</div>
|
38 |
<MemoizedReactMarkdown
|
39 |
className="break-words text-sm"
|
components/chat/ChatPanel.tsx
DELETED
@@ -1,55 +0,0 @@
|
|
1 |
-
import * as React from 'react';
|
2 |
-
import { type UseChatHelpers } from 'ai/react';
|
3 |
-
|
4 |
-
import { Button } from '@/components/ui/Button';
|
5 |
-
import { PromptForm } from '@/components/chat/PromptForm';
|
6 |
-
import { IconRefresh, IconStop } from '@/components/ui/Icons';
|
7 |
-
import { MessageBase } from '../../lib/types';
|
8 |
-
|
9 |
-
export interface ChatPanelProps
|
10 |
-
extends Pick<
|
11 |
-
UseChatHelpers,
|
12 |
-
'append' | 'isLoading' | 'reload' | 'stop' | 'input' | 'setInput'
|
13 |
-
> {
|
14 |
-
id?: string;
|
15 |
-
title?: string;
|
16 |
-
messages: MessageBase[];
|
17 |
-
url?: string;
|
18 |
-
}
|
19 |
-
|
20 |
-
export function ChatPanel({
|
21 |
-
id,
|
22 |
-
title,
|
23 |
-
isLoading,
|
24 |
-
stop,
|
25 |
-
append,
|
26 |
-
reload,
|
27 |
-
input,
|
28 |
-
setInput,
|
29 |
-
messages,
|
30 |
-
url,
|
31 |
-
}: ChatPanelProps) {
|
32 |
-
return (
|
33 |
-
<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]">
|
34 |
-
<div className="mx-auto sm:max-w-3xl sm:px-4">
|
35 |
-
<div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4">
|
36 |
-
<PromptForm
|
37 |
-
url={url}
|
38 |
-
onSubmit={async value => {
|
39 |
-
await append({
|
40 |
-
id,
|
41 |
-
content: value,
|
42 |
-
role: 'user',
|
43 |
-
});
|
44 |
-
}}
|
45 |
-
input={input}
|
46 |
-
setInput={setInput}
|
47 |
-
isLoading={isLoading}
|
48 |
-
messages={messages}
|
49 |
-
reload={reload}
|
50 |
-
/>
|
51 |
-
</div>
|
52 |
-
</div>
|
53 |
-
</div>
|
54 |
-
);
|
55 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/chat/Composer.tsx
ADDED
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use client';
|
2 |
+
|
3 |
+
import * as React from 'react';
|
4 |
+
import { type UseChatHelpers } from 'ai/react';
|
5 |
+
import Textarea from 'react-textarea-autosize';
|
6 |
+
|
7 |
+
import { Button } from '@/components/ui/Button';
|
8 |
+
import { MessageBase } from '../../lib/types';
|
9 |
+
import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
|
10 |
+
import Img from '../ui/Img';
|
11 |
+
import {
|
12 |
+
Tooltip,
|
13 |
+
TooltipContent,
|
14 |
+
TooltipTrigger,
|
15 |
+
} from '@/components/ui/Tooltip';
|
16 |
+
import {
|
17 |
+
IconArrowDown,
|
18 |
+
IconArrowElbow,
|
19 |
+
IconRefresh,
|
20 |
+
IconStop,
|
21 |
+
} from '@/components/ui/Icons';
|
22 |
+
import { cn } from '@/lib/utils';
|
23 |
+
|
24 |
+
export interface ComposerProps
|
25 |
+
extends Pick<
|
26 |
+
UseChatHelpers,
|
27 |
+
'append' | 'isLoading' | 'reload' | 'stop' | 'input' | 'setInput'
|
28 |
+
> {
|
29 |
+
id?: string;
|
30 |
+
title?: string;
|
31 |
+
messages: MessageBase[];
|
32 |
+
url?: string;
|
33 |
+
isAtBottom: boolean;
|
34 |
+
scrollToBottom: () => void;
|
35 |
+
}
|
36 |
+
|
37 |
+
export function Composer({
|
38 |
+
id,
|
39 |
+
title,
|
40 |
+
isLoading,
|
41 |
+
stop,
|
42 |
+
append,
|
43 |
+
reload,
|
44 |
+
input,
|
45 |
+
setInput,
|
46 |
+
messages,
|
47 |
+
isAtBottom,
|
48 |
+
scrollToBottom,
|
49 |
+
url,
|
50 |
+
}: ComposerProps) {
|
51 |
+
const { formRef, onKeyDown } = useEnterSubmit();
|
52 |
+
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
53 |
+
React.useEffect(() => {
|
54 |
+
if (inputRef.current) {
|
55 |
+
inputRef.current.focus();
|
56 |
+
}
|
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 |
+
}
|
components/chat/PromptForm.tsx
DELETED
@@ -1,124 +0,0 @@
|
|
1 |
-
import * as React from 'react';
|
2 |
-
import Textarea from 'react-textarea-autosize';
|
3 |
-
import { UseChatHelpers } from 'ai/react';
|
4 |
-
import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
|
5 |
-
import { Button, buttonVariants } from '@/components/ui/Button';
|
6 |
-
import {
|
7 |
-
Tooltip,
|
8 |
-
TooltipContent,
|
9 |
-
TooltipTrigger,
|
10 |
-
} from '@/components/ui/Tooltip';
|
11 |
-
import {
|
12 |
-
IconArrowElbow,
|
13 |
-
IconPlus,
|
14 |
-
IconRefresh,
|
15 |
-
IconStop,
|
16 |
-
} from '@/components/ui/Icons';
|
17 |
-
import { useRouter } from 'next/navigation';
|
18 |
-
import Img from '../ui/Img';
|
19 |
-
import { MessageBase } from '@/lib/types';
|
20 |
-
|
21 |
-
export interface PromptProps
|
22 |
-
extends Pick<UseChatHelpers, 'input' | 'setInput' | 'reload'> {
|
23 |
-
onSubmit: (value: string) => void;
|
24 |
-
isLoading: boolean;
|
25 |
-
url?: string;
|
26 |
-
messages: MessageBase[];
|
27 |
-
}
|
28 |
-
|
29 |
-
export function PromptForm({
|
30 |
-
onSubmit,
|
31 |
-
input,
|
32 |
-
setInput,
|
33 |
-
isLoading,
|
34 |
-
url,
|
35 |
-
messages,
|
36 |
-
reload,
|
37 |
-
}: PromptProps) {
|
38 |
-
const { formRef, onKeyDown } = useEnterSubmit();
|
39 |
-
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
40 |
-
const router = useRouter();
|
41 |
-
React.useEffect(() => {
|
42 |
-
if (inputRef.current) {
|
43 |
-
inputRef.current.focus();
|
44 |
-
}
|
45 |
-
}, []);
|
46 |
-
|
47 |
-
return (
|
48 |
-
<form
|
49 |
-
onSubmit={async e => {
|
50 |
-
e.preventDefault();
|
51 |
-
if (!input?.trim()) {
|
52 |
-
return;
|
53 |
-
}
|
54 |
-
setInput('');
|
55 |
-
await onSubmit(input);
|
56 |
-
}}
|
57 |
-
ref={formRef}
|
58 |
-
>
|
59 |
-
<div className="relative flex w-full px-8 pl-2 overflow-hidden max-h-60 grow bg-background sm:rounded-md sm:border sm:px-12 sm:pl-2">
|
60 |
-
{url && (
|
61 |
-
<Tooltip>
|
62 |
-
<TooltipTrigger asChild>
|
63 |
-
<Img
|
64 |
-
alt="prompt-image"
|
65 |
-
src={url}
|
66 |
-
className="w-1/5 my-4 mx-2 cursor-zoom-in"
|
67 |
-
/>
|
68 |
-
</TooltipTrigger>
|
69 |
-
<TooltipContent>
|
70 |
-
<Img alt="prompt-hovered-image" src={url} className="m-2" />
|
71 |
-
</TooltipContent>
|
72 |
-
</Tooltip>
|
73 |
-
)}
|
74 |
-
<Textarea
|
75 |
-
ref={inputRef}
|
76 |
-
tabIndex={0}
|
77 |
-
onKeyDown={onKeyDown}
|
78 |
-
rows={1}
|
79 |
-
value={input}
|
80 |
-
onChange={e => setInput(e.target.value)}
|
81 |
-
placeholder="Ask questions about the images."
|
82 |
-
spellCheck={false}
|
83 |
-
className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
|
84 |
-
/>
|
85 |
-
<div className="absolute left-1/2 -translate-x-1/2 bottom-0 h-12 z-40">
|
86 |
-
{isLoading ? (
|
87 |
-
<Button
|
88 |
-
variant="outline"
|
89 |
-
onClick={() => stop()}
|
90 |
-
className="bg-background"
|
91 |
-
>
|
92 |
-
<IconStop className="mr-2" />
|
93 |
-
Stop generating
|
94 |
-
</Button>
|
95 |
-
) : (
|
96 |
-
messages?.length >= 2 && (
|
97 |
-
<div className="flex space-x-2">
|
98 |
-
<Button variant="outline" onClick={() => reload()}>
|
99 |
-
<IconRefresh className="mr-2" />
|
100 |
-
Regenerate response
|
101 |
-
</Button>
|
102 |
-
</div>
|
103 |
-
)
|
104 |
-
)}
|
105 |
-
</div>
|
106 |
-
<div className="absolute top-1/2 -translate-y-1/2 right-4">
|
107 |
-
<Tooltip>
|
108 |
-
<TooltipTrigger asChild>
|
109 |
-
<Button
|
110 |
-
type="submit"
|
111 |
-
size="icon"
|
112 |
-
disabled={isLoading || input === ''}
|
113 |
-
>
|
114 |
-
<IconArrowElbow />
|
115 |
-
<span className="sr-only">Send message</span>
|
116 |
-
</Button>
|
117 |
-
</TooltipTrigger>
|
118 |
-
<TooltipContent>Send message</TooltipContent>
|
119 |
-
</Tooltip>
|
120 |
-
</div>
|
121 |
-
</div>
|
122 |
-
</form>
|
123 |
-
);
|
124 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/chat/index.tsx
CHANGED
@@ -2,12 +2,11 @@
|
|
2 |
|
3 |
import { cn } from '@/lib/utils';
|
4 |
import { ChatList } from '@/components/chat/ChatList';
|
5 |
-
import {
|
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 |
-
import { ButtonScrollToBottom } from '../ui/ButtonScrollToBottom';
|
11 |
|
12 |
export interface ChatProps extends React.ComponentProps<'div'> {
|
13 |
chat: ChatEntity;
|
@@ -29,7 +28,7 @@ export function Chat({ chat }: ChatProps) {
|
|
29 |
<div className="h-px w-full" ref={visibilityRef} />
|
30 |
</div>
|
31 |
</div>
|
32 |
-
<
|
33 |
id={id}
|
34 |
url={url}
|
35 |
isLoading={isLoading}
|
@@ -39,8 +38,6 @@ export function Chat({ chat }: ChatProps) {
|
|
39 |
messages={messages}
|
40 |
input={input}
|
41 |
setInput={setInput}
|
42 |
-
/>
|
43 |
-
<ButtonScrollToBottom
|
44 |
isAtBottom={isAtBottom}
|
45 |
scrollToBottom={scrollToBottom}
|
46 |
/>
|
|
|
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 |
|
11 |
export interface ChatProps extends React.ComponentProps<'div'> {
|
12 |
chat: ChatEntity;
|
|
|
28 |
<div className="h-px w-full" ref={visibilityRef} />
|
29 |
</div>
|
30 |
</div>
|
31 |
+
<Composer
|
32 |
id={id}
|
33 |
url={url}
|
34 |
isLoading={isLoading}
|
|
|
38 |
messages={messages}
|
39 |
input={input}
|
40 |
setInput={setInput}
|
|
|
|
|
41 |
isAtBottom={isAtBottom}
|
42 |
scrollToBottom={scrollToBottom}
|
43 |
/>
|
components/project/Chat.tsx
CHANGED
@@ -4,7 +4,7 @@ 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[];
|
@@ -16,7 +16,7 @@ const Chat: React.FC<ChatProps> = ({ mediaList }) => {
|
|
16 |
return (
|
17 |
<>
|
18 |
<ChatList messages={messages} />
|
19 |
-
<ChatPanel
|
20 |
isLoading={isLoading}
|
21 |
stop={stop}
|
22 |
append={append}
|
@@ -24,7 +24,7 @@ const Chat: React.FC<ChatProps> = ({ mediaList }) => {
|
|
24 |
messages={messages}
|
25 |
input={input}
|
26 |
setInput={setInput}
|
27 |
-
/>
|
28 |
</>
|
29 |
);
|
30 |
};
|
|
|
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[];
|
|
|
16 |
return (
|
17 |
<>
|
18 |
<ChatList messages={messages} />
|
19 |
+
{/* <ChatPanel
|
20 |
isLoading={isLoading}
|
21 |
stop={stop}
|
22 |
append={append}
|
|
|
24 |
messages={messages}
|
25 |
input={input}
|
26 |
setInput={setInput}
|
27 |
+
/> */}
|
28 |
</>
|
29 |
);
|
30 |
};
|
components/ui/ButtonScrollToBottom.tsx
DELETED
@@ -1,36 +0,0 @@
|
|
1 |
-
'use client';
|
2 |
-
|
3 |
-
import React from 'react';
|
4 |
-
|
5 |
-
import { cn } from '@/lib/utils';
|
6 |
-
import { Button, type ButtonProps } from '@/components/ui/Button';
|
7 |
-
import { IconArrowDown } from '@/components/ui/Icons';
|
8 |
-
|
9 |
-
interface ButtonScrollToBottomProps extends ButtonProps {
|
10 |
-
isAtBottom: boolean;
|
11 |
-
scrollToBottom: () => void;
|
12 |
-
}
|
13 |
-
|
14 |
-
export function ButtonScrollToBottom({
|
15 |
-
className,
|
16 |
-
isAtBottom,
|
17 |
-
scrollToBottom,
|
18 |
-
...props
|
19 |
-
}: ButtonScrollToBottomProps) {
|
20 |
-
return (
|
21 |
-
<Button
|
22 |
-
variant="outline"
|
23 |
-
size="icon"
|
24 |
-
className={cn(
|
25 |
-
'fixed bottom-16 right-4 z-10 bg-background transition-opacity duration-300',
|
26 |
-
isAtBottom ? 'opacity-0' : 'opacity-100',
|
27 |
-
className,
|
28 |
-
)}
|
29 |
-
onClick={() => scrollToBottom()}
|
30 |
-
{...props}
|
31 |
-
>
|
32 |
-
<IconArrowDown />
|
33 |
-
<span className="sr-only">Scroll to bottom</span>
|
34 |
-
</Button>
|
35 |
-
);
|
36 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lib/kv/chat.ts
CHANGED
@@ -3,22 +3,13 @@
|
|
3 |
import { revalidatePath } from 'next/cache';
|
4 |
import { kv } from '@vercel/kv';
|
5 |
|
6 |
-
import { auth } from '@/auth';
|
7 |
import { ChatEntity, MessageBase } from '@/lib/types';
|
8 |
import { notFound, redirect } from 'next/navigation';
|
9 |
import { nanoid } from '../utils';
|
10 |
|
11 |
-
async function authCheck() {
|
12 |
-
const session = await auth();
|
13 |
-
const email = session?.user?.email;
|
14 |
-
// if (!email) {
|
15 |
-
// redirect('/');
|
16 |
-
// }
|
17 |
-
return { email, isAdmin: !!email?.endsWith('landing.ai') };
|
18 |
-
}
|
19 |
-
|
20 |
export async function getKVChats() {
|
21 |
-
const { email } = await
|
22 |
|
23 |
try {
|
24 |
const pipeline = kv.pipeline();
|
@@ -30,16 +21,41 @@ export async function getKVChats() {
|
|
30 |
pipeline.hgetall(chat);
|
31 |
}
|
32 |
|
33 |
-
const results = await pipeline.exec();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
|
35 |
-
return results
|
36 |
} catch (error) {
|
37 |
return [];
|
38 |
}
|
39 |
}
|
40 |
|
41 |
export async function getKVChat(id: string) {
|
42 |
-
// const { email, isAdmin } = await
|
43 |
const chat = await kv.hgetall<ChatEntity>(`chat:${id}`);
|
44 |
|
45 |
if (!chat) {
|
@@ -50,8 +66,8 @@ export async function getKVChat(id: string) {
|
|
50 |
}
|
51 |
|
52 |
export async function createKVChat(chat: ChatEntity) {
|
53 |
-
// const { email, isAdmin } = await
|
54 |
-
const { email } = await
|
55 |
|
56 |
await kv.hmset(`chat:${chat.id}`, chat);
|
57 |
if (email) {
|
@@ -76,6 +92,7 @@ export async function saveKVChatMessage(id: string, message: MessageBase) {
|
|
76 |
await kv.hmset(`chat:${id}`, {
|
77 |
...chat,
|
78 |
messages: [...messages, message],
|
|
|
79 |
});
|
80 |
revalidatePath('/chat', 'layout');
|
81 |
}
|
|
|
3 |
import { revalidatePath } from 'next/cache';
|
4 |
import { kv } from '@vercel/kv';
|
5 |
|
6 |
+
import { auth, authEmail } from '@/auth';
|
7 |
import { ChatEntity, MessageBase } from '@/lib/types';
|
8 |
import { notFound, redirect } from 'next/navigation';
|
9 |
import { nanoid } from '../utils';
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
export async function getKVChats() {
|
12 |
+
const { email } = await authEmail();
|
13 |
|
14 |
try {
|
15 |
const pipeline = kv.pipeline();
|
|
|
21 |
pipeline.hgetall(chat);
|
22 |
}
|
23 |
|
24 |
+
const results = (await pipeline.exec()) as ChatEntity[];
|
25 |
+
|
26 |
+
return results.sort((r1, r2) => r2.updatedAt - r1.updatedAt);
|
27 |
+
} catch (error) {
|
28 |
+
return [];
|
29 |
+
}
|
30 |
+
}
|
31 |
+
|
32 |
+
export async function adminGetAllKVChats() {
|
33 |
+
const { isAdmin } = await authEmail();
|
34 |
+
|
35 |
+
if (!isAdmin) {
|
36 |
+
notFound();
|
37 |
+
}
|
38 |
+
|
39 |
+
try {
|
40 |
+
const pipeline = kv.pipeline();
|
41 |
+
const chats: string[] = await kv.zrange(`user:chat:all`, 0, -1, {
|
42 |
+
rev: true,
|
43 |
+
});
|
44 |
+
|
45 |
+
for (const chat of chats) {
|
46 |
+
pipeline.hgetall(chat);
|
47 |
+
}
|
48 |
+
|
49 |
+
const results = (await pipeline.exec()) as ChatEntity[];
|
50 |
|
51 |
+
return results.sort((r1, r2) => r2.updatedAt - r1.updatedAt);
|
52 |
} catch (error) {
|
53 |
return [];
|
54 |
}
|
55 |
}
|
56 |
|
57 |
export async function getKVChat(id: string) {
|
58 |
+
// const { email, isAdmin } = await authEmail();
|
59 |
const chat = await kv.hgetall<ChatEntity>(`chat:${id}`);
|
60 |
|
61 |
if (!chat) {
|
|
|
66 |
}
|
67 |
|
68 |
export async function createKVChat(chat: ChatEntity) {
|
69 |
+
// const { email, isAdmin } = await authEmail();
|
70 |
+
const { email } = await authEmail();
|
71 |
|
72 |
await kv.hmset(`chat:${chat.id}`, chat);
|
73 |
if (email) {
|
|
|
92 |
await kv.hmset(`chat:${id}`, {
|
93 |
...chat,
|
94 |
messages: [...messages, message],
|
95 |
+
updatedAt: Date.now(),
|
96 |
});
|
97 |
revalidatePath('/chat', 'layout');
|
98 |
}
|
lib/types.ts
CHANGED
@@ -3,8 +3,8 @@ import { type Message } from 'ai';
|
|
3 |
export type ServerActionResult<Result> = Promise<
|
4 |
| Result
|
5 |
| {
|
6 |
-
|
7 |
-
|
8 |
>;
|
9 |
|
10 |
/**
|
@@ -34,6 +34,7 @@ export type ChatEntity = {
|
|
34 |
id: string;
|
35 |
user: string; // email
|
36 |
messages: MessageBase[];
|
|
|
37 |
};
|
38 |
|
39 |
export interface SignedPayload {
|
@@ -41,4 +42,4 @@ export interface SignedPayload {
|
|
41 |
publicUrl: string;
|
42 |
signedUrl: string;
|
43 |
fields: Record<string, string>;
|
44 |
-
}
|
|
|
3 |
export type ServerActionResult<Result> = Promise<
|
4 |
| Result
|
5 |
| {
|
6 |
+
error: string;
|
7 |
+
}
|
8 |
>;
|
9 |
|
10 |
/**
|
|
|
34 |
id: string;
|
35 |
user: string; // email
|
36 |
messages: MessageBase[];
|
37 |
+
updatedAt: number;
|
38 |
};
|
39 |
|
40 |
export interface SignedPayload {
|
|
|
42 |
publicUrl: string;
|
43 |
signedUrl: string;
|
44 |
fields: Record<string, string>;
|
45 |
+
}
|
lib/utils.ts
CHANGED
@@ -1,3 +1,4 @@
|
|
|
|
1 |
import { clsx, type ClassValue } from 'clsx';
|
2 |
import { customAlphabet } from 'nanoid';
|
3 |
import { twMerge } from 'tailwind-merge';
|
|
|
1 |
+
import { auth } from '@/auth';
|
2 |
import { clsx, type ClassValue } from 'clsx';
|
3 |
import { customAlphabet } from 'nanoid';
|
4 |
import { twMerge } from 'tailwind-merge';
|
state/index.ts
CHANGED
@@ -4,3 +4,5 @@ import { DatasetImageEntity } from '../lib/types';
|
|
4 |
// list of image urls or base64 strings
|
5 |
export const datasetAtom = atom<DatasetImageEntity[]>([]);
|
6 |
// export const selectedImagesAtom = atom<number[]>([]);
|
|
|
|
|
|
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');
|