Skip to content

Commit 180cfcf

Browse files
feat: image generation (#62)
* ui for image generation * added intl * updated prisma schema * added image generation route * updated UI * shared fixes * added fixed for input
1 parent 2bb98b3 commit 180cfcf

File tree

20 files changed

+387
-97
lines changed

20 files changed

+387
-97
lines changed

actions/chat/get-chat-conversations.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export const getChatConversations = async ({ sharedConversationId }: GetChatConv
2323
include: {
2424
conversation: {
2525
include: {
26-
messages: true,
26+
messages: {
27+
orderBy: { createdAt: 'asc' },
28+
include: { imageGeneration: true },
29+
},
2730
shared: true,
2831
user: true,
2932
},
@@ -66,7 +69,7 @@ export const getChatConversations = async ({ sharedConversationId }: GetChatConv
6669
user: true,
6770
messages: {
6871
orderBy: { createdAt: 'asc' },
69-
include: { feedback: true },
72+
include: { feedback: true, imageGeneration: true },
7073
},
7174
},
7275
});

actions/uploadthing/upload-files.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use server';
2+
3+
import { UTFile } from 'uploadthing/server';
4+
5+
import { base64ToBlob } from '@/lib/utils';
6+
import { utapi } from '@/server/uploadthing';
7+
8+
export const uploadFiles = async (
9+
files: { name: string; contentType: string; base64: string }[],
10+
) => {
11+
const UTFiles = files.map(({ name, contentType, base64 }) => {
12+
const file = new UTFile([base64ToBlob(base64, contentType)], name);
13+
14+
return file;
15+
});
16+
17+
const response = await utapi.uploadFiles(UTFiles);
18+
19+
return response;
20+
};

app/(chat)/(routes)/chat/[[...slug]]/_components/chat-main/chat-body.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { ArrowDown } from 'lucide-react';
3+
import { ArrowDown, LoaderPinwheel } from 'lucide-react';
44
import { useTranslations } from 'next-intl';
55
import React, {
66
createContext,
@@ -53,6 +53,7 @@ const Content = ({ children }: ContentProps) => {
5353
};
5454

5555
type ChatBodyProps = {
56+
assistantImage?: string;
5657
assistantMessage?: string;
5758
introMessages: string[];
5859
isShared?: boolean;
@@ -67,6 +68,7 @@ type ChatBodyProps = {
6768
};
6869

6970
export const ChatBody = ({
71+
assistantImage,
7072
assistantMessage,
7173
introMessages,
7274
isShared,
@@ -77,9 +79,10 @@ export const ChatBody = ({
7779
const t = useTranslations('chat.body');
7880

7981
const { user } = useCurrentUser();
80-
const { chatMessages, conversationId } = useChatStore((state) => ({
82+
const { chatMessages, conversationId, isImageGeneration } = useChatStore((state) => ({
8183
chatMessages: state.chatMessages,
8284
conversationId: state.conversationId,
85+
isImageGeneration: state.isImageGeneration,
8386
}));
8487

8588
const [sticky, setSticky] = useState(false);
@@ -138,16 +141,24 @@ export const ChatBody = ({
138141
</div>
139142
);
140143
})}
141-
{assistantMessage && (
142-
<div className="flex flex-1 text-base md:px-5 lg:px-1 xl:px-5 mx-auto gap-3 md:max-w-3xl lg:max-w-[40rem] xl:max-w-4xl px-4 first:mt-4 last:mb-6">
144+
145+
<div className="flex flex-1 text-base md:px-5 lg:px-1 xl:px-5 mx-auto gap-3 md:max-w-3xl lg:max-w-[40rem] xl:max-w-4xl px-4 first:mt-4 last:mb-6">
146+
{!assistantMessage && isSubmitting && isImageGeneration && (
147+
<div className="flex gap-x-2 items-center justify-center w-full mt-4">
148+
<LoaderPinwheel className="h-4 w-4 animate-spin text-muted-foreground" />
149+
<p className="text-sm text-muted-foreground">{t('image-loading')}</p>
150+
</div>
151+
)}
152+
{assistantMessage && (
143153
<ChatBubble
154+
streamImage={assistantImage}
144155
isSubmitting={isSubmitting}
145156
message={{ role: ChatCompletionRole.ASSISTANT, content: '' }}
146157
name="Nova Copilot"
147158
streamMessage={assistantMessage}
148159
/>
149-
</div>
150-
)}
160+
)}
161+
</div>
151162
</Content>
152163
</ScrollToBottom>
153164
)}

app/(chat)/(routes)/chat/[[...slug]]/_components/chat-main/chat-bubble.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client';
22

3-
import { MoreHorizontal } from 'lucide-react';
3+
import { Download, MoreHorizontal } from 'lucide-react';
4+
import Image from 'next/image';
5+
import Link from 'next/link';
46

57
import { CopyClipboard } from '@/components/common/copy-clipboard';
68
import { MarkdownText } from '@/components/common/markdown-text';
@@ -19,9 +21,15 @@ type ChatBubbleProps = {
1921
model?: string;
2022
id?: string;
2123
feedback?: { feedback: string } | null;
24+
imageGeneration?: {
25+
model: string;
26+
revisedPrompt: string;
27+
url: string;
28+
} | null;
2229
};
2330
name: string;
2431
picture?: string | null;
32+
streamImage?: string;
2533
streamMessage?: string;
2634
};
2735

@@ -31,10 +39,14 @@ export const ChatBubble = ({
3139
message,
3240
name,
3341
picture,
42+
streamImage,
3443
streamMessage,
3544
}: ChatBubbleProps) => {
3645
const isAssistant = message.role === ChatCompletionRole.ASSISTANT;
46+
3747
const text = streamMessage ?? message.content;
48+
const image = streamImage ?? message?.imageGeneration?.url;
49+
const model = message?.imageGeneration ? message.imageGeneration.model : message.model;
3850

3951
return (
4052
<div className="pb-4 pt-2">
@@ -49,18 +61,32 @@ export const ChatBubble = ({
4961
<div className="flex flex-col">
5062
<div className="flex items-center space-x-2">
5163
<span className="text-medium font-bold">{name}</span>
52-
{Boolean(isAssistant && streamMessage && isSubmitting) && (
64+
{Boolean(isAssistant && isSubmitting) && (
5365
<MoreHorizontal className="w-6 h-6 animate-pulse" />
5466
)}
5567
{isAssistant && isShared && (
56-
<div className="text-xs text-muted-foreground">{message.model}</div>
68+
<div className="text-xs text-muted-foreground">{model}</div>
5769
)}
5870
</div>
71+
{image && (
72+
<div className="relative aspect-w-16 aspect-h-14 border my-4">
73+
<Image alt="Image" fill src={image} className="rounded-sm" />
74+
</div>
75+
)}
5976
<MarkdownText text={text} />
6077
{isAssistant && !isSubmitting && (
61-
<div className="flex gap-x-3 mt-4">
78+
<div className="flex gap-x-3 mt-4 items-center">
6279
<CopyClipboard textToCopy={text} />
63-
<ChatFeedback messageId={message.id} state={message.feedback?.feedback} />
80+
{image && (
81+
<Link href={image} target="_blank">
82+
<button className="flex items-center">
83+
<Download className="h-4 w-4" />
84+
</button>
85+
</Link>
86+
)}
87+
{!isShared && (
88+
<ChatFeedback messageId={message.id} state={message.feedback?.feedback} />
89+
)}
6490
</div>
6591
)}
6692
</div>

app/(chat)/(routes)/chat/[[...slug]]/_components/chat-main/chat-feedback.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const ChatFeedback = ({ className, disabled, messageId, state }: ChatFeed
8282
<button onClick={handleClick} disabled={disabled || isFetching} name={Feedback.POSITIVE}>
8383
<ThumbsUp
8484
className={cn(
85-
'h-4 w-4 hover:text-green-500 transition-all duration-300',
85+
'h-4 w-4 hover:text-green-500 transition-colors duration-300',
8686
state === Feedback.POSITIVE && 'text-green-500',
8787
clicked[Feedback.POSITIVE] ? 'animate-spin-once' : '',
8888
className,
@@ -92,7 +92,7 @@ export const ChatFeedback = ({ className, disabled, messageId, state }: ChatFeed
9292
<button onClick={handleClick} disabled={disabled || isFetching} name={Feedback.NEGATIVE}>
9393
<ThumbsDown
9494
className={cn(
95-
'h-4 w-4 hover:text-red-500 transition-all duration-300',
95+
'h-4 w-4 hover:text-red-500 transition-color duration-300',
9696
state === Feedback.NEGATIVE && 'text-red-500',
9797
clicked[Feedback.NEGATIVE] ? 'animate-spin-once' : '',
9898
className,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use client';
2+
3+
import { ImageIcon, Paperclip, SendHorizonal, StopCircle } from 'lucide-react';
4+
import { useTranslations } from 'next-intl';
5+
6+
import { Badge, Button, Separator } from '@/components/ui';
7+
import { OPEN_AI_IMAGE_MODELS } from '@/constants/open-ai';
8+
import { useChatStore } from '@/hooks/store/use-chat-store';
9+
import { cn } from '@/lib/utils';
10+
11+
type ChatInputFooterProps = {
12+
isDisabled?: boolean;
13+
isSubmitting?: boolean;
14+
onSendMessage: () => void;
15+
};
16+
17+
export const ChatInputFooter = ({
18+
isDisabled,
19+
isSubmitting,
20+
onSendMessage,
21+
}: ChatInputFooterProps) => {
22+
const t = useTranslations('chat.input');
23+
24+
const { isImageGeneration, setIsImageGeneration } = useChatStore((state) => ({
25+
isImageGeneration: state.isImageGeneration,
26+
setIsImageGeneration: state.setIsImageGeneration,
27+
}));
28+
29+
return (
30+
<div className="flex bg-background justify-between px-2 py-2 items-center">
31+
<div className="text-xs text-muted-foreground flex items-center gap-x-2 pr-2">
32+
{isImageGeneration && (
33+
<Badge variant="secondary" className="rounded-sm px-1 font-normal line-clamp-2">
34+
{t('image-generation-mode', { model: OPEN_AI_IMAGE_MODELS[0].label })}
35+
</Badge>
36+
)}
37+
</div>
38+
<div className="flex items-center">
39+
<button
40+
type="button"
41+
className="mr-3"
42+
disabled={isSubmitting}
43+
onClick={() => setIsImageGeneration(!isImageGeneration)}
44+
>
45+
<ImageIcon
46+
className={cn(
47+
'w-4 h-4 text-muted-foreground transition-colors duration-300',
48+
isImageGeneration && 'text-purple-500',
49+
)}
50+
/>
51+
</button>
52+
<button type="button" disabled={isSubmitting}>
53+
<Paperclip className="w-4 h-4 text-muted-foreground" />
54+
</button>
55+
<Separator orientation="vertical" className="mr-4 ml-2 h-6" />
56+
<Button
57+
className={cn(
58+
'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white hover:text-white font-medium z-10 px-2 text-sm',
59+
isSubmitting && 'w-12',
60+
)}
61+
disabled={isDisabled || (isSubmitting && isImageGeneration)}
62+
type={isSubmitting ? 'button' : 'submit'}
63+
variant="outline"
64+
onClick={onSendMessage}
65+
>
66+
{isSubmitting && <StopCircle className="w-4 h-4 mx-2" />}
67+
{!isSubmitting && <SendHorizonal className="w-4 h-4 mx-2" />}
68+
</Button>
69+
</div>
70+
</div>
71+
);
72+
};

app/(chat)/(routes)/chat/[[...slug]]/_components/chat-main/chat-input.tsx

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
'use client';
22

3-
import { SendHorizonal, StopCircle } from 'lucide-react';
43
import { useTranslations } from 'next-intl';
54
import { SyntheticEvent, useState } from 'react';
65

7-
import { Button, Textarea } from '@/components/ui';
6+
import { Textarea } from '@/components/ui';
87
import { LIMIT_CHAT_INPUT } from '@/constants/chat';
8+
import { useChatStore } from '@/hooks/store/use-chat-store';
99
import { cn } from '@/lib/utils';
1010

11+
import { ChatInputFooter } from './chat-input-footer';
12+
1113
type ChatInputProps = {
1214
currenMessage: string;
1315
isSubmitting?: boolean;
@@ -25,6 +27,10 @@ export const ChatInput = ({
2527
}: ChatInputProps) => {
2628
const t = useTranslations('chat.input');
2729

30+
const { isImageGeneration } = useChatStore((state) => ({
31+
isImageGeneration: state.isImageGeneration,
32+
}));
33+
2834
const [inputLength, setInputLength] = useState(0);
2935

3036
return (
@@ -35,14 +41,16 @@ export const ChatInput = ({
3541
className={cn(
3642
'mx-auto flex flex-col lg:max-w-2xl xl:max-w-4xl w-full h-full border rounded-sm z-10 focus-within:border-b-indigo-500 focus-within:border-b-2 transition-colors duration-200 ease-in-out',
3743
inputLength >= LIMIT_CHAT_INPUT && 'focus-within:border-b-red-600',
44+
isImageGeneration &&
45+
'border-b-purple-500 border-b-2 focus-within:border-b-purple-500 ',
3846
)}
3947
onSubmit={onSubmit}
4048
>
4149
<Textarea
4250
className="resize-none overflow-auto z-10 border-none"
4351
disabled={isSubmitting}
4452
maxLength={LIMIT_CHAT_INPUT}
45-
placeholder={t('enterMessage')}
53+
placeholder={t(isImageGeneration ? 'enterImageMessage' : 'enterMessage')}
4654
value={currenMessage}
4755
onChange={(event) => {
4856
setCurrentMessage(event.target.value);
@@ -55,24 +63,11 @@ export const ChatInput = ({
5563
}
5664
}}
5765
/>
58-
<div className="flex bg-background justify-between px-2 pb-4 pt-2 items-end">
59-
<div className="text-xs text-muted-foreground">
60-
{inputLength} / {LIMIT_CHAT_INPUT}
61-
</div>
62-
<Button
63-
className={cn(
64-
'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white hover:text-white font-medium z-10 px-2 text-sm',
65-
isSubmitting && 'w-12',
66-
)}
67-
disabled={!currenMessage && !isSubmitting}
68-
type={isSubmitting ? 'button' : 'submit'}
69-
variant="outline"
70-
onClick={isSubmitting ? onAbortGenerating : () => {}}
71-
>
72-
{isSubmitting && <StopCircle className="w-4 h-4 mx-2" />}
73-
{!isSubmitting && <SendHorizonal className="w-4 h-4 mx-2" />}
74-
</Button>
75-
</div>
66+
<ChatInputFooter
67+
isDisabled={!currenMessage && !isSubmitting}
68+
isSubmitting={isSubmitting}
69+
onSendMessage={isSubmitting ? onAbortGenerating : () => {}}
70+
/>
7671
</form>
7772
<div className="p-2 text-center text-xs text-muted-foreground z-10">{t('footer')}</div>
7873
</div>

0 commit comments

Comments
 (0)