12 Commits

21 changed files with 481 additions and 202 deletions

0
.codex Normal file
View File

View File

@@ -2,29 +2,28 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog'
import ChatInterface from '~/app/chat/_components/ChatInterface' import ChatInterface from '~/app/chat/_components/ChatInterface'
import { useMessages } from '~/app/_providers/MessagesProvider';
import { Spinner } from '~/components/ui/spinner';
type DBMessage = { export default function ChatModal() {
id: string
role: 'user' | 'assistant'
content: string
}
interface ChatModalProps {
sessionId: string
initialMessages: DBMessage[]
}
export default function ChatModal({ sessionId, initialMessages }: ChatModalProps) {
const router = useRouter() const router = useRouter()
const {messages,session,isLoading,error} = useMessages()
return ( return (
<Dialog modal={true} open onOpenChange={() => router.back()}> <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"> <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"> <DialogHeader className="p-4 border-b shrink-0">
<DialogTitle>AI Recruiter</DialogTitle> <DialogTitle>Talk To My AI-Assistant</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-hidden min-h-0"> <div className="flex-1 overflow-hidden min-h-0">
<ChatInterface sessionId={sessionId} initialMessages={initialMessages} /> {!isLoading &&
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
}
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -0,0 +1,8 @@
'use client'
import ChatModal from './_components/ChatModal'
export default function AssistantModalPage() {
return (
<ChatModal/>
)
}

View File

@@ -1,15 +0,0 @@
'use client'
import { Skeleton } from '~/components/ui/skeleton';
import ChatModal from './_components/ChatModal'
import { trpc } from '~/app/_trpc/Client'
export default function ChatModalPage() {
const { data: session, error, isLoading } = trpc.chat.getSession.useQuery();
return (
<>
{session && <ChatModal sessionId={session.id} initialMessages={session.messages} />}
{error && <div>{error.message}</div>}
{isLoading && <Skeleton />}
</>
)
}

View File

@@ -7,32 +7,58 @@ import { cn } from "~/lib/utils";
const AnimateTextIn = ({ const AnimateTextIn = ({
children, children,
animation = "type", animation = "type",
position, position = 0,
tlId = undefined,
speed = 1,
scrollOnly = false,
className className
}: { }: {
children: ReactNode, children: ReactNode,
animation?: "type" | "slide", animation?: "type" | "slide",
position: gsap.Position, position?: gsap.Position,
className?:HTMLAttributes<HTMLDivElement>['className'] tlId?: string,
scrollOnly?: boolean,
speed?: number,
className?: HTMLAttributes<HTMLDivElement>['className']
}) => { }) => {
const el = useRef<HTMLDivElement>(null) const el = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext(); const gsapContext = useGsapContext();
useGSAP(() => { useGSAP(() => {
const rect = el.current?.getBoundingClientRect() 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' }) 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" const fromVars = animation === "slide"
? { opacity: 0, x: -10, duration: 0.2, stagger: { each: 0.08 }, ease: 'bounce.inOut', onComplete: () => chars.revert() } ? { opacity: 0, x: -10, duration: 0.2 * speed, stagger: { each: 0.08 * speed }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
: { opacity: 0, duration: 0.01, stagger: { each: 0.04 }, 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) { if (isInView && !scrollOnly) {
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position) gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position, tlId)
} else { } 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: [] }) }, { dependencies: [] })
return ( return (
<div ref={el} className={cn(className,"opacity-0")}> <div ref={el} className={cn(className, "opacity-0")}>
{children} {children}
</div> </div>
) )

View File

@@ -1,19 +1,22 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
import { Show } from '@clerk/nextjs'
import { Button } from '~/components/ui/button' import { Button } from '~/components/ui/button'
import { usePathname } from 'next/navigation'
export default function ChatFAB() { export default function ChatFAB() {
const pathName = usePathname()
const isChat = pathName.indexOf('\/chat') > -1
return ( return (
<Show when="signed-in"> <>
<div className="fixed bottom-6 right-6 z-50"> {!isChat &&
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg"> <div className="fixed bottom-6 right-6 z-50">
<Link href="/chat"> <Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
<MessageCircle className="h-6 w-6" /> <Link href="/assistant">
</Link> <MessageCircle className="h-6 w-6" />
</Button> </Link>
</div> </Button>
</Show> </div>
}
</>
) )
} }

View File

@@ -24,7 +24,7 @@ export default function TopNav() {
</Button> </Button>
<Show when="signed-in"> <Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full w-full lg:w-20" variant="outline"> <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> </Button>
</Show> </Show>
</div> </div>

View File

@@ -2,12 +2,13 @@
import { useGSAP } from '@gsap/react' import { useGSAP } from '@gsap/react'
import gsap from 'gsap' import gsap from 'gsap'
import { SplitText } from 'gsap/SplitText' import { SplitText } from 'gsap/SplitText'
import { ScrollTrigger } from 'gsap/all' import { ScrollTrigger, GSDevTools } from 'gsap/all'
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
gsap.registerPlugin(useGSAP) gsap.registerPlugin(useGSAP)
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(SplitText) gsap.registerPlugin(SplitText)
gsap.registerPlugin(GSDevTools)
const GsapContext = createContext<{ const GsapContext = createContext<{
addAnimation: ( addAnimation: (
animation: gsap.core.TimelineChild, animation: gsap.core.TimelineChild,
@@ -22,7 +23,7 @@ export function useGsapContext() {
return useContext(GsapContext) return useContext(GsapContext)
} }
export const useTimeLine = (dep?:any,all?:boolean) => { export const useTimeLine = (dep:any,all?:boolean) => {
const gsapContext = useGsapContext() const gsapContext = useGsapContext()
useEffect(() => { useEffect(() => {
if (dep instanceof Array && all) { if (dep instanceof Array && all) {
@@ -48,10 +49,25 @@ export default function GsapProvider({ children }: { children: ReactNode }) {
const tl = useRef<gsap.core.Timeline | null>(null) const tl = useRef<gsap.core.Timeline | null>(null)
const scrollerRef = useRef<Element | Window | null>(null) const scrollerRef = useRef<Element | Window | null>(null)
const getScroller = useCallback(() => { const getScroller = useCallback(() => {
const cached = scrollerRef.current // const cached = scrollerRef.current
if (!cached || (cached instanceof Element && !document.contains(cached))) { // if (!cached || (cached instanceof Element && !document.contains(cached))) {
scrollerRef.current = document.querySelector('[data-slot="scroll-area-viewport"]') ?? window 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 return scrollerRef.current
}, []) }, [])
useGSAP(() => { useGSAP(() => {
@@ -66,7 +82,6 @@ export default function GsapProvider({ children }: { children: ReactNode }) {
tl.current?.add(animation, position); tl.current?.add(animation, position);
},[]) },[])
const resetTimeline = useCallback(() => { const resetTimeline = useCallback(() => {
console.log('resetting timeline')
tl.current?.kill() tl.current?.kill()
tl.current?.revert() tl.current?.revert()
ScrollTrigger.getAll().forEach(st => st.kill()) ScrollTrigger.getAll().forEach(st => st.kill())

View File

@@ -0,0 +1,95 @@
'use client'
import type { inferRouterOutputs } from '@trpc/server';
import { useUser } from '@clerk/nextjs'
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { trpc } from '~/app/_trpc/Client'
import { type ChatRouter } from '~/server/routers/chat'
const MessageContext = createContext<{
session?: inferRouterOutputs<ChatRouter>['getSession']
messages?: inferRouterOutputs<ChatRouter>['getMessages']
refetchMessages: () => void
clearChat: (callback?: () => void) => void
error: string|null
isLoading: boolean
clearingChat: boolean
clearedChat: boolean
}>({
session: undefined,
messages: undefined,
refetchMessages: () => undefined,
clearChat: () => undefined,
error: null,
isLoading: true,
clearingChat: false,
clearedChat: false
})
export const useMessages = () => useContext(MessageContext)
export const MessagesProvider = ({children}:{children:ReactNode}) => {
const [error,setError] = useState<string|null>(null)
const [isLoading,setIsLoading] = useState<boolean>(true)
const { isLoaded, isSignedIn } = useUser()
const { data: session,error:sessionError,isLoading:sessionLoading} = trpc.chat.getSession.useQuery(undefined, {
enabled: isSignedIn === true,
})
const { data: messages, refetch, error:messageError, isLoading:messagesLoading } = trpc.chat.getMessages.useQuery(session?.id ? session.id : "", {
enabled: isSignedIn === true && session?.id != undefined,
})
const { mutate ,isPending:clearingChat,isSuccess:clearedChat } = trpc.chat.clearChat.useMutation()
const utils = trpc.useUtils()
const refetchMessages = () => {
if (!isSignedIn) {
return;
}
utils.chat.getMessages.invalidate()
refetch()
}
const clearChat = (callback?: () => void) => {
if (!isSignedIn) {
if (callback) {
callback()
}
return;
}
mutate(undefined,{onSuccess: () => {
if (callback) {
callback()
}
utils.chat.getMessages.invalidate()
}})
}
useEffect(() => {
if (isSignedIn !== true) {
setError(null)
return;
}
messageError && setError(messageError.message)
sessionError && setError(sessionError.message)
},[messageError,sessionError,isSignedIn])
useEffect(() => {
if (!isLoaded) {
setIsLoading(true)
return;
}
if (isSignedIn !== true) {
setIsLoading(false)
return;
}
setIsLoading(sessionLoading || messagesLoading)
},[isLoaded,isSignedIn,sessionLoading,messagesLoading])
return (
<MessageContext.Provider value={
{
session: isSignedIn === true ? session : undefined,
messages: isSignedIn === true ? messages : undefined,
refetchMessages,
clearChat,
error,
isLoading,
clearingChat,
clearedChat
}
}>
{children}
</MessageContext.Provider>
)
}

View File

@@ -0,0 +1,8 @@
export default function currentTime() {
let now = Date.now();
console.log(now);
return {
success: true,
time: now
}
}

View File

@@ -1,5 +1,5 @@
'use server' 'use server'
import { clerkClient } from '@clerk/nextjs/server' import { clerkClient, auth } from '@clerk/nextjs/server'
import { google } from 'googleapis' import { google } from 'googleapis'
import { env } from '~/env' import { env } from '~/env'
@@ -10,7 +10,6 @@ export async function scheduleMeeting({
durationMinutes, durationMinutes,
attendeeEmail, attendeeEmail,
attendeeName, attendeeName,
userId,
}: { }: {
title: string title: string
description: string description: string
@@ -18,11 +17,11 @@ export async function scheduleMeeting({
durationMinutes: number durationMinutes: number
attendeeEmail?: string attendeeEmail?: string
attendeeName?: string attendeeName?: string
userId: string
}) { }) {
try { try {
const clerk = await clerkClient() const clerk = await clerkClient()
const userAuth = await auth()
const user = await clerk.users.getUser(userAuth.userId?userAuth.userId:"")
// Get admin's Google OAuth token to create the event on Gregor's calendar // Get admin's Google OAuth token to create the event on Gregor's calendar
const adminTokenResponse = await clerk.users.getUserOauthAccessToken( const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
env.ADMIN_USER_CLERK_ID, env.ADMIN_USER_CLERK_ID,
@@ -37,16 +36,7 @@ export async function scheduleMeeting({
// Try to resolve visitor's Google email for the invite // Try to resolve visitor's Google email for the invite
let visitorEmail: string | undefined = attendeeEmail let visitorEmail: string | undefined = attendeeEmail
if (!visitorEmail) { if (!visitorEmail) {
try { visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
const visitorTokenResponse = await clerk.users.getUserOauthAccessToken(userId, 'oauth_google')
if (visitorTokenResponse.data[0]) {
const user = await clerk.users.getUser(userId)
const googleAccount = user.externalAccounts.find((a) => a.provider === 'google')
visitorEmail = googleAccount?.emailAddress ?? undefined
}
} catch {
// Visitor not signed in with Google — no invite
}
} }
const oAuth2Client = new google.auth.OAuth2() const oAuth2Client = new google.auth.OAuth2()
@@ -71,6 +61,7 @@ export async function scheduleMeeting({
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' }, end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
attendees, attendees,
}, },
sendNotifications: true
}) })
return { return {

View File

@@ -1,13 +1,14 @@
import { auth } from '@clerk/nextjs/server' import { auth } from '@clerk/nextjs/server'
import { createOpenAI } from '@ai-sdk/openai' import { createOpenAI } from '@ai-sdk/openai'
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai' import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
import { z } from 'zod' import { success, z } from 'zod'
import { eq, and } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { env } from '~/env' import { env } from '~/env'
import { db } from '~/server/db' import { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema' import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { servTrpc } from '~/app/_trpc/ServerClient' import { servTrpc } from '~/app/_trpc/ServerClient'
import { scheduleMeeting } from '~/app/actions/scheduleMeeting' import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
import currentTime from '~/app/actions/currentTime';
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY }) const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
@@ -45,15 +46,15 @@ export async function POST(req: Request) {
} }
const result = streamText({ const result = streamText({
model: openai('gpt-4o'), model: openai('gpt-5-mini'),
system: systemPrompt, system: systemPrompt,
messages: await convertToModelMessages(messages), messages: await convertToModelMessages(messages),
tools: { tools: {
scheduleMeeting: tool({ scheduleMeeting: tool({
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar', description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
inputSchema: z.object({ inputSchema: z.object({
title: z.string().describe('Meeting title'), title: z.string().describe('Meeting title, make something up if not provided'),
description: z.string().describe('Meeting description / agenda'), description: z.string().describe('Meeting description / agenda, make something up if not provided'),
dateTime: z dateTime: z
.string() .string()
.describe( .describe(
@@ -64,16 +65,23 @@ export async function POST(req: Request) {
.int() .int()
.min(15) .min(15)
.max(120) .max(120)
.describe('Duration of the meeting in minutes'), .describe('Duration of the meeting in minutes, if none provided ask if 20 minutes is ok'),
attendeeEmail: z attendeeEmail: z
.string() .string()
.email() .email()
.optional() .optional()
.describe('Email of the visitor to invite (if provided)'), .describe('Optional Email of the visitor to invite (if provided)'),
attendeeName: z.string().optional().describe('Name of the visitor'), attendeeName: z.string().optional().describe('Name of the visitor'),
}), }),
execute: async (input) => scheduleMeeting({ ...input, userId }), execute: async (input) => scheduleMeeting({ ...input }),
}), }),
getCurrentUnixTime: tool({
description: 'Get the current unix time to reference for meeting dates',
inputSchema: z.object({
none: z.string().optional().describe("no inputs are needed")
}),
execute: async () => currentTime()
})
}, },
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
onFinish: async ({ text, finishReason }) => { onFinish: async ({ text, finishReason }) => {

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function AssistantPage() {
redirect('/chat')
}

View File

@@ -1,23 +1,42 @@
'use client' 'use client'
import { useRef, useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useChat } from '@ai-sdk/react' import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai' import { DefaultChatTransport, type UIMessage } from 'ai'
import { Button } from '~/components/ui/button' import { Button } from '~/components/ui/button'
import { Textarea } from '~/components/ui/textarea' import { Textarea } from '~/components/ui/textarea'
import { cn } from '~/lib/utils' import { SignInButton } from '@clerk/nextjs'
import Markdown from 'react-markdown'; import {
import { AssistantMessage } from './AssistantMessage'; useGsapContext,
import { UserMessage } from './UserMessage'; } from '~/app/_providers/GsapProvicer';
import Messages from './Messages'
type DBMessage = { import { DeleteIcon } from 'lucide-react';
import { Spinner } from '~/components/ui/spinner';
import { useMessages } from '~/app/_providers/MessagesProvider';
interface DBMessage {
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
} }
interface ChatInterfaceProps { interface ChatInterfaceProps {
sessionId: string sessionId?: string,
initialMessages: DBMessage[] dbMessages: DBMessage[],
}
function SignInChatPrompt() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-center">
<div className="space-y-2">
<h2 className="text-xl font-semibold">Sign in to use the chat</h2>
<p className="text-sm text-muted-foreground">
You need to be signed in before you can talk to Gregor's AI assistant.
</p>
</div>
<SignInButton mode="modal">
<Button type="button">Sign in</Button>
</SignInButton>
</div>
)
} }
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] { function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
@@ -28,60 +47,55 @@ function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
})) }))
} }
export default function ChatInterface({ sessionId, initialMessages }: ChatInterfaceProps) { function addInitMessage(messageArray: UIMessage[]) {
if (messageArray.at(0)?.id != 'init') {
messageArray.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."
}],
})
}
}
function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProps & { sessionId: string }) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null) const { clearingChat, clearChat, refetchMessages } = useMessages();
const initialMessages = toUIMessages(dbMessages)
const { messages, sendMessage, status, error, clearError } = useChat({ addInitMessage(initialMessages)
const { messages, sendMessage, status, error, clearError, setMessages } = useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: '/api/chat', api: '/api/chat', body: { sessionId },
body: { sessionId },
}), }),
messages: toUIMessages(initialMessages), messages: initialMessages,
}) })
const isLoading = status === 'submitted' || status === 'streaming'
const hasError = status === 'error'
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) return () => {
}, [messages]) refetchMessages()
}
}, [])
const handleSend = () => { const handleSend = () => {
const text = input.trim() const text = input.trim()
if (!text || isLoading) return if (!text || status != 'ready' || clearingChat) return
setInput('') setInput('')
sendMessage({ text }) sendMessage({ text })
} }
const gsapContext = useGsapContext()
useEffect(() => {
let scroller = gsapContext?.getScroller()
if (scroller instanceof Window) {
return;
}
console.log(scroller?.scrollHeight)
scroller?.scrollTo({ behavior: 'smooth', top: scroller.scrollHeight })
}, [messages])
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4"> {messages &&
{messages.length === 0 && ( <Messages status={status} messages={messages} />
<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>
{error && ( {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"> <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">
<span className="flex-1"> <span className="flex-1">
@@ -89,19 +103,20 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
? 'OpenAI quota exceeded. Please try again later.' ? 'OpenAI quota exceeded. Please try again later.'
: `Error: ${error.message}`} : `Error: ${error.message}`}
</span> </span>
<button <Button
type="button" type="button"
onClick={clearError} onClick={clearError}
className="shrink-0 opacity-60 hover:opacity-100" className="shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss" variant='destructive'
> >
<DeleteIcon />
</button> </Button>
</div> </div>
)} )}
<div className="p-4 border-t flex gap-2"> <div className="p-4 border-t flex flex-row gap-2">
<Textarea <Textarea
name='message'
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Ask about Gregor's experience or schedule a meeting" placeholder="Ask about Gregor's experience or schedule a meeting"
@@ -114,14 +129,38 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
} }
}} }}
/> />
<Button <div className='flex flex-col gap-2'>
onClick={handleSend} <Button
disabled={isLoading || hasError || !input.trim()} onClick={handleSend}
className="self-end" disabled={status != "ready" || !input.trim()}
> >
Send Send
</Button> </Button>
<Button
variant='destructive'
onClick={() => {
clearChat(() => {
let messages: UIMessage[] = [];
addInitMessage(messages);
setMessages(messages)
})
}}
disabled={status != "ready" || clearingChat}
>
{clearingChat ?
<Spinner /> :
"Clear Chat"
}
</Button>
</div>
</div> </div>
</div> </div>
) )
} }
export default function ChatInterface({ dbMessages, sessionId }: ChatInterfaceProps) {
if (sessionId == undefined) {
return <SignInChatPrompt />
}
return <AuthenticatedChatInterface sessionId={sessionId} dbMessages={dbMessages} />
}

