Merge branch 'additional-ai-tools'

This commit is contained in:
2026-06-18 05:37:16 +02:00
9 changed files with 167 additions and 167 deletions

View File

@@ -0,0 +1,25 @@
'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.',
}
}
}

View File

@@ -1,7 +1,34 @@
'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,
@@ -19,59 +46,39 @@ export async function scheduleMeeting({
attendeeName?: string
}) {
try {
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 calendar = getGoogleCalendarClient()
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 attendees: { email: string; displayName?: string }[] = []
if (visitorEmail) {
attendees.push({ email: visitorEmail, displayName: attendeeName })
const eventRequest = {
summary: title,
description: eventDescription,
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
}
const event = await calendar.events.insert({
calendarId: 'primary',
sendUpdates: 'all',
requestBody: {
summary: title,
description,
start: { dateTime: startTime.toISOString(), timeZone: 'UTC' },
end: { dateTime: endTime.toISOString(), timeZone: 'UTC' },
attendees,
},
sendNotifications: true
calendarId: getGoogleCalendarId(),
requestBody: eventRequest,
})
const inviteLink = event.data.htmlLink ?? undefined
const addToCalendarLink = createGoogleCalendarTemplateLink({
title,
description,
startTime,
endTime,
gregorEmail: env.GREGOR_MEETING_EMAIL,
})
return {
success: true,
eventId: event.data.id,
htmlLink: inviteLink,
inviteLink,
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}${inviteLink ? ` Calendar invite: ${inviteLink}` : ''}`,
addToCalendarLink,
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}.${attendeeEmail ? ` Visitor email noted: ${attendeeEmail}.` : ''} The add-to-calendar link invites ${env.GREGOR_MEETING_EMAIL}.`,
}
} catch (error) {
console.error('Failed to schedule meeting:', error)

View File

@@ -36,7 +36,9 @@ 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 the returned inviteLink or htmlLink in your response.
- 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.
- Do not calculate or invent calendar availability yourself.`
const model = await servTrpc.chat.getModel()
@@ -57,7 +59,7 @@ Runtime context:
system: systemPrompt,
messages: await convertToModelMessages(messages),
tools: createChatTools(),
stopWhen: stepCountIs(2),
stopWhen: stepCountIs(3),
onFinish: async ({ text, finishReason }) => {
console.log('[ai:chat:onFinish]', {
finishReason,

View File

@@ -11,6 +11,8 @@ 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:
@@ -27,7 +29,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
>
<div
className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
'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'
>
{message.parts.map((part, i) => {
if (part.type === 'text') {
@@ -35,12 +37,12 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
<ClientMdx key={i} source={part.text} fallback={part.text} />
)
}
if (part.type === 'tool-scheduleMeeting') {
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; inviteLink?: string; error?: string }
output?: { success: boolean; error?: string }
}
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
return (
@@ -48,33 +50,39 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
Scheduling meeting
</p>
)
}
}
if (toolPart.state === 'output-available' && toolPart.output) {
const result = toolPart.output
const inviteLink = result.inviteLink ?? result.htmlLink
if (result.success) return null
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>
)}
<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>
)
}
}
if (part.type.startsWith('tool-')) {
const toolPart = part as unknown as {
type: string

View File

@@ -14,7 +14,7 @@ export const UserMessage = (props:{message: UIMessage}) => {
>
<div
className=
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
'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]'
>
{message}
</div>