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()
return (
<Dialog open onOpenChange={() => router.back()}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
<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>
</DialogHeader>

View File

@@ -1,34 +1,15 @@
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
'use client'
import { Skeleton } from '~/components/ui/skeleton';
import ChatModal from './_components/ChatModal'
import { trpc } from '~/app/_trpc/Client'
export default async function ChatModalPage() {
const { userId } = await auth()
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 <ChatModal sessionId={session.id} initialMessages={messages} />
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

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

View File

@@ -26,6 +26,9 @@ export default function AdminSideBar() {
<SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link>
</SimpleSidebarGroup>
<SimpleSidebarGroup lable="Chat">
<Link href={"/admin/chat"}> System Prompt </Link>
</SimpleSidebarGroup>
</SidebarContent>
</Sidebar>
</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 { db } from '~/server/db'
import { chatSession, chatMessage } from '~/server/dbschema/schema'
import { servTrpc } from '~/app/_trpc/ServerClient'
import { scheduleMeeting } from '~/app/actions/scheduleMeeting'
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
export async function POST(req: Request) {
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 {
messages: UIMessage[]
@@ -29,6 +30,8 @@ export async function POST(req: Request) {
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
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') {
@@ -43,16 +46,7 @@ export async function POST(req: Request) {
const result = streamText({
model: openai('gpt-4o'),
system: `You are an AI recruiter assistant on Gregor Lohaus's personal portfolio website.
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.`,
system: systemPrompt,
messages: await convertToModelMessages(messages),
tools: {
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 { 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 = {
id: string
@@ -29,7 +32,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const { messages, sendMessage, status } = useChat({
const { messages, sendMessage, status, error, clearError } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
body: { sessionId },
@@ -38,6 +41,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
})
const isLoading = status === 'submitted' || status === 'streaming'
const hasError = status === 'error'
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -61,67 +65,10 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
)}
{messages.map((message) => (
<div
key={message.id}
className={cn('flex', message.role === 'user' ? 'justify-end' : 'justify-start')}
>
<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>
<>
{message.role == 'assistant' && <AssistantMessage message={message}/>}
{message.role == 'user' && <UserMessage message={message}/>}
</>
))}
{isLoading && (
@@ -135,6 +82,24 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
<div ref={messagesEndRef} />
</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">
<Textarea
value={input}
@@ -151,7 +116,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
/>
<Button
onClick={handleSend}
disabled={isLoading || !input.trim()}
disabled={isLoading || hasError || !input.trim()}
className="self-end"
>
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'
import { redirect } from 'next/navigation'
import { asc, desc, eq } from 'drizzle-orm'
import { db } from '~/server/db'
import { chatMessage, chatSession } from '~/server/dbschema/schema'
'use client'
import ChatInterface from './_components/ChatInterface'
import { trpc } from '../_trpc/Client';
import { Skeleton } from '~/components/ui/skeleton';
export default async function ChatPage() {
const { userId } = await auth()
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))
export default function ChatPage() {
const { data: session, error, isLoading } = trpc.chat.getSession.useQuery();
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">
@@ -42,7 +17,9 @@ export default async function ChatPage() {
</div>
</div>
<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>

View File

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

View File

@@ -137,3 +137,10 @@ export const chatMessageRelations = relations(chatMessage, ({ one }) => ({
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 { trpcCrudRouterFromDrizzleEntity } from "../lib";
import { cvCategory } from "../dbschema/schema";
import { chatRouter } from "./chat";
export const trpcRouter = router({
project: trpcCrudRouterFromDrizzleEntity('project').router,
@@ -19,6 +20,7 @@ export const trpcRouter = router({
entry: trpcCrudRouterFromDrizzleEntity('cvEntry').router,
entryv2: cvEntryRouter,
music: musicRouter,
chat: chatRouter
});
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 })
}),
})