Spaces:
Sleeping
Sleeping
MingruiZhang
commited on
feat: Customized Img and scroll to bottom (#18)
Browse files<img width="1904" alt="image"
src="https://github.com/landing-ai/vision-agent-ui/assets/5669963/2043cf31-34e8-4aae-8ddd-9f068154fb8f">
- app/api/sign/route.ts +26 -26
- app/api/vision-agent/route.ts +1 -1
- app/globals.css +0 -4
- components/chat-sidebar/ChatCard.tsx +2 -7
- components/chat/ChatList.tsx +1 -1
- components/chat/ChatMessage.tsx +1 -1
- components/chat/ChatPanel.tsx +2 -23
- components/chat/ChatScrollAnchor.tsx +0 -29
- components/chat/PromptForm.tsx +38 -13
- components/chat/index.tsx +13 -4
- components/project/Chat.tsx +0 -2
- components/{chat → ui}/ButtonScrollToBottom.tsx +13 -11
- components/ui/ImageLoader.tsx +0 -11
- components/ui/Img.tsx +47 -0
- lib/hooks/useAtBottom.tsx +0 -23
- lib/hooks/useScrollAnchor.tsx +87 -0
app/api/sign/route.ts
CHANGED
@@ -7,33 +7,33 @@ import { nanoid } from '@/lib/utils';
|
|
7 |
* @returns
|
8 |
*/
|
9 |
export async function POST(req: Request): Promise<Response> {
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
|
24 |
-
|
25 |
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
}
|
|
|
7 |
* @returns
|
8 |
*/
|
9 |
export async function POST(req: Request): Promise<Response> {
|
10 |
+
const session = await auth();
|
11 |
+
const user = session?.user?.email ?? 'anonymous';
|
12 |
+
// if (!email) {
|
13 |
+
// return new Response('Unauthorized', {
|
14 |
+
// status: 401,
|
15 |
+
// });
|
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,
|
30 |
+
signedUrl: res.url,
|
31 |
+
publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`,
|
32 |
+
fields: res.fields,
|
33 |
+
});
|
34 |
+
} catch (error) {
|
35 |
+
return new Response((error as Error).message, {
|
36 |
+
status: 400,
|
37 |
+
});
|
38 |
+
}
|
39 |
}
|
app/api/vision-agent/route.ts
CHANGED
@@ -5,7 +5,7 @@ import { MessageBase } from '../../../lib/types';
|
|
5 |
|
6 |
// export const runtime = 'edge';
|
7 |
export const dynamic = 'force-dynamic';
|
8 |
-
export const maxDuration =
|
9 |
|
10 |
export async function POST(req: Request) {
|
11 |
const json = await req.json();
|
|
|
5 |
|
6 |
// export const runtime = 'edge';
|
7 |
export const dynamic = 'force-dynamic';
|
8 |
+
export const maxDuration = 300; // This function can run for a maximum of 5 minutes
|
9 |
|
10 |
export async function POST(req: Request) {
|
11 |
const json = await req.json();
|
app/globals.css
CHANGED
@@ -144,10 +144,6 @@ tr {
|
|
144 |
border-top: 1px solid var(--color-border-muted);
|
145 |
}
|
146 |
|
147 |
-
tr:nth-child(2n) {
|
148 |
-
background-color: var(--color-canvas-subtle);
|
149 |
-
}
|
150 |
-
|
151 |
td,
|
152 |
th {
|
153 |
padding: 6px 13px;
|
|
|
144 |
border-top: 1px solid var(--color-border-muted);
|
145 |
}
|
146 |
|
|
|
|
|
|
|
|
|
147 |
td,
|
148 |
th {
|
149 |
padding: 6px 13px;
|
components/chat-sidebar/ChatCard.tsx
CHANGED
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
|
|
7 |
import { ChatEntity } from '@/lib/types';
|
8 |
import Image from 'next/image';
|
9 |
import clsx from 'clsx';
|
|
|
10 |
|
11 |
type ChatCardProps = PropsWithChildren<{
|
12 |
chat: ChatEntity;
|
@@ -43,13 +44,7 @@ const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
|
|
43 |
classNames={chatIdFromParam === id && 'border-gray-500'}
|
44 |
>
|
45 |
<div className="overflow-hidden flex items-center size-full">
|
46 |
-
<
|
47 |
-
src={url}
|
48 |
-
alt={url}
|
49 |
-
width={50}
|
50 |
-
height={50}
|
51 |
-
className="rounded w-1/4 "
|
52 |
-
/>
|
53 |
<div className="flex items-start h-full">
|
54 |
<p className="text-sm w-3/4 ml-3">{title}</p>
|
55 |
</div>
|
|
|
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;
|
|
|
44 |
classNames={chatIdFromParam === id && 'border-gray-500'}
|
45 |
>
|
46 |
<div className="overflow-hidden flex items-center size-full">
|
47 |
+
<Img src={url} className="w-1/4 " />
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
<div className="flex items-start h-full">
|
49 |
<p className="text-sm w-3/4 ml-3">{title}</p>
|
50 |
</div>
|
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
|
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
@@ -32,7 +32,7 @@ export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
|
32 |
</div>
|
33 |
<div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
|
34 |
{logs && message.role !== 'user' && (
|
35 |
-
<div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-
|
36 |
<div className="text-xl font-bold">Thinking Process</div>
|
37 |
<MemoizedReactMarkdown
|
38 |
className="break-words text-sm"
|
|
|
32 |
</div>
|
33 |
<div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
|
34 |
{logs && message.role !== 'user' && (
|
35 |
+
<div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-y-scroll">
|
36 |
<div className="text-xl font-bold">Thinking Process</div>
|
37 |
<MemoizedReactMarkdown
|
38 |
className="break-words text-sm"
|
components/chat/ChatPanel.tsx
CHANGED
@@ -3,7 +3,6 @@ import { type UseChatHelpers } from 'ai/react';
|
|
3 |
|
4 |
import { Button } from '@/components/ui/Button';
|
5 |
import { PromptForm } from '@/components/chat/PromptForm';
|
6 |
-
import { ButtonScrollToBottom } from '@/components/chat/ButtonScrollToBottom';
|
7 |
import { IconRefresh, IconStop } from '@/components/ui/Icons';
|
8 |
import { MessageBase } from '../../lib/types';
|
9 |
|
@@ -32,29 +31,7 @@ export function ChatPanel({
|
|
32 |
}: ChatPanelProps) {
|
33 |
return (
|
34 |
<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]">
|
35 |
-
<ButtonScrollToBottom />
|
36 |
<div className="mx-auto sm:max-w-3xl sm:px-4">
|
37 |
-
<div className="flex items-center justify-center h-12">
|
38 |
-
{isLoading ? (
|
39 |
-
<Button
|
40 |
-
variant="outline"
|
41 |
-
onClick={() => stop()}
|
42 |
-
className="bg-background"
|
43 |
-
>
|
44 |
-
<IconStop className="mr-2" />
|
45 |
-
Stop generating
|
46 |
-
</Button>
|
47 |
-
) : (
|
48 |
-
messages?.length >= 2 && (
|
49 |
-
<div className="flex space-x-2">
|
50 |
-
<Button variant="outline" onClick={() => reload()}>
|
51 |
-
<IconRefresh className="mr-2" />
|
52 |
-
Regenerate response
|
53 |
-
</Button>
|
54 |
-
</div>
|
55 |
-
)
|
56 |
-
)}
|
57 |
-
</div>
|
58 |
<div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4">
|
59 |
<PromptForm
|
60 |
url={url}
|
@@ -68,6 +45,8 @@ export function ChatPanel({
|
|
68 |
input={input}
|
69 |
setInput={setInput}
|
70 |
isLoading={isLoading}
|
|
|
|
|
71 |
/>
|
72 |
</div>
|
73 |
</div>
|
|
|
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 |
|
|
|
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}
|
|
|
45 |
input={input}
|
46 |
setInput={setInput}
|
47 |
isLoading={isLoading}
|
48 |
+
messages={messages}
|
49 |
+
reload={reload}
|
50 |
/>
|
51 |
</div>
|
52 |
</div>
|
components/chat/ChatScrollAnchor.tsx
DELETED
@@ -1,29 +0,0 @@
|
|
1 |
-
'use client';
|
2 |
-
|
3 |
-
import * as React from 'react';
|
4 |
-
import { useInView } from 'react-intersection-observer';
|
5 |
-
|
6 |
-
import { useAtBottom } from '@/lib/hooks/useAtBottom';
|
7 |
-
|
8 |
-
interface ChatScrollAnchorProps {
|
9 |
-
trackVisibility?: boolean;
|
10 |
-
}
|
11 |
-
|
12 |
-
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
|
13 |
-
const isAtBottom = useAtBottom();
|
14 |
-
const { ref, entry, inView } = useInView({
|
15 |
-
trackVisibility,
|
16 |
-
delay: 100,
|
17 |
-
rootMargin: '0px 0px -150px 0px',
|
18 |
-
});
|
19 |
-
|
20 |
-
React.useEffect(() => {
|
21 |
-
if (isAtBottom && trackVisibility && !inView) {
|
22 |
-
entry?.target.scrollIntoView({
|
23 |
-
block: 'start',
|
24 |
-
});
|
25 |
-
}
|
26 |
-
}, [inView, entry, isAtBottom, trackVisibility]);
|
27 |
-
|
28 |
-
return <div ref={ref} className="h-px w-full" />;
|
29 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/chat/PromptForm.tsx
CHANGED
@@ -2,22 +2,28 @@ 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 { cn } from '@/lib/utils';
|
6 |
import { Button, buttonVariants } from '@/components/ui/Button';
|
7 |
import {
|
8 |
Tooltip,
|
9 |
TooltipContent,
|
10 |
TooltipTrigger,
|
11 |
} from '@/components/ui/Tooltip';
|
12 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
13 |
import { useRouter } from 'next/navigation';
|
14 |
-
import
|
|
|
15 |
|
16 |
export interface PromptProps
|
17 |
-
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
|
18 |
onSubmit: (value: string) => void;
|
19 |
isLoading: boolean;
|
20 |
url?: string;
|
|
|
21 |
}
|
22 |
|
23 |
export function PromptForm({
|
@@ -26,6 +32,8 @@ export function PromptForm({
|
|
26 |
setInput,
|
27 |
isLoading,
|
28 |
url,
|
|
|
|
|
29 |
}: PromptProps) {
|
30 |
const { formRef, onKeyDown } = useEnterSubmit();
|
31 |
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
@@ -52,15 +60,11 @@ export function PromptForm({
|
|
52 |
{url && (
|
53 |
<Tooltip>
|
54 |
<TooltipTrigger asChild>
|
55 |
-
<
|
56 |
-
src={url}
|
57 |
-
width={250}
|
58 |
-
height={250}
|
59 |
-
alt="chosen image"
|
60 |
-
className="w-1/5 my-4 mx-2 rounded-md"
|
61 |
-
/>
|
62 |
</TooltipTrigger>
|
63 |
-
<TooltipContent>
|
|
|
|
|
64 |
</Tooltip>
|
65 |
)}
|
66 |
<Textarea
|
@@ -74,7 +78,28 @@ export function PromptForm({
|
|
74 |
spellCheck={false}
|
75 |
className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
|
76 |
/>
|
77 |
-
<div className="absolute
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
78 |
<Tooltip>
|
79 |
<TooltipTrigger asChild>
|
80 |
<Button
|
|
|
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({
|
|
|
32 |
setInput,
|
33 |
isLoading,
|
34 |
url,
|
35 |
+
messages,
|
36 |
+
reload,
|
37 |
}: PromptProps) {
|
38 |
const { formRef, onKeyDown } = useEnterSubmit();
|
39 |
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
|
|
60 |
{url && (
|
61 |
<Tooltip>
|
62 |
<TooltipTrigger asChild>
|
63 |
+
<Img src={url} className="w-1/5 my-4 mx-2 cursor-zoom-in" />
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
</TooltipTrigger>
|
65 |
+
<TooltipContent>
|
66 |
+
<Img src={url} className="m-2" />
|
67 |
+
</TooltipContent>
|
68 |
</Tooltip>
|
69 |
)}
|
70 |
<Textarea
|
|
|
78 |
spellCheck={false}
|
79 |
className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
|
80 |
/>
|
81 |
+
<div className="absolute left-1/2 -translate-x-1/2 bottom-0 h-12 z-40">
|
82 |
+
{isLoading ? (
|
83 |
+
<Button
|
84 |
+
variant="outline"
|
85 |
+
onClick={() => stop()}
|
86 |
+
className="bg-background"
|
87 |
+
>
|
88 |
+
<IconStop className="mr-2" />
|
89 |
+
Stop generating
|
90 |
+
</Button>
|
91 |
+
) : (
|
92 |
+
messages?.length >= 2 && (
|
93 |
+
<div className="flex space-x-2">
|
94 |
+
<Button variant="outline" onClick={() => reload()}>
|
95 |
+
<IconRefresh className="mr-2" />
|
96 |
+
Regenerate response
|
97 |
+
</Button>
|
98 |
+
</div>
|
99 |
+
)
|
100 |
+
)}
|
101 |
+
</div>
|
102 |
+
<div className="absolute top-1/2 -translate-y-1/2 right-4">
|
103 |
<Tooltip>
|
104 |
<TooltipTrigger asChild>
|
105 |
<Button
|
components/chat/index.tsx
CHANGED
@@ -3,10 +3,11 @@
|
|
3 |
import { cn } from '@/lib/utils';
|
4 |
import { ChatList } from '@/components/chat/ChatList';
|
5 |
import { ChatPanel } from '@/components/chat/ChatPanel';
|
6 |
-
import { ChatScrollAnchor } from '@/components/chat/ChatScrollAnchor';
|
7 |
import { ChatEntity } from '@/lib/types';
|
8 |
import Image from 'next/image';
|
9 |
import useVisionAgent from '@/lib/hooks/useVisionAgent';
|
|
|
|
|
10 |
|
11 |
export interface ChatProps extends React.ComponentProps<'div'> {
|
12 |
chat: ChatEntity;
|
@@ -17,12 +18,16 @@ export function Chat({ chat }: ChatProps) {
|
|
17 |
const { messages, append, reload, stop, isLoading, input, setInput } =
|
18 |
useVisionAgent(chat);
|
19 |
|
|
|
|
|
|
|
|
|
20 |
return (
|
21 |
<>
|
22 |
-
<div className=
|
23 |
-
<div className="
|
24 |
<ChatList messages={messages} />
|
25 |
-
<
|
26 |
</div>
|
27 |
</div>
|
28 |
<ChatPanel
|
@@ -36,6 +41,10 @@ export function Chat({ chat }: ChatProps) {
|
|
36 |
input={input}
|
37 |
setInput={setInput}
|
38 |
/>
|
|
|
|
|
|
|
|
|
39 |
</>
|
40 |
);
|
41 |
}
|
|
|
3 |
import { cn } from '@/lib/utils';
|
4 |
import { ChatList } from '@/components/chat/ChatList';
|
5 |
import { ChatPanel } from '@/components/chat/ChatPanel';
|
|
|
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;
|
|
|
18 |
const { messages, append, reload, stop, isLoading, input, setInput } =
|
19 |
useVisionAgent(chat);
|
20 |
|
21 |
+
const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
|
22 |
+
useScrollAnchor();
|
23 |
+
console.log('[Ming] ~ Chat ~ isAtBottom:', isAtBottom);
|
24 |
+
|
25 |
return (
|
26 |
<>
|
27 |
+
<div className="h-full overflow-auto" ref={scrollRef}>
|
28 |
+
<div className="pb-[200px] pt-4 md:pt-10" ref={messagesRef}>
|
29 |
<ChatList messages={messages} />
|
30 |
+
<div className="h-px w-full" ref={visibilityRef} />
|
31 |
</div>
|
32 |
</div>
|
33 |
<ChatPanel
|
|
|
41 |
input={input}
|
42 |
setInput={setInput}
|
43 |
/>
|
44 |
+
<ButtonScrollToBottom
|
45 |
+
isAtBottom={isAtBottom}
|
46 |
+
scrollToBottom={scrollToBottom}
|
47 |
+
/>
|
48 |
</>
|
49 |
);
|
50 |
}
|
components/project/Chat.tsx
CHANGED
@@ -4,7 +4,6 @@ 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 { ChatScrollAnchor } from '../chat/ChatScrollAnchor';
|
8 |
import { ChatPanel } from '../chat/ChatPanel';
|
9 |
|
10 |
export interface ChatProps {
|
@@ -17,7 +16,6 @@ const Chat: React.FC<ChatProps> = ({ mediaList }) => {
|
|
17 |
return (
|
18 |
<>
|
19 |
<ChatList messages={messages} />
|
20 |
-
<ChatScrollAnchor trackVisibility={isLoading} />
|
21 |
<ChatPanel
|
22 |
isLoading={isLoading}
|
23 |
stop={stop}
|
|
|
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 {
|
|
|
16 |
return (
|
17 |
<>
|
18 |
<ChatList messages={messages} />
|
|
|
19 |
<ChatPanel
|
20 |
isLoading={isLoading}
|
21 |
stop={stop}
|
components/{chat → ui}/ButtonScrollToBottom.tsx
RENAMED
@@ -1,30 +1,32 @@
|
|
1 |
'use client';
|
2 |
|
3 |
-
import
|
4 |
|
5 |
import { cn } from '@/lib/utils';
|
6 |
-
import { useAtBottom } from '@/lib/hooks/useAtBottom';
|
7 |
import { Button, type ButtonProps } from '@/components/ui/Button';
|
8 |
import { IconArrowDown } from '@/components/ui/Icons';
|
9 |
|
10 |
-
|
11 |
-
|
|
|
|
|
12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
return (
|
14 |
<Button
|
15 |
variant="outline"
|
16 |
size="icon"
|
17 |
className={cn(
|
18 |
-
'
|
19 |
isAtBottom ? 'opacity-0' : 'opacity-100',
|
20 |
className,
|
21 |
)}
|
22 |
-
onClick={() =>
|
23 |
-
window.scrollTo({
|
24 |
-
top: document.body.offsetHeight,
|
25 |
-
behavior: 'smooth',
|
26 |
-
})
|
27 |
-
}
|
28 |
{...props}
|
29 |
>
|
30 |
<IconArrowDown />
|
|
|
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 />
|
components/ui/ImageLoader.tsx
DELETED
@@ -1,11 +0,0 @@
|
|
1 |
-
// import Image from 'next/image';
|
2 |
-
// import React from 'react';
|
3 |
-
|
4 |
-
// type ImageLoaderProps = typeof Image;
|
5 |
-
|
6 |
-
// const ImageLoader: React.FC<ImageLoaderProps> = props => {
|
7 |
-
// const { alt, width, height } = props;
|
8 |
-
// return <Image alt={alt} />;
|
9 |
-
// };
|
10 |
-
|
11 |
-
// export default ImageLoader;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/ui/Img.tsx
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use client';
|
2 |
+
|
3 |
+
import React from 'react';
|
4 |
+
import Image from 'next/image';
|
5 |
+
import { cn } from '@/lib/utils';
|
6 |
+
|
7 |
+
const placeholder =
|
8 |
+
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAgEASABIAAD/4QDWRXhpZgAATU0AKgAAAAgABwEGAAMAAAABAAIAAAESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAcgAAAAAAAABIAAAAAQAAAEgAAAABAAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAAAQAAADKgAwAEAAAAAQAAADKkBgADAAAAAQAAAAAAAAAAAAD/wAARCAAyADIDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9sAQwABAQEBAQECAQECAwICAgMEAwMDAwQGBAQEBAQGBwYGBgYGBgcHBwcHBwcHCAgICAgICQkJCQkLCwsLCwsLCwsL/9sAQwECAgIDAwMFAwMFCwgGCAsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsL/90ABAAE/9oADAMBAAIRAxEAPwD+nbSfDst/NnB5Ne5+G/htJMqsVOD610fw/wDCS3Lq7L3r6y0Lw7b20SgqMiluB4hpvwti2Asn51fufhVbsmQlfSCQQQjHFOIt24OKLAfEXiL4XtCjMiGvnrxH4SlsJG+UjFfqZf6Va3UZUgV84/ETwTGY3kRaVrbAfAp058//AK6T+zn/AM5/wr1uTw0BIwx3P+elM/4Roen+fyo5gP/Q/tp+G1vGIVJFer6n4lstGizM4XFeH/DzVUW3HPavmL9pX4sXvhmKV0Yqq5qbgfXeq/GjRLJipmH51yMn7Qvh2NtrTL+dfzYfGL9uqTw1PLGZjkE96+M7j/gotqtzf+WkrYz60rsD+1bwt8YtD12ZYYZlJPvXY+KlivdOMy8giv5q/wBjD9prxB8QNcgXezKxHOa/obsdZa48KRPMTuKiqA8dm09PNbjuaj/s9PSp5rtDM5B7nvUf2tfX9f8A69QB/9H+v3wLrW1RHuxmvJv2iPh43jLRZhEuSynpWN4X8Rm0lCs2BnivoKy1iy1W1ENyQcjFQB/J7+0x+x34yvtVmlsY3OScYBr418KfsLfES81xFmgkALDPBr+1rXPhR4d15jJJEj59qwtP+BXhiznE4t0BHsKEugH54fsJfsqS/Dqzt7m/TDgAnIr9j9Q1BNN0kW4OAq1yun2mk+G7fZAFG0dq868Y+LwyMit+FFtAEl1tfNb5x1NR/wBtr/fH+fwrxptbnLE5703+2p6QH//S/pMs+JjivYfDjv5Q5NePWn+uNev+HP8AVCkwPWdPdtvU1fnkk2n5j09aztP+7V6f7p+lLoBxeuu4jOCa8N1xmMjEnNe4a7/qzXh2t/6xqfQDlqKKKgD/2Q==';
|
9 |
+
|
10 |
+
// const Props = Omit<React.ComponentPropsWithoutRef<typeof Image>, 'alt'>;
|
11 |
+
const Img = React.forwardRef<
|
12 |
+
React.ElementRef<typeof Image>,
|
13 |
+
Omit<React.ComponentPropsWithoutRef<typeof Image>, 'alt'>
|
14 |
+
>(({ src, onLoad, width, height, className, ...props }, ref) => {
|
15 |
+
const [dimensions, setDimensions] = React.useState({
|
16 |
+
width: width ?? 200,
|
17 |
+
height: height ?? 200,
|
18 |
+
});
|
19 |
+
// const [isLoading, setIsLoading] = React.useState(true);
|
20 |
+
const [_, startTransition] = React.useTransition();
|
21 |
+
return (
|
22 |
+
<Image
|
23 |
+
src={src}
|
24 |
+
placeholder={placeholder}
|
25 |
+
width={dimensions.width}
|
26 |
+
height={dimensions.height}
|
27 |
+
alt="image"
|
28 |
+
ref={ref}
|
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);
|
39 |
+
}}
|
40 |
+
{...props}
|
41 |
+
/>
|
42 |
+
);
|
43 |
+
});
|
44 |
+
|
45 |
+
Img.displayName = Image.displayName;
|
46 |
+
|
47 |
+
export default Img;
|
lib/hooks/useAtBottom.tsx
DELETED
@@ -1,23 +0,0 @@
|
|
1 |
-
import * as React from 'react';
|
2 |
-
|
3 |
-
export function useAtBottom(offset = 0) {
|
4 |
-
const [isAtBottom, setIsAtBottom] = React.useState(false);
|
5 |
-
|
6 |
-
React.useEffect(() => {
|
7 |
-
const handleScroll = () => {
|
8 |
-
setIsAtBottom(
|
9 |
-
window.innerHeight + window.scrollY >=
|
10 |
-
document.body.offsetHeight - offset,
|
11 |
-
);
|
12 |
-
};
|
13 |
-
|
14 |
-
window.addEventListener('scroll', handleScroll, { passive: true });
|
15 |
-
handleScroll();
|
16 |
-
|
17 |
-
return () => {
|
18 |
-
window.removeEventListener('scroll', handleScroll);
|
19 |
-
};
|
20 |
-
}, [offset]);
|
21 |
-
|
22 |
-
return isAtBottom;
|
23 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lib/hooks/useScrollAnchor.tsx
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
2 |
+
|
3 |
+
export const useScrollAnchor = () => {
|
4 |
+
const messagesRef = useRef<HTMLDivElement>(null);
|
5 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
6 |
+
const visibilityRef = useRef<HTMLDivElement>(null);
|
7 |
+
|
8 |
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
9 |
+
const [isVisible, setIsVisible] = useState(false);
|
10 |
+
|
11 |
+
const scrollToBottom = useCallback(() => {
|
12 |
+
if (messagesRef.current) {
|
13 |
+
messagesRef.current.scrollIntoView({
|
14 |
+
block: 'end',
|
15 |
+
behavior: 'smooth',
|
16 |
+
});
|
17 |
+
}
|
18 |
+
}, []);
|
19 |
+
|
20 |
+
useEffect(() => {
|
21 |
+
if (messagesRef.current) {
|
22 |
+
if (isAtBottom && !isVisible) {
|
23 |
+
messagesRef.current.scrollIntoView({
|
24 |
+
block: 'end',
|
25 |
+
});
|
26 |
+
}
|
27 |
+
}
|
28 |
+
}, [isAtBottom, isVisible]);
|
29 |
+
|
30 |
+
useEffect(() => {
|
31 |
+
const { current } = scrollRef;
|
32 |
+
|
33 |
+
if (current) {
|
34 |
+
const handleScroll = (event: Event) => {
|
35 |
+
const target = event.target as HTMLDivElement;
|
36 |
+
const offset = 25;
|
37 |
+
const isAtBottom =
|
38 |
+
target.scrollTop + target.clientHeight >=
|
39 |
+
target.scrollHeight - offset;
|
40 |
+
|
41 |
+
setIsAtBottom(isAtBottom);
|
42 |
+
};
|
43 |
+
|
44 |
+
current.addEventListener('scroll', handleScroll, {
|
45 |
+
passive: true,
|
46 |
+
});
|
47 |
+
|
48 |
+
return () => {
|
49 |
+
current.removeEventListener('scroll', handleScroll);
|
50 |
+
};
|
51 |
+
}
|
52 |
+
}, []);
|
53 |
+
|
54 |
+
useEffect(() => {
|
55 |
+
if (visibilityRef.current) {
|
56 |
+
let observer = new IntersectionObserver(
|
57 |
+
entries => {
|
58 |
+
entries.forEach(entry => {
|
59 |
+
if (entry.isIntersecting) {
|
60 |
+
setIsVisible(true);
|
61 |
+
} else {
|
62 |
+
setIsVisible(false);
|
63 |
+
}
|
64 |
+
});
|
65 |
+
},
|
66 |
+
{
|
67 |
+
rootMargin: '0px 0px -150px 0px',
|
68 |
+
},
|
69 |
+
);
|
70 |
+
|
71 |
+
observer.observe(visibilityRef.current);
|
72 |
+
|
73 |
+
return () => {
|
74 |
+
observer.disconnect();
|
75 |
+
};
|
76 |
+
}
|
77 |
+
});
|
78 |
+
|
79 |
+
return {
|
80 |
+
messagesRef,
|
81 |
+
scrollRef,
|
82 |
+
visibilityRef,
|
83 |
+
scrollToBottom,
|
84 |
+
isAtBottom,
|
85 |
+
isVisible,
|
86 |
+
};
|
87 |
+
};
|