View File

@@ -0,0 +1,36 @@
import { type ChatStatus, type UIMessage } from 'ai'
import * as Card from "~/components/ui/card"
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
import { ScrollArea } from '~/components/ui/scroll-area';
import { memo } from 'react';
const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
return (
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto">
{messages.map((message, i) => (
<Card.AnimatedCard scrollOnly={true} key={i}>
<Card.CardContent>
{message.role == 'assistant' && <AssistantMessage message={message} />}
{message.role == 'user' && <UserMessage message={message} />}
</Card.CardContent>
</Card.AnimatedCard>
))}
{status == 'submitted' &&
<Card.AnimatedCard scrollOnly={true}>
<Card.CardContent>
<AssistantMessage message={{
id:"",
role:"assistant",
parts:[{
type:'text',
text:'Thinking ...'
}]
}}/>
</Card.CardContent>
</Card.AnimatedCard>
}
</ScrollArea>)
})
export default Messages;

View File

@@ -1,27 +1,29 @@
'use client' 'use client'
import ChatInterface from './_components/ChatInterface' import ChatInterface from './_components/ChatInterface'
import { trpc } from '../_trpc/Client'; import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
import { Skeleton } from '~/components/ui/skeleton'; import { useTimeLine } from '../_providers/GsapProvicer';
import { useMessages } from '../_providers/MessagesProvider';
import { Spinner } from '~/components/ui/spinner';
export default function ChatPage() { export default function ChatPage() {
const { data: session, error, isLoading } = trpc.chat.getSession.useQuery(); const {messages,session,isLoading,error} = useMessages()
useTimeLine(messages)
return ( return (
<div className="container max-w-2xl mx-auto h-screen pt-10 pb-4 flex flex-col"> <div className="flex flex-col px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<div className="flex flex-col flex-1 bg-background border rounded-lg overflow-hidden"> <AnimatedPageTitle position={0}>
<div className="p-4 border-b flex items-center justify-between"> <span>Talk To My </span> <span> AI-Assistant</span>
<div> </AnimatedPageTitle>
<h1 className="text-lg font-semibold">AI Recruiter</h1> <div className='flex items-center h-[80%] w-full my-auto w-full'>
<p className="text-xs text-muted-foreground"> {!isLoading &&
Chat with Gregor's AI assistant <ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
</p> }
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</div> </div>
</div>
<div className="flex-1 overflow-hidden">
{session && <ChatInterface sessionId={session?.id} initialMessages={session?.messages} /> }
{error && <div>{error.message}</div>}
{isLoading && <Skeleton/>}
</div>
</div>
</div> </div>
) )
} }

