Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useState, useRef } from 'react'
import ChatHeader from './chat-header'
import ChatInput from './chat-input'
import ChatMessage from './chat-message'
import { useChat } from '@ai-sdk/react'
import { useChat, Message } from '@ai-sdk/react'
import { Sparkles } from 'lucide-react'
import TypingLoadingAnimation from './typing-loading-animation'

const initialSuggestions = [
'가장 많이 팔린 상품 5개 보여줘',
Expand All @@ -18,7 +19,7 @@ const initialSuggestions = [
*/
export default function AiChatPanel(): React.JSX.Element {
const [searchTerm, setSearchTerm] = useState('')
const { messages, input, handleInputChange, handleSubmit, setInput, isLoading } = useChat({
const { messages, input, handleInputChange, setInput, isLoading, append } = useChat({
api: '/api/chat',
streamProtocol: 'text', // TODO: AI 팀에서 받아올 때는 data로 변경해야함
initialMessages: [
Expand All @@ -32,18 +33,46 @@ export default function AiChatPanel(): React.JSX.Element {
})
const textareaRef = useRef<HTMLTextAreaElement>(null)

const [tempMessages, setTempMessages] = useState<Message[]>([])
const [showTyping, setShowTyping] = useState(false)

const handleSuggestionClick = (suggestion: string): void => {
setInput(suggestion)
textareaRef.current?.focus()
}

const handleSubmitCustom = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!input.trim()) return

const userMessage: Message = {
id: `${Date.now()}`,
role: 'user',
content: input.trim()
}

setTempMessages((prev) => [...prev, userMessage])
setInput('')
setShowTyping(true)

// TODO: 추후 AI 응답의 실제 완료 시점을 기반으로 로딩 애니메이션을 종료하도록 수정 필요
setTimeout(() => {
append({
role: 'user',
content: userMessage.content
})
setTempMessages([])
setShowTyping(false)
}, 5000)
}

console.log(messages)

return (
<div className="flex-1 h-full bg-neutral-800 outline-1 outline-offset-[-1px] outline-neutral-700 flex flex-col">
<ChatHeader onSearchChange={setSearchTerm} />
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-6">
{messages.map((m, index) => (
{[...messages, ...tempMessages].map((m, index) => (
<div key={m.id}>
<ChatMessage message={m} highlightTerm={searchTerm} />
{m.role === 'system' && index === 0 && (
Expand All @@ -64,13 +93,15 @@ export default function AiChatPanel(): React.JSX.Element {
)}
</div>
))}
{showTyping && <TypingLoadingAnimation className="h-12" />}
</div>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmitCustom}>
<ChatInput
ref={textareaRef}
value={input}
onChange={handleInputChange}
isLoading={isLoading}
isLoading={isLoading || showTyping}
disabled={isLoading || showTyping}
Comment on lines +103 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isLoadingdisabled 를 둘 다 넘겨주는 이유가 있을까요?
chat-input에서 isLoading은 사용되지 않게 변경되고,
disabled가 기존 isLoading의 역할을 대체하는 식으로 작성되어 있어서
둘 중 하나를 삭제하고 props도 변경하는 것이 어떨까요?

추후 애니메이션 사용에 대한 확장성을 위한 것이라면
chat-input 파일에서 이에 대한 주석을 작성해두는 것도 좋을 것 같아요!

/>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface ChatInputProps {
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
isLoading: boolean
disabled: boolean
}

/**
Expand All @@ -13,7 +14,7 @@ interface ChatInputProps {
* @returns JSX.Element
*/
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(function ChatInput(
{ value, onChange, isLoading },
{ value, onChange, disabled },
ref
) {
useEffect(() => {
Expand All @@ -25,7 +26,7 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(function ChatI
}, [value, ref])

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === 'Enter' && !e.shiftKey && !isLoading) {
if (e.key === 'Enter' && !e.shiftKey && !disabled) {
e.preventDefault()
// The form submission is handled by the parent form's onSubmit
e.currentTarget.form?.requestSubmit()
Expand All @@ -40,10 +41,10 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(function ChatI
value={value}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={isLoading ? 'AI가 답변을 생성중입니다...' : '무엇이든 물어보세요!'}
placeholder={disabled ? 'AI가 답변을 생성중입니다...' : '무엇이든 물어보세요!'}
className="self-stretch bg-transparent text-neutral-200 text-xs font-medium font-['Pretendard'] leading-[14px] placeholder:text-zinc-500 focus:outline-none resize-none max-h-[44px]"
rows={1}
disabled={isLoading}
disabled={disabled}
/>
<div className="self-stretch inline-flex justify-between items-end">
<div className="flex justify-start items-center gap-[5px]">
Expand All @@ -55,7 +56,7 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(function ChatI
</div>
<button
type="submit"
disabled={!value.trim() || isLoading}
disabled={!value.trim() || disabled}
className="px-3 py-1.5 bg-gradient-to-b from-neutral-700 to-zinc-800 rounded-lg outline-1 outline-offset-[-1px] outline-white/20 flex justify-center items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:cursor-pointer"
>
<div className="py-0.75 justify-start text-neutral-200 text-xs font-semibold font-['Pretendard'] leading-none">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { cn } from '@/lib/utils'

/**
* @author hyynjju
* @summary AI 응답 대기 중 표시되는 타이핑 애니메이션
* @param className 추가 CSS 클래스
* @returns JSX.Element
*/
export default function TypingLoadingAnimation({
className
}: {
className?: string
}): React.JSX.Element {
return (
<div className={cn('flex items-end h-12', className)}>
<div className={cn('flex items-center gap-1.5')}>
<div
className={cn('rounded-full bg-neutral-400 w-2 h-2')}
style={{
animation: 'typingBounce 1.8s infinite ease-in-out',
animationDelay: '0s'
}}
/>
<div
className={cn('rounded-full bg-neutral-400 w-2 h-2')}
style={{
animation: 'typingBounce 1.8s infinite ease-in-out',
animationDelay: '0.2s'
}}
/>
<div
className={cn('rounded-full bg-neutral-400 w-2 h-2')}
style={{
animation: 'typingBounce 1.8s infinite ease-in-out',
animationDelay: '0.4s'
}}
/>
</div>

{/* 키프레임 애니메이션 */}
<style>{`
@keyframes typingBounce {
0%, 70%, 100% {
transform: translateY(0) scale(0.9);
opacity: 0.3;
}
25% {
transform: translateY(-8px) scale(1.1);
opacity: 1;
}
}
`}</style>
</div>
)
}