This commit is contained in:
2026-03-30 18:06:38 +02:00
parent 34dc53a8e9
commit 18abc4b3f7
16 changed files with 301 additions and 140 deletions

View File

@@ -18,8 +18,8 @@ export default function ChatModal({ sessionId, initialMessages }: ChatModalProps
const router = useRouter() const router = useRouter()
return ( return (
<Dialog open onOpenChange={() => router.back()}> <Dialog modal={true} open onOpenChange={() => router.back()}>
<DialogContent className="max-w-2xl 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>AI Recruiter</DialogTitle>
</DialogHeader> </DialogHeader>

View File

@@ -1,34 +1,15 @@
import { auth } from '@clerk/nextjs/server' 'use client'
import { redirect } from 'next/navigation' import { Skeleton } from '~/components/ui/skeleton';
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
import ChatModal from './_components/ChatModal' import ChatModal from './_components/ChatModal'
import { trpc } from '~/app/_trpc/Client'
export default async function ChatModalPage() { export default function ChatModalPage() {
const { userId } = await auth() const { data: session, error, isLoading } = trpc.chat.getSession.useQuery();
if (!userId) redirect('/') return (
<>
let session = await db {session && <ChatModal sessionId={session.id} initialMessages={session.messages} />}
.select() {error && <div>{error.message}</div>}
.from(chatSession) {isLoading && <Skeleton />}
.where(eq(chatSession.userId, userId)) </>
.orderBy(desc(chatSession.createdAt)) )
.limit(1)
.then((r) => r[0])
if (!session) {
const [created] = await db.insert(chatSession).values({ userId }).returning()
session = created
}
if (!session) redirect('/')
const messages = await db
.select()
.from(chatMessage)
.where(eq(chatMessage.sessionId, session.id))
.orderBy(asc(chatMessage.createdAt))
return <ChatModal sessionId={session.id} initialMessages={messages} />
} }

View File

@@ -0,0 +1,19 @@
'use client'
import Link from 'next/link'
import { MessageCircle } from 'lucide-react'
import { Show } from '@clerk/nextjs'
import { Button } from '~/components/ui/button'
export default function ChatFAB() {
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>
)
}

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">
<Link href={"/chat"}> Chat </Link> <a href="/chat"> Chat </a>
</Button> </Button>
</Show> </Show>
</div> </div>
@@ -52,7 +52,13 @@ export default function TopNav() {
<Show when="signed-in"> <Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}> <Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
<div> <div>
<UserButton /> <UserButton
userProfileProps={{
additionalOAuthScopes: {
google: ['https://www.googleapis.com/auth/calendar'],
},
}}
/>
</div> </div>
</Button> </Button>
</Show> </Show>

View File

@@ -26,6 +26,9 @@ export default function AdminSideBar() {
<SimpleSidebarGroup lable="Blog"> <SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link> <Link href={"/"}> Some Blog Action </Link>
</SimpleSidebarGroup> </SimpleSidebarGroup>
<SimpleSidebarGroup lable="Chat">
<Link href={"/admin/chat"}> System Prompt </Link>
</SimpleSidebarGroup>
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
</SidebarProvider> </SidebarProvider>

View File

@@ -0,0 +1,39 @@
'use client'
import { useState } from 'react'
import { Textarea } from '~/components/ui/textarea'
import { Button } from '~/components/ui/button'
import { trpc } from '~/app/_trpc/Client'
export default function SystemPromptForm({ initialValue }: { initialValue: string }) {
const [value, setValue] = useState(initialValue)
const [saved, setSaved] = useState(false)
const mutation = trpc.chat.updateSystemPrompt.useMutation({
onSuccess: () => setSaved(true),
})
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaved(false)
mutation.mutate({ prompt: value })
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full">
<Textarea
value={value}
onChange={(e) => { setValue(e.target.value); setSaved(false) }}
rows={16}
className="font-mono text-sm resize-y"
placeholder="Enter the system prompt for the AI recruiter..."
/>
<div className="flex items-center gap-3">
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving…' : 'Save'}
</Button>
{saved && <span className="text-sm text-muted-foreground">Saved</span>}
{mutation.error && <span className="text-sm text-destructive">{mutation.error.message}</span>}
</div>
</form>
)
}

View File

@@ -0,0 +1,22 @@
import { isAdmin } from '~/app/actions'
import { redirect } from 'next/navigation'
import { servTrpc } from '~/app/_trpc/ServerClient'
import SystemPromptForm from './_components/SystemPromptForm'
export default async function SystemPromptPage() {
if (!(await isAdmin())) redirect('/admin')
const prompt = await servTrpc.chat.getSystemPrompt()
return (
<div className="w-full max-w-2xl p-6 flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold">AI System Prompt</h1>
<p className="text-sm text-muted-foreground">
This prompt is sent to the model on every chat request.
</p>
</div>
<SystemPromptForm initialValue={prompt} />
</div>
)
}

View File

@@ -6,13 +6,14 @@ 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 { scheduleMeeting } from '~/app/actions/scheduleMeeting' import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY }) const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
export async function POST(req: Request) { export async function POST(req: Request) {
const { userId } = await auth() const { userId } = await auth()
if (!userId) return new Response('Unauthorized', { status: 401 }) if (userId == null) return new Response('Unauthorized', { status: 401 })
const { messages, sessionId } = (await req.json()) as { const { messages, sessionId } = (await req.json()) as {
messages: UIMessage[] messages: UIMessage[]
@@ -29,6 +30,8 @@ export async function POST(req: Request) {
if (!session) return new Response('Session not found', { status: 404 }) if (!session) return new Response('Session not found', { status: 404 })
const systemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
// Save the latest user message // Save the latest user message
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') { if (lastMessage?.role === 'user') {
@@ -43,16 +46,7 @@ export async function POST(req: Request) {
const result = streamText({ const result = streamText({
model: openai('gpt-4o'), model: openai('gpt-4o'),
system: `You are an AI recruiter assistant on Gregor Lohaus's personal portfolio website. system: systemPrompt,
Your role is to help visitors learn about Gregor's background, skills, and experience, and to schedule meetings.
About Gregor:
- Fullstack developer specialising in TypeScript, React, Next.js, and modern web technologies
- Experienced with Drizzle ORM, tRPC, PostgreSQL, server components, and the T3 stack
- Also experienced with React Native, Expo, Java/Spring, gRPC, AWS, and Linux/Debian server administration
- Open to new opportunities and collaboration
Be professional, friendly, and concise. When a visitor wants to schedule a meeting, collect the necessary details (preferred date/time, duration, and optionally their name/email) and use the scheduleMeeting tool.`,
messages: await convertToModelMessages(messages), messages: await convertToModelMessages(messages),
tools: { tools: {
scheduleMeeting: tool({ scheduleMeeting: tool({

View File

@@ -0,0 +1,68 @@
import type { UIMessage } from "ai";
import Markdown from "react-markdown";
import { cn } from "~/lib/utils";
export const AssistantMessage = (props: { message: UIMessage }) => {
let message = props.message;
return (
<div
key={message.id}
className='flex justify-start'
>
<div
className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return (
<Markdown>
{part.text}
</Markdown>
)
}
if (part.type === 'tool-scheduleMeeting') {
const toolPart = part as unknown as {
type: 'tool-scheduleMeeting'
state: string
input: unknown
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Scheduling meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
{result.success ? (
<span>
{result.message}{' '}
{result.htmlLink && (
<a
href={result.htmlLink}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View event
</a>
)}
</span>
) : (
<span> {result.error}</span>
)}
</div>
)
}
}
return null
})}
</div>
</div>
)
}

View File

@@ -5,6 +5,9 @@ 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 { cn } from '~/lib/utils'
import Markdown from 'react-markdown';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
type DBMessage = { type DBMessage = {
id: string id: string
@@ -29,7 +32,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
const [input, setInput] = useState('') const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const { messages, sendMessage, status } = useChat({ const { messages, sendMessage, status, error, clearError } = useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: '/api/chat', api: '/api/chat',
body: { sessionId }, body: { sessionId },
@@ -38,6 +41,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
}) })
const isLoading = status === 'submitted' || status === 'streaming' const isLoading = status === 'submitted' || status === 'streaming'
const hasError = status === 'error'
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -61,67 +65,10 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
)} )}
{messages.map((message) => ( {messages.map((message) => (
<div <>
key={message.id} {message.role == 'assistant' && <AssistantMessage message={message}/>}
className={cn('flex', message.role === 'user' ? 'justify-end' : 'justify-start')} {message.role == 'user' && <UserMessage message={message}/>}
> </>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2 text-sm space-y-2',
message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted',
)}
>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return (
<p key={i} className="whitespace-pre-wrap">
{part.text}
</p>
)
}
if (part.type === 'tool-scheduleMeeting') {
const toolPart = part as unknown as {
type: 'tool-scheduleMeeting'
state: string
input: unknown
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
<p key={i} className="text-xs opacity-70 italic">
Scheduling meeting
</p>
)
}
if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output
return (
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
{result.success ? (
<span>
{result.message}{' '}
{result.htmlLink && (
<a
href={result.htmlLink}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View event
</a>
)}
</span>
) : (
<span> {result.error}</span>
)}
</div>
)
}
}
return null
})}
</div>
</div>
))} ))}
{isLoading && ( {isLoading && (
@@ -135,6 +82,24 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{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"
aria-label="Dismiss"
>
</button>
</div>
)}
<div className="p-4 border-t flex gap-2"> <div className="p-4 border-t flex gap-2">
<Textarea <Textarea
value={input} value={input}
@@ -151,7 +116,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
/> />
<Button <Button
onClick={handleSend} onClick={handleSend}
disabled={isLoading || !input.trim()} disabled={isLoading || hasError || !input.trim()}
className="self-end" className="self-end"
> >
Send Send

View File

@@ -0,0 +1,23 @@
import type { UIMessage } from "ai"
export const UserMessage = (props:{message: UIMessage}) => {
let message = props.message.parts.reduce((acc, part) => {
if (part.type == 'text') {
return acc + part.text
}
return acc
},"");
return (
<div
key={props.message.id}
className='flex justify-end'
>
<div
className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
>
{message}
</div>
</div>
)
}

View File

@@ -1,35 +1,10 @@
import { auth } from '@clerk/nextjs/server' 'use client'
import { redirect } from 'next/navigation'
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
import ChatInterface from './_components/ChatInterface' import ChatInterface from './_components/ChatInterface'
import { trpc } from '../_trpc/Client';
import { Skeleton } from '~/components/ui/skeleton';
export default async function ChatPage() { export default function ChatPage() {
const { userId } = await auth() const { data: session, error, isLoading } = trpc.chat.getSession.useQuery();
if (!userId) redirect('/')
let session = await db
.select()
.from(chatSession)
.where(eq(chatSession.userId, userId))
.orderBy(desc(chatSession.createdAt))
.limit(1)
.then((r) => r[0])
if (!session) {
const [created] = await db.insert(chatSession).values({ userId }).returning()
session = created
}
if (!session) redirect('/')
const messages = await db
.select()
.from(chatMessage)
.where(eq(chatMessage.sessionId, session.id))
.orderBy(asc(chatMessage.createdAt))
return ( return (
<div className="container max-w-2xl mx-auto h-screen pt-10 pb-4 flex flex-col"> <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="flex flex-col flex-1 bg-background border rounded-lg overflow-hidden">
@@ -42,7 +17,9 @@ export default async function ChatPage() {
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ChatInterface sessionId={session.id} initialMessages={messages} /> {session && <ChatInterface sessionId={session?.id} initialMessages={session?.messages} /> }
{error && <div>{error.message}</div>}
{isLoading && <Skeleton/>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { ClerkProvider } from "@clerk/nextjs";
import { config } from "@fortawesome/fontawesome-svg-core" import { config } from "@fortawesome/fontawesome-svg-core"
import "@fortawesome/fontawesome-svg-core/styles.css" import "@fortawesome/fontawesome-svg-core/styles.css"
import TopNav from "./_components/TopNav"; import TopNav from "./_components/TopNav";
import ChatFAB from "./_components/ChatFAB";
import TrpcProvider from "./_trpc/TrpcProvider"; import TrpcProvider from "./_trpc/TrpcProvider";
// import dynamic from "next/dynamic"; // import dynamic from "next/dynamic";
// const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true}) // const ThemeProvider = dynamic(() => import("./_providers/ThemeProvider"),{ssr:true})
@@ -51,6 +52,7 @@ export default async function RootLayout({
</main> </main>
{modal} {modal}
</AnimatedBackGroundContainer> </AnimatedBackGroundContainer>
<ChatFAB />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -137,3 +137,10 @@ export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
references: [chatSession.id], references: [chatSession.id],
}), }),
})) }))
export const systemSettings = createTable(
"systemSetting",
(d) => ({
systemPropmt: d.text()
})
)

View File

@@ -8,6 +8,7 @@ import { cvEntryRouter } from "./cvEntry";
import { musicRouter } from "./music"; import { musicRouter } from "./music";
import { trpcCrudRouterFromDrizzleEntity } from "../lib"; import { trpcCrudRouterFromDrizzleEntity } from "../lib";
import { cvCategory } from "../dbschema/schema"; import { cvCategory } from "../dbschema/schema";
import { chatRouter } from "./chat";
export const trpcRouter = router({ export const trpcRouter = router({
project: trpcCrudRouterFromDrizzleEntity('project').router, project: trpcCrudRouterFromDrizzleEntity('project').router,
@@ -19,6 +20,7 @@ export const trpcRouter = router({
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router, entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
entryv2: cvEntryRouter, entryv2: cvEntryRouter,
music: musicRouter, music: musicRouter,
chat: chatRouter
}); });
export type TrpcRouter = typeof trpcRouter; export type TrpcRouter = typeof trpcRouter;

View File

@@ -0,0 +1,53 @@
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 { isAdmin } from '~/app/actions';
import { z } from 'zod';
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'});
}
let session = await db.query.chatSession.findFirst({
with: {
messages: true
},
where(fields, operators) {
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));
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 () => {
const row = await db.select().from(systemSettings).limit(1).then((r) => r[0])
return row?.systemPropmt ?? ''
}),
updateSystemPrompt: publicProcedure.input(z.object({ prompt: z.string() })).mutation(async ({ input }) => {
if (!(await isAdmin())) throw new TRPCError({ code: 'FORBIDDEN' })
await db.delete(systemSettings)
await db.insert(systemSettings).values({ systemPropmt: input.prompt })
}),
})