View File

@@ -11,6 +11,7 @@ import TrpcProvider from "./_trpc/TrpcProvider";
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true}) // const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
import ThemeProvider from './_providers/ThemeProvider' import ThemeProvider from './_providers/ThemeProvider'
import GsapProvider from "./_providers/GsapProvicer"; import GsapProvider from "./_providers/GsapProvicer";
import {MessagesProvider} from "./_providers/MessagesProvider";
import { CodeHighlightStyle } from "./_components/CodeHighlightSyle"; import { CodeHighlightStyle } from "./_components/CodeHighlightSyle";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer"; import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer";
@@ -45,14 +46,16 @@ export default async function RootLayout({
</head> </head>
<body className="flex flex-col bg-background text-foreground"> <body className="flex flex-col bg-background text-foreground">
<ThemeProvider> <ThemeProvider>
<AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}> <MessagesProvider>
<TopNav /> <AnimatedBackGroundContainer followSpeed={0.003} particleCount={100} orbitRadius={2000}>
<main className="absolute lg:top-10 h-screen w-screen"> <TopNav />
{children} <main className="absolute lg:top-10 h-screen lg:h-[calc(100vh-var(--spacing)*10)] w-screen">
</main> {children}
{modal} </main>
</AnimatedBackGroundContainer> {modal}
</AnimatedBackGroundContainer>
<ChatFAB /> <ChatFAB />
</MessagesProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -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 { useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer"; import { useGsapContext } from "~/app/_providers/GsapProvicer";
import gsap from 'gsap' import gsap from 'gsap'
@@ -26,20 +26,39 @@ function AnimatedCard({
className, className,
position = 0, position = 0,
size = "default", size = "default",
scrollOnly = false,
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm", position?: gsap.Position, scrollOnly?: boolean }) {
const gsapContext = useGsapContext() const gsapContext = useGsapContext()
const ref = useRef<HTMLDivElement|null>(null) const ref = useRef<HTMLDivElement | null>(null)
useGSAP(() => { useGSAP(() => {
const rect = ref.current?.getBoundingClientRect() 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 } const fromVars = { x: -100, opacity: 0, duration: 0.5 }
if (isInView) { if (isInView && !scrollOnly) {
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position) gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
} else { } else {
const scroller = gsapContext?.getScroller() gsap.from(ref.current,
console.log('scroller:', scroller) {
gsap.from(ref.current, { ...fromVars, scrollTrigger: { trigger: ref.current, start: 'top 85%', scroller } }) ...fromVars,
scrollTrigger: {
trigger: ref.current,
start: 'top bottom',
end: 'bottom top',
toggleActions: "play reverse play reverse",
scroller
}
})
} }
}, { dependencies: [] }) }, { dependencies: [] })
return ( return (

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs" import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs"
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>( function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>(
relationShipData: T[] | undefined, relationShipData: T[] | undefined,
@@ -56,3 +56,10 @@ export function makeUseRelationShipWithNameIndex<K extends string>(key:K) {
} }
} }
export function usePrevious<T>(value:T,initialValue:T) {
const ref = useRef(initialValue)
useEffect(() => {
ref.current = value
})
return ref.current;
}

View File

@@ -1,45 +1,69 @@
import { auth } from '@clerk/nextjs/server'
import { publicProcedure, router } from "../trpc"; import { publicProcedure, router } from "../trpc";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { db } from '~/server/db' import { db } from '~/server/db'
import { chatSession, systemSettings } from "../dbschema/schema"; import { chatMessage,
chatSession, systemSettings } from "../dbschema/schema";
import { isAdmin } from '~/app/actions'; import { isAdmin } from '~/app/actions';
import { z } from 'zod'; import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { clerkClient, auth } from '@clerk/nextjs/server'
export const chatRouter = router({ export const chatRouter = router({
getSession: publicProcedure.query(async () => { getSession: publicProcedure.query(async () => {
const clerk = await clerkClient()
const { userId } = await auth();
const user = await clerk.users.getUser(userId?userId:"")
if (user == undefined) {
throw new TRPCError({ message: "chat is only available to signed in users", code: 'UNAUTHORIZED' });
}
let session = await db.query.chatSession.findFirst({
where(fields, operators) {
return operators.eq(fields.userId, user.id)
},
})
if (session !== undefined) {
return session;
}
let newSession = await db.insert(chatSession).values({ userId: user.id}).returning().execute().then((r) => r.at(0)); if (newSession == undefined) {
throw new TRPCError({ message: "failed to create session", code: "INTERNAL_SERVER_ERROR" });
}
session = await db.query.chatSession.findFirst({
where(fields, operators) {
return operators.eq(fields.userId, user.id)
},
})
if (session == undefined) {
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(); const { userId } = await auth();
if (userId == null) { 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({ let session = await db.query.chatSession.findFirst({
with: { with: {
messages: true messages: true
}, },
where(fields, operators) { where(fields, operators) {
return operators.eq(fields.userId,userId) return operators.eq(fields.userId, userId)
}, },
}) })
if (session !== undefined) { if (session != undefined) {
return session; db.delete(chatMessage).where(eq(chatMessage.sessionId,session.id)).execute()
}
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"});
}
session = await db.query.chatSession.findFirst({
with: {
messages: true
},
where(fields, operators) {
return operators.eq(fields.id,newSession.id)
},
})
if (session == undefined) {
throw new TRPCError({message: "session not found", code:"NOT_FOUND"});
}
if (session !== undefined) {
return session;
} }
}), }),
getSystemPrompt: publicProcedure.query(async () => { getSystemPrompt: publicProcedure.query(async () => {
const row = await db.select().from(systemSettings).limit(1).then((r) => r[0]) const row = await db.select().from(systemSettings).limit(1).then((r) => r[0])
@@ -51,3 +75,5 @@ export const chatRouter = router({
await db.insert(systemSettings).values({ systemPropmt: input.prompt }) await db.insert(systemSettings).values({ systemPropmt: input.prompt })
}), }),
}) })
export type ChatRouter = typeof chatRouter;

View File

@@ -140,3 +140,7 @@
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
.cl-button__google {
display: none
}