Compare commits
3 Commits
additional
...
fb379b912a
| Author | SHA1 | Date | |
|---|---|---|---|
| fb379b912a | |||
| ffa475e876 | |||
| d85cc205de |
@@ -1,25 +0,0 @@
|
||||
'use server'
|
||||
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
|
||||
|
||||
export async function cancelMeeting({ eventId }: { eventId: string }) {
|
||||
try {
|
||||
const calendar = getGoogleCalendarClient()
|
||||
|
||||
await calendar.events.delete({
|
||||
calendarId: getGoogleCalendarId(),
|
||||
eventId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId,
|
||||
message: 'Meeting removed from Gregor availability calendar.',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel meeting:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to remove the meeting from Gregor availability calendar.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,7 @@
|
||||
'use server'
|
||||
import { clerkClient, auth } from '@clerk/nextjs/server'
|
||||
import { google } from 'googleapis'
|
||||
import { env } from '~/env'
|
||||
import { getGoogleCalendarClient, getGoogleCalendarId } from '~/server/googleCalendar'
|
||||
|
||||
function googleCalendarDate(date: Date) {
|
||||
return date.toISOString().replace(/[-:]|\.\d{3}/g, '')
|
||||
}
|
||||
|
||||
function createGoogleCalendarTemplateLink({
|
||||
title,
|
||||
description,
|
||||
startTime,
|
||||
endTime,
|
||||
gregorEmail,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
gregorEmail: string
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
action: 'TEMPLATE',
|
||||
text: title,
|
||||
dates: `${googleCalendarDate(startTime)}/${googleCalendarDate(endTime)}`,
|
||||
details: description,
|
||||
add: gregorEmail,
|
||||
})
|
||||
|
||||
return `https://calendar.google.com/calendar/render?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function scheduleMeeting({
|
||||
title,
|
||||
@@ -46,39 +19,59 @@ export async function scheduleMeeting({
|
||||
attendeeName?: string
|
||||
}) {
|
||||
try {
|
||||
const calendar = getGoogleCalendarClient()
|
||||
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
|
||||
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
|
||||
env.ADMIN_USER_CLERK_ID,
|
||||
'oauth_google',
|
||||
)
|
||||
const adminToken = adminTokenResponse.data[0]
|
||||
|
||||
if (!adminToken?.token) {
|
||||
return { success: false, error: 'Admin Google Calendar not connected. Ensure the admin account is linked with Google and has calendar scope enabled.' }
|
||||
}
|
||||
|
||||
// Try to resolve visitor's Google email for the invite
|
||||
let visitorEmail: string | undefined = attendeeEmail
|
||||
if (!visitorEmail) {
|
||||
visitorEmail = user?.emailAddresses.at(0)?.emailAddress ?? undefined
|
||||
}
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2()
|
||||
oAuth2Client.setCredentials({ access_token: adminToken.token })
|
||||
const calendar = google.calendar({ version: 'v3', auth: oAuth2Client })
|
||||
|
||||
const startTime = new Date(dateTime)
|
||||
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000)
|
||||
const attendeeNote = attendeeEmail
|
||||
? `\n\nVisitor: ${attendeeName ?? 'Unknown'} <${attendeeEmail}>`
|
||||
: ''
|
||||
const eventDescription = `${description}${attendeeNote}`
|
||||
|
||||
const eventRequest = {
|
||||
const attendees: { email: string; displayName?: string }[] = []
|
||||
if (visitorEmail) {
|
||||
attendees.push({ email: visitorEmail, displayName: attendeeName })
|
||||
}
|
||||
|
||||
const event = await calendar.events.insert({
|
||||
calendarId: 'primary',
|
||||
sendUpdates: 'all',
|
||||
requestBody: {
|
||||
summary: title,
|
||||
description: eventDescription,
|
||||
description,
|
||||
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
|
||||
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
|
||||
}
|
||||
const event = await calendar.events.insert({
|
||||
calendarId: getGoogleCalendarId(),
|
||||
requestBody: eventRequest,
|
||||
attendees,
|
||||
},
|
||||
sendNotifications: true
|
||||
})
|
||||
|
||||
const addToCalendarLink = createGoogleCalendarTemplateLink({
|
||||
title,
|
||||
description,
|
||||
startTime,
|
||||
endTime,
|
||||
gregorEmail: env.GREGOR_MEETING_EMAIL,
|
||||
})
|
||||
const inviteLink = event.data.htmlLink ?? undefined
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: event.data.id,
|
||||
addToCalendarLink,
|
||||
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}.${attendeeEmail ? ` Visitor email noted: ${attendeeEmail}.` : ''} The add-to-calendar link invites ${env.GREGOR_MEETING_EMAIL}.`,
|
||||
htmlLink: inviteLink,
|
||||
inviteLink,
|
||||
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}${inviteLink ? ` Calendar invite: ${inviteLink}` : ''}`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to schedule meeting:', error)
|
||||
|
||||
@@ -36,9 +36,7 @@ Runtime context:
|
||||
- Current server time: ${new Date().toISOString()}.
|
||||
- Default meeting timezone: Europe/Berlin.
|
||||
- For availability questions like "next open spot", call getAvailability once. It defaults to checking from now. Use nextAvailableSlot for the next opening, or the first item in availableSlots if needed. Do not call getAvailability again just to get more slots.
|
||||
- After scheduleMeeting succeeds, include only the returned addToCalendarLink for the visitor. Format it as a Markdown link like [Add this meeting to your Google Calendar](URL); do not paste the raw URL. Explain briefly that this link lets them add the meeting to their own calendar and invite Gregor. Do not mention internal Google Calendar event links.
|
||||
- You can remove meetings from Gregor's availability calendar with cancelMeeting only when you have the exact eventId from a previous scheduleMeeting result. If a visitor asks to reschedule and you have both the old eventId and a confirmed new slot, call cancelMeeting once for the old event and scheduleMeeting once for the new event. If you do not have the old eventId, ask for clarification instead of guessing.
|
||||
- When rescheduling, make clear that cancelMeeting only removes the old slot from Gregor's availability calendar. If the visitor already added the old link to their own calendar, they may need to remove that copy themselves.
|
||||
- After scheduleMeeting succeeds, include the returned inviteLink or htmlLink in your response.
|
||||
- Do not calculate or invent calendar availability yourself.`
|
||||
const model = await servTrpc.chat.getModel()
|
||||
|
||||
@@ -59,7 +57,7 @@ Runtime context:
|
||||
system: systemPrompt,
|
||||
messages: await convertToModelMessages(messages),
|
||||
tools: createChatTools(),
|
||||
stopWhen: stepCountIs(3),
|
||||
stopWhen: stepCountIs(2),
|
||||
onFinish: async ({ text, finishReason }) => {
|
||||
console.log('[ai:chat:onFinish]', {
|
||||
finishReason,
|
||||
|
||||
@@ -32,7 +32,7 @@ export default async function BlogPostPage({ params }: Props) {
|
||||
const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||
<main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{title}</h1>
|
||||
{date && (
|
||||
|
||||
@@ -6,7 +6,7 @@ export default async function BlogPage() {
|
||||
const posts = await servTrpc.blog.list();
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||
<main className="mx-auto h-full max-w-2xl overflow-y-auto px-4 py-12">
|
||||
<h1 className="mb-8 text-3xl font-bold">Blog</h1>
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-muted-foreground">No posts yet.</p>
|
||||
|
||||
@@ -11,8 +11,6 @@ function toolLabel(type: string) {
|
||||
return "Loading project details";
|
||||
case "tool-getAvailability":
|
||||
return "Checking availability";
|
||||
case "tool-cancelMeeting":
|
||||
return "Removing meeting";
|
||||
case "tool-getCurrentUnixTime":
|
||||
return "Checking current time";
|
||||
default:
|
||||
@@ -29,7 +27,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-muted break-words [overflow-wrap:anywhere] [&_a]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto'
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
@@ -42,7 +40,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; error?: string }
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; inviteLink?: string; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
@@ -53,32 +51,26 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
if (result.success) return null
|
||||
const inviteLink = result.inviteLink ?? result.htmlLink
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
{result.success ? (
|
||||
<span>
|
||||
✓ {result.message}{' '}
|
||||
{inviteLink && (
|
||||
<a
|
||||
href={inviteLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Open calendar invite
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>✗ {result.error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
if (part.type === 'tool-cancelMeeting') {
|
||||
const toolPart = part as unknown as {
|
||||
type: 'tool-cancelMeeting'
|
||||
state: string
|
||||
output?: { success: boolean; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
Removing meeting…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
if (toolPart.output.success) return null
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
<span>✗ {toolPart.output.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const UserMessage = (props:{message: UIMessage}) => {
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] min-w-0 px-4 py-2 text-sm space-y-2 bg-primary break-words whitespace-pre-wrap [overflow-wrap:anywhere]'
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
@@ -100,19 +100,12 @@ function PullQuote({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
||||
const { className, ...rest } = props;
|
||||
const href = props.href ?? "";
|
||||
const isExternal = /^https?:\/\//.test(href);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
"text-sky-600 underline underline-offset-4 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
if (!isExternal) return <a {...props} />;
|
||||
|
||||
return <a {...props} target="_blank" rel="noreferrer" />;
|
||||
}
|
||||
|
||||
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
|
||||
|
||||
@@ -31,10 +31,6 @@ export const env = createEnv({
|
||||
CLERK_SECRET_KEY: z.string(),
|
||||
ADMIN_USER_CLERK_ID: z.string(),
|
||||
OPENAI_API_KEY: z.string(),
|
||||
GOOGLE_SERVICE_ACCOUNT_EMAIL: z.string().email(),
|
||||
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(),
|
||||
GOOGLE_CALENDAR_ID: z.string(),
|
||||
GREGOR_MEETING_EMAIL: z.string().email(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
@@ -76,10 +72,6 @@ export const env = createEnv({
|
||||
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
|
||||
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
GOOGLE_SERVICE_ACCOUNT_EMAIL: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY,
|
||||
GOOGLE_CALENDAR_ID: process.env.GOOGLE_CALENDAR_ID,
|
||||
GREGOR_MEETING_EMAIL: process.env.GREGOR_MEETING_EMAIL,
|
||||
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
|
||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import "server-only";
|
||||
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { tool } from "ai";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
import { cancelMeeting } from "~/app/actions/cancelMeeting";
|
||||
import { scheduleMeeting } from "~/app/actions/scheduleMeeting";
|
||||
import { env } from "~/env";
|
||||
import { db } from "~/server/db";
|
||||
import { blogPost, music } from "~/server/dbschema/schema";
|
||||
import { getGoogleCalendarClient, getGoogleCalendarId } from "~/server/googleCalendar";
|
||||
|
||||
const contentTypeSchema = z.enum(["cv", "project", "blog", "music"]);
|
||||
|
||||
@@ -24,6 +25,9 @@ type SearchResult = {
|
||||
|
||||
type ProjectWithStack = Awaited<ReturnType<typeof loadProjects>>[number];
|
||||
|
||||
let cachedAdminGoogleToken: { token: string; expiresAt: number } | undefined;
|
||||
let adminGoogleTokenRequest: Promise<string | undefined> | undefined;
|
||||
|
||||
function stripMarkup(value: string | null | undefined) {
|
||||
return (value ?? "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
@@ -360,6 +364,83 @@ function logAvailability(requestId: string, message: string, data?: Record<strin
|
||||
console.log(`[ai:getAvailability:${requestId}] ${message}`, data ?? "");
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function getAdminGoogleToken(requestId: string) {
|
||||
if (cachedAdminGoogleToken && cachedAdminGoogleToken.expiresAt > Date.now()) {
|
||||
logAvailability(requestId, "admin oauth token cache hit");
|
||||
return cachedAdminGoogleToken.token;
|
||||
}
|
||||
|
||||
if (!adminGoogleTokenRequest) {
|
||||
adminGoogleTokenRequest = (async () => {
|
||||
logAvailability(requestId, "admin oauth token request start");
|
||||
const clerk = await clerkClient();
|
||||
const adminTokenResponse = await clerk.users.getUserOauthAccessToken(
|
||||
env.ADMIN_USER_CLERK_ID,
|
||||
"oauth_google",
|
||||
);
|
||||
const token = adminTokenResponse.data[0]?.token;
|
||||
if (token) {
|
||||
cachedAdminGoogleToken = {
|
||||
token,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
return token;
|
||||
})().finally(() => {
|
||||
adminGoogleTokenRequest = undefined;
|
||||
});
|
||||
} else {
|
||||
logAvailability(requestId, "admin oauth token request joined");
|
||||
}
|
||||
|
||||
return withTimeout(
|
||||
adminGoogleTokenRequest,
|
||||
5000,
|
||||
"Timed out while loading the admin Google OAuth token.",
|
||||
);
|
||||
}
|
||||
|
||||
async function getAdminCalendar(requestId: string) {
|
||||
let adminToken: string | undefined;
|
||||
try {
|
||||
adminToken = await getAdminGoogleToken(requestId);
|
||||
} catch (error) {
|
||||
console.error(`[ai:getAvailability:${requestId}] admin oauth token failed`, error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Timed out while loading Gregor's Google Calendar connection.",
|
||||
};
|
||||
}
|
||||
|
||||
logAvailability(requestId, "admin oauth token resolved", {
|
||||
hasToken: Boolean(adminToken),
|
||||
});
|
||||
|
||||
if (!adminToken) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Admin Google Calendar is not connected or does not expose a Google OAuth token.",
|
||||
};
|
||||
}
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2();
|
||||
oAuth2Client.setCredentials({ access_token: adminToken });
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
calendar: google.calendar({ version: "v3", auth: oAuth2Client }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createChatTools() {
|
||||
return {
|
||||
scheduleMeeting: tool({
|
||||
@@ -393,13 +474,6 @@ export function createChatTools() {
|
||||
return scheduleMeeting({ ...input });
|
||||
},
|
||||
}),
|
||||
cancelMeeting: tool({
|
||||
description: "Remove a previously scheduled meeting from Gregor's dedicated availability calendar. Use only when you have the exact eventId returned by scheduleMeeting.",
|
||||
inputSchema: z.object({
|
||||
eventId: z.string().min(1).describe("Google Calendar event id returned by a previous scheduleMeeting tool call."),
|
||||
}),
|
||||
execute: async ({ eventId }) => cancelMeeting({ eventId }),
|
||||
}),
|
||||
searchSiteContent: tool({
|
||||
description: "Search Gregor Lohaus's own website content across CV entries, projects, blog posts, and music. Use this for questions about Gregor's work, skills, writing, projects, or site content. For broad questions about Gregor's projects, use types ['project'] so the tool returns the project catalog.",
|
||||
inputSchema: z.object({
|
||||
@@ -515,24 +589,27 @@ export function createChatTools() {
|
||||
rangeEnd: rangeEnd.toISOString(),
|
||||
});
|
||||
|
||||
const calendar = getGoogleCalendarClient();
|
||||
const calendarId = getGoogleCalendarId();
|
||||
logAvailability(requestId, "service account calendar ready", {
|
||||
calendarId,
|
||||
const adminCalendar = await getAdminCalendar(requestId);
|
||||
|
||||
if (!adminCalendar.success) {
|
||||
logAvailability(requestId, "admin calendar unavailable", {
|
||||
error: adminCalendar.error,
|
||||
});
|
||||
return adminCalendar;
|
||||
}
|
||||
|
||||
let busy: Array<{ start: Date; end: Date }>;
|
||||
try {
|
||||
logAvailability(requestId, "freebusy request");
|
||||
const response = await calendar.freebusy.query({
|
||||
const response = await adminCalendar.calendar.freebusy.query({
|
||||
requestBody: {
|
||||
timeMin: rangeStart.toISOString(),
|
||||
timeMax: rangeEnd.toISOString(),
|
||||
timeZone,
|
||||
items: [{ id: calendarId }],
|
||||
items: [{ id: "primary" }],
|
||||
},
|
||||
});
|
||||
busy = (response.data.calendars?.[calendarId]?.busy ?? [])
|
||||
busy = (response.data.calendars?.["primary"]?.busy ?? [])
|
||||
.map((item) => ({
|
||||
start: parseDate(item.start ?? undefined, rangeStart),
|
||||
end: parseDate(item.end ?? undefined, rangeStart),
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { google } from "googleapis";
|
||||
import { env } from "~/env";
|
||||
|
||||
const calendarScope = "https://www.googleapis.com/auth/calendar";
|
||||
|
||||
export function getGoogleCalendarClient() {
|
||||
const auth = new google.auth.JWT({
|
||||
email: env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY.replace(/\\n/g, "\n"),
|
||||
scopes: [calendarScope],
|
||||
});
|
||||
|
||||
return google.calendar({ version: "v3", auth });
|
||||
}
|
||||
|
||||
export function getGoogleCalendarId() {
|
||||
return env.GOOGLE_CALENDAR_ID;
|
||||
}
|
||||
Reference in New Issue
Block a user