diff --git a/src/app/@modal/(.)chat/_components/ChatModal.tsx b/src/app/@modal/(.)assistant/_components/ChatModal.tsx similarity index 74% rename from src/app/@modal/(.)chat/_components/ChatModal.tsx rename to src/app/@modal/(.)assistant/_components/ChatModal.tsx index d5973b1..088911b 100644 --- a/src/app/@modal/(.)chat/_components/ChatModal.tsx +++ b/src/app/@modal/(.)assistant/_components/ChatModal.tsx @@ -10,21 +10,21 @@ type DBMessage = { } interface ChatModalProps { - sessionId: string - initialMessages: DBMessage[] + sessionId?: string + // initialMessages: DBMessage[] } -export default function ChatModal({ sessionId, initialMessages }: ChatModalProps) { +export default function ChatModal({ sessionId }: ChatModalProps) { const router = useRouter() return ( router.back()}> - AI Recruiter + Talk To My AI-Assistant
- +
diff --git a/src/app/@modal/(.)chat/page.tsx b/src/app/@modal/(.)assistant/page.tsx similarity index 71% rename from src/app/@modal/(.)chat/page.tsx rename to src/app/@modal/(.)assistant/page.tsx index 00a66b8..8c9d29d 100644 --- a/src/app/@modal/(.)chat/page.tsx +++ b/src/app/@modal/(.)assistant/page.tsx @@ -3,11 +3,11 @@ import { Skeleton } from '~/components/ui/skeleton'; import ChatModal from './_components/ChatModal' import { trpc } from '~/app/_trpc/Client' -export default function ChatModalPage() { +export default function AssistantModalPage() { const { data: session, error, isLoading } = trpc.chat.getSession.useQuery(); return ( <> - {session && } + && {error &&
{error.message}
} {isLoading && } diff --git a/src/app/_components/Animated/AnimateIn.tsx b/src/app/_components/Animated/AnimateIn.tsx index aaa9c07..054f2ff 100644 --- a/src/app/_components/Animated/AnimateIn.tsx +++ b/src/app/_components/Animated/AnimateIn.tsx @@ -7,32 +7,58 @@ import { cn } from "~/lib/utils"; const AnimateTextIn = ({ children, animation = "type", - position, + position = 0, + tlId = undefined, + speed = 1, + scrollOnly = false, className }: { children: ReactNode, animation?: "type" | "slide", - position: gsap.Position, - className?:HTMLAttributes['className'] + position?: gsap.Position, + tlId?: string, + scrollOnly?: boolean, + speed?: number, + className?: HTMLAttributes['className'] }) => { const el = useRef(null) const gsapContext = useGsapContext(); useGSAP(() => { const rect = el.current?.getBoundingClientRect() - const isInView = rect && rect.top < window.innerHeight + const scroller = gsapContext?.getScroller() + console.log(scroller) + let viewportTop = 0 + let viewportBottom = window.innerHeight + if (scroller && scroller instanceof Element) { + const scrollerRect = scroller.getBoundingClientRect() + viewportTop = scrollerRect.top + viewportBottom = scrollerRect.top + scrollerRect.height + } + const isInView = rect && rect.bottom > viewportTop && rect.top < viewportBottom + console.log(isInView) const chars = new SplitText(el.current, { type: 'chars' }) - gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0) + gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0, tlId) const fromVars = animation === "slide" - ? { opacity: 0, x: -10, duration: 0.2, stagger: { each: 0.08 }, ease: 'bounce.inOut', onComplete: () => chars.revert() } - : { opacity: 0, duration: 0.01, stagger: { each: 0.04 }, ease: 'bounce.inOut', onComplete: () => chars.revert() } - if (isInView) { - gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position) + ? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() } + : { opacity: 0, duration: 0.01 * speed, stagger: { each: 0.04 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() } + if (isInView && !scrollOnly) { + gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position, tlId) } else { - gsap.from(chars.chars, { ...fromVars, scrollTrigger: { trigger: el.current, start: 'top 85%', scroller: gsapContext?.getScroller() } }) + gsap.from(chars.chars, + { + ...fromVars, + scrollTrigger: { + trigger: el.current, + start: 'top bottom', + end: 'bottom top', + toggleActions: "play reverse play reverse", + scroller + } + }) } }, { dependencies: [] }) return ( -
+
{children}
) diff --git a/src/app/_components/ChatFAB.tsx b/src/app/_components/ChatFAB.tsx index f99d738..290c502 100644 --- a/src/app/_components/ChatFAB.tsx +++ b/src/app/_components/ChatFAB.tsx @@ -3,17 +3,23 @@ import Link from 'next/link' import { MessageCircle } from 'lucide-react' import { Show } from '@clerk/nextjs' import { Button } from '~/components/ui/button' - +import { usePathname } from 'next/navigation' export default function ChatFAB() { + const pathName = usePathname() + const isChat = pathName.indexOf('\/chat') > -1 return ( - -
- -
-
+ <> + {!isChat && + +
+ +
+
+ } + ) } diff --git a/src/app/_components/TopNav.tsx b/src/app/_components/TopNav.tsx index 7ae1b1e..9890f18 100644 --- a/src/app/_components/TopNav.tsx +++ b/src/app/_components/TopNav.tsx @@ -24,7 +24,7 @@ export default function TopNav() {
diff --git a/src/app/_providers/GsapProvicer.tsx b/src/app/_providers/GsapProvicer.tsx index be99e46..467c198 100644 --- a/src/app/_providers/GsapProvicer.tsx +++ b/src/app/_providers/GsapProvicer.tsx @@ -11,10 +11,11 @@ gsap.registerPlugin(SplitText) const GsapContext = createContext<{ addAnimation: ( animation: gsap.core.TimelineChild, - position: gsap.Position + position: gsap.Position, + tlId?:string ) => void, - resetTimeline: () => void, - resumeTimeline: () => void, + resetTimeline: (tlId?:string) => void, + resumeTimeline: (tlId?:string) => void, getScroller: () => Element | Window | null } | null>(null) @@ -22,59 +23,103 @@ export function useGsapContext() { return useContext(GsapContext) } -export const useTimeLine = (dep?:any,all?:boolean) => { +export const useTimeLine = (dep?:any,all?:boolean,tlId?:string,) => { + console.log(tlId) const gsapContext = useGsapContext() useEffect(() => { if (dep instanceof Array && all) { let acc = true; let allDepsSatisfied = dep.reduce((p,c) => c !== undefined && p ,acc ) if (allDepsSatisfied) { - gsapContext?.resumeTimeline() + gsapContext?.resumeTimeline(tlId) } } else { if (dep) { - gsapContext?.resumeTimeline() + gsapContext?.resumeTimeline(tlId) } } },[dep]) useLayoutEffect(() => { return () => { - gsapContext?.resetTimeline() + gsapContext?.resetTimeline(tlId) } },[]) } -export default function GsapProvider({ children }: { children: ReactNode }) { +export default function GsapProvider({ children,timelines }: { children: ReactNode,timelines?:string[] }) { + const timeLines = useRef|null>(null) const tl = useRef(null) const scrollerRef = useRef(null) const getScroller = useCallback(() => { - const cached = scrollerRef.current - if (!cached || (cached instanceof Element && !document.contains(cached))) { - scrollerRef.current = document.querySelector('[data-slot="scroll-area-viewport"]') ?? window - } + // const cached = scrollerRef.current + // if (!cached || (cached instanceof Element && !document.contains(cached))) { + let scrollers = document.querySelectorAll('[data-slot="scroll-area-viewport"]') + if (scrollers.length < 1) { + scrollerRef.current = window + } else { + let scrollerArray = Array.from(scrollers.values()).sort((a,b) => { + const s1 = a as HTMLDivElement; + const s2 = b as HTMLDivElement; + // using bitwise not (~~) to coerce NaN values to 0 + const aPriority = ~~Number(s1.dataset?.scrollerPriority) + const bPriority = ~~Number(s2.dataset?.scrollerPriority) + return aPriority - bPriority; + }) + let prioScroller = scrollerArray.pop(); + scrollerRef.current = prioScroller || window; + } + + // } return scrollerRef.current }, []) useGSAP(() => { + if (!timeLines.current && timelines) { + timeLines.current = new Map() + } + timelines?.forEach((tlId) => { + timeLines.current?.set(tlId,gsap.timeline({id:tlId,paused:true})) + }) if (!tl.current) { tl.current = gsap.timeline({ paused: true }) } return () => { console.log("gsap cleanup") } }) - const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => { + const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position,tlId?:string) => { + if(tlId) { + const selectedTimeLine = timeLines.current?.get(tlId) + console.log("add animation to:", position, selectedTimeLine !== undefined) + selectedTimeLine?.add(animation,position) + return; + } console.log("add animation to:", position, tl.current !== undefined) tl.current?.add(animation, position); },[]) - const resetTimeline = useCallback(() => { - console.log('resetting timeline') - tl.current?.kill() - tl.current?.revert() - ScrollTrigger.getAll().forEach(st => st.kill()) - tl.current = gsap.timeline({paused:true}) + const resetTimeline = useCallback((tlId?:string) => { + console.log("resetting timeline",tlId) + if (tlId) { + let selectedTimeLine = timeLines.current?.get(tlId) + selectedTimeLine?.kill() + selectedTimeLine?.revert() + timeLines.current?.set(tlId,gsap.timeline({id:tlId,paused:true})) + } else { + tl.current?.kill() + tl.current?.revert() + tl.current = gsap.timeline({paused:true}) + } + ScrollTrigger.getAll().forEach(st => { + st.kill() + }) },[]) - const resumeTimeline = useCallback(() => { - console.log("resuming timeline:",tl.current) - tl.current?.resume() + const resumeTimeline = useCallback((tlId?:string) => { + if (tlId) { + console.log("trying to resume timeline",tlId) + let selectedTimeLine = timeLines.current?.get(tlId) + selectedTimeLine?.resume() + } else { + console.log("resuming default timeline") + tl.current?.resume() + } },[]) return ( diff --git a/src/app/assistant/page.tsx b/src/app/assistant/page.tsx new file mode 100644 index 0000000..5caf40a --- /dev/null +++ b/src/app/assistant/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function AssistantPage() { + redirect('/chat') +} diff --git a/src/app/chat/_components/AssistantMessage.tsx b/src/app/chat/_components/AssistantMessage.tsx index 98d10ec..9c24c68 100644 --- a/src/app/chat/_components/AssistantMessage.tsx +++ b/src/app/chat/_components/AssistantMessage.tsx @@ -16,7 +16,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => { {message.parts.map((part, i) => { if (part.type === 'text') { return ( - + {part.text} ) diff --git a/src/app/chat/_components/ChatInterface.tsx b/src/app/chat/_components/ChatInterface.tsx index 3d8b133..4248f7e 100644 --- a/src/app/chat/_components/ChatInterface.tsx +++ b/src/app/chat/_components/ChatInterface.tsx @@ -1,23 +1,25 @@ 'use client' -import { useRef, useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { useChat } from '@ai-sdk/react' import { DefaultChatTransport, type UIMessage } from 'ai' import { Button } from '~/components/ui/button' import { Textarea } from '~/components/ui/textarea' -import { cn } from '~/lib/utils' -import Markdown from 'react-markdown'; -import { AssistantMessage } from './AssistantMessage'; -import { UserMessage } from './UserMessage'; - -type DBMessage = { +import { + useGsapContext, +} from '~/app/_providers/GsapProvicer'; +import Messages from './Messages' +import { DeleteIcon } from 'lucide-react'; +import { trpc } from '~/app/_trpc/Client' +import { Spinner } from '~/components/ui/spinner'; +interface DBMessage { id: string role: 'user' | 'assistant' content: string } interface ChatInterfaceProps { - sessionId: string - initialMessages: DBMessage[] + sessionId?: string + // initialMessages: DBMessage[] } function toUIMessages(dbMessages: DBMessage[]): UIMessage[] { @@ -27,60 +29,80 @@ function toUIMessages(dbMessages: DBMessage[]): UIMessage[] { parts: [{ type: 'text' as const, text: m.content }], })) } - -export default function ChatInterface({ sessionId, initialMessages }: ChatInterfaceProps) { - const [input, setInput] = useState('') - const messagesEndRef = useRef(null) - - const { messages, sendMessage, status, error, clearError } = useChat({ - transport: new DefaultChatTransport({ - api: '/api/chat', - body: { sessionId }, - }), - messages: toUIMessages(initialMessages), - }) - - const isLoading = status === 'submitted' || status === 'streaming' - const hasError = status === 'error' - +export default function ChatInterface({ sessionId }: ChatInterfaceProps) { + const utils = trpc.useUtils(); + const { data: dbMessages, refetch: refetchMessages } = trpc.chat.getMessages.useQuery(sessionId ? sessionId : "") + const [messages, setMessages] = useState([]); + function addMessage(newMessage: UIMessage) { + setMessages(prev => [...prev, newMessage]); + } useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + setMessages(toUIMessages(dbMessages ?? [])); + }, [dbMessages]); + if (messages.at(0)?.id != 'init') { + messages.unshift({ + id: "init", + role: 'assistant', + parts: [{ + type: 'text', + text: "Hi im gregors ai assistant,you can ask me to provide general information or to schedule a meeting." + }], + }) + } + const [input, setInput] = useState('') + const { sendMessage, status, error, clearError } = useChat({ + transport: new DefaultChatTransport({ + api: '/api/chat', body: { sessionId }, + }), + messages: messages, + }) const handleSend = () => { const text = input.trim() - if (!text || isLoading) return + if (!text || status != 'ready') return setInput('') sendMessage({ text }) + addMessage({ + id: "", role: "user", parts: [ + { type: 'text', text } + ] + }) + addMessage({ + id: "", role: "assistant", parts: [ + { type: 'text', text: "Thinking..." } + ] + }) } - + const clearChatMutation = trpc.chat.clearChat.useMutation() + const handleClear = () => { + clearChatMutation.mutate(undefined, { + onSuccess: () => { + utils.chat.getMessages.invalidate() + refetchMessages() + } + }) + } + const gsapContext = useGsapContext() + useEffect(() => { + console.log(status) + if (status == 'ready') { + utils.chat.getMessages.invalidate(); + refetchMessages() + } + }, [status]) + useEffect(() => { + let scroller = gsapContext?.getScroller() + if (scroller instanceof Window) { + return; + } + console.log(scroller?.scrollHeight) + scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight }) + }, [messages]) return (
-
- {messages.length === 0 && ( -
-

Hi! I'm Gregor's AI recruiter assistant.

-

Ask me about his skills and experience, or schedule a meeting!

-
- )} - - {messages.map((message) => ( - <> - {message.role == 'assistant' && } - {message.role == 'user' && } - - ))} - - {isLoading && ( -
-
- Thinking… -
-
- )} - -
-
+ {messages && + + } {error && (
@@ -89,19 +111,20 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf ? 'OpenAI quota exceeded. Please try again later.' : `Error: ${error.message}`} - + +
)} -
+