4 Commits

Author SHA1 Message Date
64bd5c429e logged out chat modal, call to action 2026-04-22 21:05:53 +02:00
52e0a65113 hide google sign in 2026-04-22 19:31:09 +02:00
30e3dbb42b chat interface 2026-04-21 14:19:50 +02:00
caa9604704 early return on no sessionid 2026-03-31 15:47:41 +02:00
14 changed files with 236 additions and 116 deletions

0
.codex Normal file
View File

View File

@@ -2,21 +2,12 @@
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 }: 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">
@@ -24,7 +15,15 @@ export default function ChatModal({ sessionId }: ChatModalProps) {
<DialogTitle>Talk To My AI-Assistant</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} /> {!isLoading &&
<ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
}
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

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

View File

@@ -1,7 +1,6 @@
'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' import { usePathname } from 'next/navigation'
export default function ChatFAB() { export default function ChatFAB() {
@@ -10,15 +9,13 @@ export default function ChatFAB() {
return ( return (
<> <>
{!isChat && {!isChat &&
<Show when="signed-in"> <div className="fixed bottom-6 right-6 z-50">
<div className="fixed bottom-6 right-6 z-50"> <Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg">
<Button asChild size="icon" className="h-14 w-14 rounded-full shadow-lg"> <Link href="/assistant">
<Link href="/assistant"> <MessageCircle className="h-6 w-6" />
<MessageCircle className="h-6 w-6" /> </Link>
</Link> </Button>
</Button> </div>
</div>
</Show>
} }
</> </>
) )

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

@@ -16,7 +16,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
{message.parts.map((part, i) => { {message.parts.map((part, i) => {
if (part.type === 'text') { if (part.type === 'text') {
return ( return (
<Markdown key={i}> <Markdown>
{part.text} {part.text}
</Markdown> </Markdown>
) )

View File

@@ -1,16 +1,17 @@
'use client' 'use client'
import { 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 { SignInButton } from '@clerk/nextjs'
import { import {
useGsapContext, useGsapContext,
} from '~/app/_providers/GsapProvicer'; } from '~/app/_providers/GsapProvicer';
import Messages from './Messages' import Messages from './Messages'
import { DeleteIcon } from 'lucide-react'; import { DeleteIcon } from 'lucide-react';
import { trpc } from '~/app/_trpc/Client'
import { Spinner } from '~/components/ui/spinner'; import { Spinner } from '~/components/ui/spinner';
import { useMessages } from '~/app/_providers/MessagesProvider';
interface DBMessage { interface DBMessage {
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -18,8 +19,24 @@ interface DBMessage {
} }
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[] {
@@ -29,19 +46,10 @@ function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
parts: [{ type: 'text' as const, text: m.content }], parts: [{ type: 'text' as const, text: m.content }],
})) }))
} }
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(() => {
setMessages(toUIMessages(dbMessages ?? []));
}, [dbMessages]);
if (messages.at(0)?.id != 'init') { function addInitMessage(messageArray: UIMessage[]) {
messages.unshift({ if (messageArray.at(0)?.id != 'init') {
messageArray.unshift({
id: "init", id: "init",
role: 'assistant', role: 'assistant',
parts: [{ parts: [{
@@ -50,46 +58,31 @@ export default function ChatInterface({ sessionId }: ChatInterfaceProps) {
}], }],
}) })
} }
}
function AuthenticatedChatInterface({ dbMessages, sessionId }: ChatInterfaceProps & { sessionId: string }) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const { sendMessage, status, error, clearError } = useChat({ const { clearingChat, clearChat, refetchMessages } = useMessages();
const initialMessages = toUIMessages(dbMessages)
addInitMessage(initialMessages)
const { messages, sendMessage, status, error, clearError, setMessages } = useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: '/api/chat', body: { sessionId }, api: '/api/chat', body: { sessionId },
}), }),
messages: messages, messages: initialMessages,
}) })
const handleSend = () => {
const text = input.trim()
if (!text || status != 'ready' || sessionId == undefined) 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(() => { useEffect(() => {
console.log(status) return () => {
if (status == 'ready') {
utils.chat.getMessages.invalidate();
refetchMessages() refetchMessages()
} }
}, [status]) }, [])
const handleSend = () => {
const text = input.trim()
if (!text || status != 'ready' || clearingChat) return
setInput('')
sendMessage({ text })
}
const gsapContext = useGsapContext()
useEffect(() => { useEffect(() => {
let scroller = gsapContext?.getScroller() let scroller = gsapContext?.getScroller()
if (scroller instanceof Window) { if (scroller instanceof Window) {
@@ -101,9 +94,8 @@ export default function ChatInterface({ sessionId }: ChatInterfaceProps) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{messages && {messages &&
<Messages messages={messages} /> <Messages status={status} messages={messages} />
} }
{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">
@@ -140,16 +132,22 @@ export default function ChatInterface({ sessionId }: ChatInterfaceProps) {
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<Button <Button
onClick={handleSend} onClick={handleSend}
disabled={status != "ready" || !input.trim() || sessionId == undefined} disabled={status != "ready" || !input.trim()}
> >
Send Send
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
onClick={handleClear} onClick={() => {
disabled={status != "ready" || clearChatMutation.isPending} clearChat(() => {
let messages: UIMessage[] = [];
addInitMessage(messages);
setMessages(messages)
})
}}
disabled={status != "ready" || clearingChat}
> >
{clearChatMutation.isPending ? {clearingChat ?
<Spinner /> : <Spinner /> :
"Clear Chat" "Clear Chat"
} }
@@ -159,3 +157,10 @@ export default function ChatInterface({ sessionId }: ChatInterfaceProps) {
</div> </div>
) )
} }
export default function ChatInterface({ dbMessages, sessionId }: ChatInterfaceProps) {
if (sessionId == undefined) {
return <SignInChatPrompt />
}
return <AuthenticatedChatInterface sessionId={sessionId} dbMessages={dbMessages} />
}

View File

@@ -1,24 +1,35 @@
import { type UIMessage } from 'ai' import { type ChatStatus, type UIMessage } from 'ai'
import * as Card from "~/components/ui/card" import * as Card from "~/components/ui/card"
import AnimateTextIn from '~/app/_components/Animated/AnimateIn';
import { UserMessage } from './UserMessage'; import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage'; import { AssistantMessage } from './AssistantMessage';
import { ScrollArea } from '~/components/ui/scroll-area'; import { ScrollArea } from '~/components/ui/scroll-area';
import { useTimeLine } from '~/app/_providers/GsapProvicer'; import { memo } from 'react';
import { const Messages = memo(({messages,status}: { messages: UIMessage[],status:ChatStatus}) => {
memo
} from 'react';
const Messages = memo(({ messages}: { messages: UIMessage[]}) => {
return ( return (
<ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto"> <ScrollArea data-scroller-priority='1' className="w-full h-[90%] max-w-4xl mx-auto">
{messages.map((message, i) => ( {messages.map((message, i) => (
<Card.AnimatedCard scrollOnly={true} tlId='chat' position={i * 0.2} key={i}> <Card.AnimatedCard scrollOnly={true} key={i}>
<Card.CardContent> <Card.CardContent>
{message.role == 'assistant' && <AssistantMessage message={message} />} {message.role == 'assistant' && <AssistantMessage message={message} />}
{message.role == 'user' && <UserMessage message={message} />} {message.role == 'user' && <UserMessage message={message} />}
</Card.CardContent> </Card.CardContent>
</Card.AnimatedCard> </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>) </ScrollArea>)
}) })

View File

@@ -1,22 +1,28 @@
'use client' 'use client'
import ChatInterface from './_components/ChatInterface' import ChatInterface from './_components/ChatInterface'
import { trpc } from '../_trpc/Client';
import { Skeleton } from '~/components/ui/skeleton';
import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle'; import AnimatedPageTitle from '../_components/Animated/AnimatedPageTitle';
import { useTimeLine } from '../_providers/GsapProvicer'; 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(session) useTimeLine(messages)
return ( return (
<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 px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<AnimatedPageTitle position={0}> <AnimatedPageTitle position={0}>
<span>Talk To My </span> <span> AI-Assistant</span> <span>Talk To My </span> <span> AI-Assistant</span>
</AnimatedPageTitle> </AnimatedPageTitle>
<div className='flex items-center h-[80%] my-auto w-full'> <div className='flex items-center h-[80%] w-full my-auto w-full'>
<ChatInterface sessionId={session?.id} /> {!isLoading &&
{error && <div>{error.message}</div>} <ChatInterface sessionId={session?.id} dbMessages={messages ?? []}/>
{isLoading && <Skeleton/>} }
{isLoading &&
<><Spinner/> Loading Messages...</>
}
{error &&
<div> {error} </div>
}
</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 lg:h-[calc(100vh-var(--spacing)*10)] 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

@@ -26,10 +26,9 @@ function AnimatedCard({
className, className,
position = 0, position = 0,
size = "default", size = "default",
tlId = undefined,
scrollOnly = false, scrollOnly = false,
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position, tlId?: string, scrollOnly?: boolean }) { }: 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(() => {
@@ -47,7 +46,7 @@ function AnimatedCard({
console.log(isInView) console.log(isInView)
const fromVars = { x: -100, opacity: 0, duration: 0.5 } const fromVars = { x: -100, opacity: 0, duration: 0.5 }
if (isInView && !scrollOnly) { if (isInView && !scrollOnly) {
gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position, tlId) gsapContext?.addAnimation(gsap.from(ref.current, fromVars), position)
} else { } else {
gsap.from(ref.current, gsap.from(ref.current,
{ {

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

@@ -75,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
}