Files
gregorlohaus.com/src/app/chat/_components/ChatInterface.tsx
2026-03-31 14:03:41 +02:00

162 lines
4.2 KiB
TypeScript

'use client'
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 {
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[]
}
function toUIMessages(dbMessages: DBMessage[]): UIMessage[] {
return dbMessages.map((m) => ({
id: m.id,
role: m.role,
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') {
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 || 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">
{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">
<span className="flex-1">
{error.message.includes('quota') || error.message.includes('429')
? 'OpenAI quota exceeded. Please try again later.'
: `Error: ${error.message}`}
</span>
<Button
type="button"
onClick={clearError}
className="shrink-0 opacity-60 hover:opacity-100"
variant='destructive'
>
<DeleteIcon />
</Button>
</div>
)}
<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…"
className="resize-none"
rows={2}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
/>
<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>
)
}