fix chat
This commit is contained in:
@@ -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 (
|
||||
<Dialog modal={true} open onOpenChange={() => router.back()}>
|
||||
<DialogContent className="w-full max-w-full rounded-none sm:max-w-full h-[100svh] lg:max-w-3xl lg:rounded-xl lg:h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-4 border-b shrink-0">
|
||||
<DialogTitle>AI Recruiter</DialogTitle>
|
||||
<DialogTitle>Talk To My AI-Assistant</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<ChatInterface sessionId={sessionId} initialMessages={initialMessages} />
|
||||
<ChatInterface sessionId={sessionId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -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 && <ChatModal sessionId={session.id} initialMessages={session.messages} />}
|
||||
&& <ChatModal sessionId={session?.id} />
|
||||
{error && <div>{error.message}</div>}
|
||||
{isLoading && <Skeleton />}
|
||||
</>
|
||||
@@ -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<HTMLDivElement>['className']
|
||||
position?: gsap.Position,
|
||||
tlId?: string,
|
||||
scrollOnly?: boolean,
|
||||
speed?: number,
|
||||
className?: HTMLAttributes<HTMLDivElement>['className']
|
||||
}) => {
|
||||
const el = useRef<HTMLDivElement>(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 (
|
||||
<div ref={el} className={cn(className,"opacity-0")}>
|
||||
<div ref={el} className={cn(className, "opacity-0")}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<Show when="signed-in">
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
|
||||
<Link href="/chat">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<>
|
||||
{!isChat &&
|
||||
<Show when="signed-in">
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
|
||||
<Link href="/assistant">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function TopNav() {
|
||||
</Button>
|
||||
<Show when="signed-in">
|
||||
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline">
|
||||
<a href="/chat"> Chat </a>
|
||||
<Link href="/chat"> Chat </Link>
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -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<Map<string,gsap.core.Timeline>|null>(null)
|
||||
const tl = useRef<gsap.core.Timeline | null>(null)
|
||||
const scrollerRef = useRef<Element | Window | null>(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 (
|
||||
<GsapContext.Provider value={{ addAnimation, resetTimeline, resumeTimeline, getScroller }}>
|
||||
|
||||
5
src/app/assistant/page.tsx
Normal file
5
src/app/assistant/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AssistantPage() {
|
||||
redirect('/chat')
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<Markdown>
|
||||
<Markdown key={i}>
|
||||
{part.text}
|
||||
</Markdown>
|
||||
)
|
||||
|
||||
@@ -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<HTMLDivElement>(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<UIMessage[]>([]);
|
||||
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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<p className="text-base font-medium mb-1">Hi! I'm Gregor's AI recruiter assistant.</p>
|
||||
<p className="text-sm">Ask me about his skills and experience, or schedule a meeting!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<>
|
||||
{message.role == 'assistant' && <AssistantMessage message={message}/>}
|
||||
{message.role == 'user' && <UserMessage message={message}/>}
|
||||
</>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-muted rounded-lg px-4 py-2 text-sm">
|
||||
<span className="animate-pulse">Thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{messages &&
|
||||
<Messages messages={messages} />
|
||||
}
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
@@ -89,19 +111,20 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
|
||||
? 'OpenAI quota exceeded. Please try again later.'
|
||||
: `Error: ${error.message}`}
|
||||
</span>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
variant='destructive'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-t flex gap-2">
|
||||
<div className="p-4 border-t flex flex-row gap-2">
|
||||
<Textarea
|
||||
name='message'
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask about Gregor's experience or schedule a meeting…"
|
||||
@@ -114,13 +137,24 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || hasError || !input.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={status != "ready" || !input.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleClear}
|
||||
disabled={status != "ready" || clearChatMutation.isPending}
|
||||
>
|
||||
{clearChatMutation.isPending ?
|
||||
<Spinner /> :
|
||||
"Clear Chat"
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
25
src/app/chat/_components/Messages.tsx
Normal file
25
src/app/chat/_components/Messages.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { type UIMessage } from 'ai'
|
||||
import * as Card from "~/components/ui/card"
|
||||
import AnimateTextIn from '~/app/_components/Animated/AnimateIn';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { ScrollArea } from '~/components/ui/scroll-area';
|
||||
import { useTimeLine } from '~/app/_providers/GsapProvicer';
|
||||
import {
|
||||
memo
|
||||
} from 'react';
|
||||
const Messages = memo(({ messages}: { messages: UIMessage[]}) => {
|
||||
return (
|
||||
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto">
|
||||
{messages.map((message, i) => (
|
||||
<Card.AnimatedCard scrollOnly={true} tlId='chat' position={i * 0.2} key={i}>
|
||||
<Card.CardContent>
|
||||
{message.role == 'assistant' && <AssistantMessage message={message} />}
|
||||
{message.role == 'user' && <UserMessage message={message} />}
|
||||
</Card.CardContent>
|
||||
</Card.AnimatedCard>
|
||||
))}
|
||||
</ScrollArea>)
|
||||
})
|
||||
|
||||
export default Messages;
|
||||
@@ -2,26 +2,22 @@
|
||||
import ChatInterface from './_components/ChatInterface'
|
||||
import { trpc } from '../_trpc/Client';
|
||||
import { Skeleton } from '~/components/ui/skeleton';
|
||||
import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
|
||||
import { useTimeLine } from '../_providers/GsapProvicer';
|
||||
|
||||
export default function ChatPage() {
|
||||
const { data: session, error, isLoading } = trpc.chat.getSession.useQuery();
|
||||
useTimeLine(session)
|
||||
return (
|
||||
<div className="container max-w-2xl mx-auto h-screen pt-10 pb-4 flex flex-col">
|
||||
<div className="flex flex-col flex-1 bg-background border rounded-lg overflow-hidden">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">AI Recruiter</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chat with Gregor's AI assistant
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{session && <ChatInterface sessionId={session?.id} initialMessages={session?.messages} /> }
|
||||
<div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||
<AnimatedPageTitle position={0}>
|
||||
<span>Talk To My </span> <span> AI-Assistant</span>
|
||||
</AnimatedPageTitle>
|
||||
<div className='flex items-center h-[80%] my-auto w-full'>
|
||||
<ChatInterface sessionId={session?.id} />
|
||||
{error && <div>{error.message}</div>}
|
||||
{isLoading && <Skeleton/>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<TrpcProvider>
|
||||
<GsapProvider>
|
||||
<GsapProvider timelines={['chat']}>
|
||||
<html lang="en" className={cn(geist.variable, "font-sans", inter.variable)} suppressHydrationWarning>
|
||||
<head>
|
||||
<CodeHighlightStyle />
|
||||
@@ -47,7 +47,7 @@ export default async function RootLayout({
|
||||
<ThemeProvider>
|
||||
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
|
||||
<TopNav />
|
||||
<main className="absolute lg:top-10 h-screen w-screen">
|
||||
<main className="absolute lg:top-10 h-screen lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
|
||||
{children}
|
||||
</main>
|
||||
{modal}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useGSAP } from "@gsap/react";import * as React from "react"
|
||||
import { useGSAP } from "@gsap/react"; import * as React from "react"
|
||||
import { useRef } from "react";
|
||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||
import gsap from 'gsap'
|
||||
@@ -26,20 +26,40 @@ function AnimatedCard({
|
||||
className,
|
||||
position = 0,
|
||||
size = "default",
|
||||
tlId = undefined,
|
||||
scrollOnly = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position }) {
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position, tlId?: string, scrollOnly?: boolean }) {
|
||||
const gsapContext = useGsapContext()
|
||||
const ref = useRef<HTMLDivElement|null>(null)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
useGSAP(() => {
|
||||
const rect = ref.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 fromVars = { x: -100, opacity: 0, duration: 0.5 }
|
||||
if (isInView) {
|
||||
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
|
||||
if (isInView && !scrollOnly) {
|
||||
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position, tlId)
|
||||
} else {
|
||||
const scroller = gsapContext?.getScroller()
|
||||
console.log('scroller:', scroller)
|
||||
gsap.from(ref.current, { ...fromVars, scrollTrigger: { trigger: ref.current, start: 'top 85%', scroller } })
|
||||
gsap.from(ref.current,
|
||||
{
|
||||
...fromVars,
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
toggleActions: "play reverse play reverse",
|
||||
scroller
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { dependencies: [] })
|
||||
return (
|
||||
|
||||
@@ -2,45 +2,71 @@ import { auth } from '@clerk/nextjs/server'
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { db } from '~/server/db'
|
||||
import { chatSession, systemSettings } from "../dbschema/schema";
|
||||
import { chatMessage,
|
||||
chatSession, systemSettings } from "../dbschema/schema";
|
||||
import { isAdmin } from '~/app/actions';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
export const chatRouter = router({
|
||||
getSession: publicProcedure.query(async () => {
|
||||
const { userId } = await auth();
|
||||
if (userId == null) {
|
||||
throw new TRPCError({message: "chat is only available to signed in users",code: 'UNAUTHORIZED'});
|
||||
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
|
||||
}
|
||||
let session = await db.query.chatSession.findFirst({
|
||||
with: {
|
||||
messages: true
|
||||
},
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId,userId)
|
||||
return operators.eq(fields.userId, userId)
|
||||
},
|
||||
})
|
||||
if (session !== undefined) {
|
||||
return session;
|
||||
}
|
||||
let newSession = await db.insert(chatSession).values({userId: userId}).returning().execute().then((r) => r.at(0));
|
||||
let newSession = await db.insert(chatSession).values({ userId: userId }).returning().execute().then((r) => r.at(0));
|
||||
if (newSession == undefined) {
|
||||
throw new TRPCError({message: "failed to create session", code:"INTERNAL_SERVER_ERROR"});
|
||||
throw new TRPCError({ message: "failed to create session", code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
session = await db.query.chatSession.findFirst({
|
||||
with: {
|
||||
messages: true
|
||||
},
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.id,newSession.id)
|
||||
return operators.eq(fields.id, newSession.id)
|
||||
},
|
||||
})
|
||||
if (session == undefined) {
|
||||
throw new TRPCError({message: "session not found", code:"NOT_FOUND"});
|
||||
throw new TRPCError({ message: "session not found", code: "NOT_FOUND" });
|
||||
}
|
||||
if (session !== undefined) {
|
||||
return session;
|
||||
}
|
||||
}),
|
||||
getMessages: publicProcedure.input(z.string()).query(async ({input}) => {
|
||||
let res = await db.query.chatMessage.findMany({
|
||||
where(fields,operators) {
|
||||
return operators.eq(fields.sessionId,input)
|
||||
}
|
||||
})
|
||||
return res;
|
||||
}),
|
||||
clearChat: publicProcedure.mutation(async () => {
|
||||
console.log("deleting session")
|
||||
const { userId } = await auth();
|
||||
if (userId == null) {
|
||||
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
|
||||
}
|
||||
let session = await db.query.chatSession.findFirst({
|
||||
with: {
|
||||
messages: true
|
||||
},
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.userId, userId)
|
||||
},
|
||||
})
|
||||
if (session != undefined) {
|
||||
db.delete(chatMessage).where(eq(chatMessage.sessionId,session.id)).execute()
|
||||
}
|
||||
|
||||
}),
|
||||
getSystemPrompt: publicProcedure.query(async () => {
|
||||
const row = await db.select().from(systemSettings).limit(1).then((r) => r[0])
|
||||
return row?.systemPropmt ?? ''
|
||||
|
||||
Reference in New Issue
Block a user