Merge branch 'additional-ai-tools'
This commit is contained in:
@@ -64,11 +64,14 @@ export async function scheduleMeeting({
|
||||
sendNotifications: true
|
||||
})
|
||||
|
||||
const inviteLink = event.data.htmlLink ?? undefined
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: event.data.id,
|
||||
htmlLink: event.data.htmlLink,
|
||||
message: `Meeting "${title}" scheduled for ${startTime.toLocaleString()}${visitorEmail ? `. Invite sent to ${visitorEmail}.` : '.'}`,
|
||||
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)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { streamText, tool, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
|
||||
import { success, z } from 'zod'
|
||||
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai'
|
||||
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'
|
||||
import currentTime from '~/app/actions/currentTime';
|
||||
import { createChatTools } from '~/server/ai/tools'
|
||||
|
||||
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
|
||||
|
||||
@@ -31,7 +29,15 @@ 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.'
|
||||
const configuredSystemPrompt = await servTrpc.chat.getSystemPrompt() || 'You are an AI recruiter assistant.'
|
||||
const systemPrompt = `${configuredSystemPrompt}
|
||||
|
||||
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.
|
||||
- Do not calculate or invent calendar availability yourself.`
|
||||
const model = await servTrpc.chat.getModel()
|
||||
|
||||
// Save the latest user message
|
||||
@@ -50,42 +56,14 @@ export async function POST(req: Request) {
|
||||
model: openai(model),
|
||||
system: systemPrompt,
|
||||
messages: await convertToModelMessages(messages),
|
||||
tools: {
|
||||
scheduleMeeting: tool({
|
||||
description: 'Schedule a meeting with Gregor Lohaus and add it to his Google Calendar',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Meeting title, make something up if not provided'),
|
||||
description: z.string().describe('Meeting description / agenda, make something up if not provided'),
|
||||
dateTime: z
|
||||
.string()
|
||||
.describe(
|
||||
'ISO 8601 datetime for the meeting start, e.g. 2025-04-01T10:00:00',
|
||||
),
|
||||
durationMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(15)
|
||||
.max(120)
|
||||
.describe('Duration of the meeting in minutes, if none provided ask if 20 minutes is ok'),
|
||||
attendeeEmail: z
|
||||
.string()
|
||||
.email()
|
||||
.optional()
|
||||
.describe('Optional Email of the visitor to invite (if provided)'),
|
||||
attendeeName: z.string().optional().describe('Name of the visitor'),
|
||||
}),
|
||||
execute: async (input) => scheduleMeeting({ ...input }),
|
||||
}),
|
||||
getCurrentUnixTime: tool({
|
||||
description: 'Get the current unix time to reference for meeting dates',
|
||||
inputSchema: z.object({
|
||||
none: z.string().optional().describe("no inputs are needed")
|
||||
}),
|
||||
execute: async () => currentTime()
|
||||
})
|
||||
},
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: createChatTools(),
|
||||
stopWhen: stepCountIs(2),
|
||||
onFinish: async ({ text, finishReason }) => {
|
||||
console.log('[ai:chat:onFinish]', {
|
||||
finishReason,
|
||||
hasText: Boolean(text),
|
||||
textLength: text.length,
|
||||
})
|
||||
if (text && finishReason === 'stop') {
|
||||
await db.insert(chatMessage).values({
|
||||
sessionId,
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { ClientMdx } from "~/components/ClientMdx";
|
||||
|
||||
function toolLabel(type: string) {
|
||||
switch (type) {
|
||||
case "tool-searchSiteContent":
|
||||
return "Searching site content";
|
||||
case "tool-getRelevantExperience":
|
||||
return "Finding relevant experience";
|
||||
case "tool-getProjectDetails":
|
||||
return "Loading project details";
|
||||
case "tool-getAvailability":
|
||||
return "Checking availability";
|
||||
case "tool-getCurrentUnixTime":
|
||||
return "Checking current time";
|
||||
default:
|
||||
return "Using tool";
|
||||
}
|
||||
}
|
||||
|
||||
export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
let message = props.message;
|
||||
return (
|
||||
@@ -19,12 +36,12 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
const toolPart = part as unknown as {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; inviteLink?: string; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
@@ -32,33 +49,62 @@ export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
</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>
|
||||
)}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const toolPart = part as unknown as {
|
||||
type: string
|
||||
state: string
|
||||
output?: { success?: boolean; results?: unknown[]; matches?: unknown[]; availableSlots?: unknown[]; project?: unknown; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
{toolLabel(toolPart.type)}...
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (toolPart.state === 'output-available') {
|
||||
const count = toolPart.output?.results?.length
|
||||
?? toolPart.output?.matches?.length
|
||||
?? toolPart.output?.availableSlots?.length
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
{toolPart.output?.success === false
|
||||
? (toolPart.output.error ?? `${toolLabel(toolPart.type)} failed`)
|
||||
: count != null
|
||||
? `${toolLabel(toolPart.type)} complete (${count})`
|
||||
: `${toolLabel(toolPart.type)} complete`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ function addInitMessage(messageArray: UIMessage[]) {
|
||||
role: 'assistant',
|
||||
parts: [{
|
||||
type: 'text',
|
||||
text: "Hi im gregors ai assistant,you can ask me to provide general information or to schedule a meeting."
|
||||
text: "Hi, I'm Gregor's AI assistant. Ask me about his experience, projects, blog posts, or availability for a meeting."
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
663
src/server/ai/tools.ts
Normal file
663
src/server/ai/tools.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
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 { scheduleMeeting } from "~/app/actions/scheduleMeeting";
|
||||
import { env } from "~/env";
|
||||
import { db } from "~/server/db";
|
||||
import { blogPost, music } from "~/server/dbschema/schema";
|
||||
|
||||
const contentTypeSchema = z.enum(["cv", "project", "blog", "music"]);
|
||||
|
||||
type ContentType = z.infer<typeof contentTypeSchema>;
|
||||
|
||||
type SearchResult = {
|
||||
type: ContentType;
|
||||
title: string;
|
||||
snippet: string;
|
||||
url: string;
|
||||
score: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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, " ")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/[#*_~[\]()>-]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tokenize(value: string) {
|
||||
return Array.from(new Set(value.toLowerCase().match(/[a-z0-9+#.-]+/g) ?? []));
|
||||
}
|
||||
|
||||
function scoreText(query: string, title: string, body: string, extraTerms: string[] = []) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const titleLower = title.toLowerCase();
|
||||
const bodyLower = body.toLowerCase();
|
||||
const tokens = tokenize(query);
|
||||
let score = 0;
|
||||
|
||||
if (normalizedQuery && titleLower.includes(normalizedQuery)) score += 40;
|
||||
if (normalizedQuery && bodyLower.includes(normalizedQuery)) score += 20;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (titleLower.includes(token)) score += 12;
|
||||
if (bodyLower.includes(token)) score += 6;
|
||||
if (extraTerms.some((term) => term.toLowerCase() === token)) score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
const projectCatalogTerms = new Set(["project", "projects", "portfolio", "work"]);
|
||||
const genericQuestionTerms = new Set([
|
||||
"a",
|
||||
"about",
|
||||
"all",
|
||||
"any",
|
||||
"are",
|
||||
"can",
|
||||
"current",
|
||||
"do",
|
||||
"give",
|
||||
"have",
|
||||
"list",
|
||||
"me",
|
||||
"of",
|
||||
"on",
|
||||
"show",
|
||||
"site",
|
||||
"tell",
|
||||
"the",
|
||||
"there",
|
||||
"these",
|
||||
"this",
|
||||
"what",
|
||||
"which",
|
||||
"you",
|
||||
]);
|
||||
|
||||
function isProjectCatalogQuery(query: string) {
|
||||
const tokens = tokenize(query);
|
||||
if (!tokens.some((token) => projectCatalogTerms.has(token))) return false;
|
||||
return tokens.every((token) => projectCatalogTerms.has(token) || genericQuestionTerms.has(token));
|
||||
}
|
||||
|
||||
function snippet(value: string, query: string, maxLength = 240) {
|
||||
const text = stripMarkup(value);
|
||||
if (text.length <= maxLength) return text;
|
||||
|
||||
const tokens = tokenize(query);
|
||||
const lower = text.toLowerCase();
|
||||
const firstMatch = tokens
|
||||
.map((token) => lower.indexOf(token))
|
||||
.filter((index) => index >= 0)
|
||||
.sort((a, b) => a - b)[0] ?? 0;
|
||||
const start = Math.max(0, firstMatch - 60);
|
||||
const excerpt = text.slice(start, start + maxLength).trim();
|
||||
|
||||
return `${start > 0 ? "..." : ""}${excerpt}${start + maxLength < text.length ? "..." : ""}`;
|
||||
}
|
||||
|
||||
function uniqueByUrl(results: SearchResult[]) {
|
||||
const seen = new Set<string>();
|
||||
return results.filter((result) => {
|
||||
const key = `${result.type}:${result.url}:${result.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCvEntries() {
|
||||
const categories = await db.query.cvCategory.findMany({
|
||||
orderBy: (fields, { asc }) => [asc(fields.layoutPosition), asc(fields.name)],
|
||||
with: {
|
||||
cvEntry: {
|
||||
orderBy: (fields, { desc }) => [desc(fields.toTime), desc(fields.fromTime)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return categories.flatMap((category) =>
|
||||
category.cvEntry.map((entry) => ({
|
||||
...entry,
|
||||
categoryName: category.name ?? "CV",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
return db.query.project.findMany({
|
||||
orderBy: (fields, { asc }) => [asc(fields.orderPos), asc(fields.title), asc(fields.id)],
|
||||
with: {
|
||||
techStack: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSearchResults(query: string, types: ContentType[]) {
|
||||
const selected = new Set(types);
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (selected.has("cv")) {
|
||||
const entries = await loadCvEntries();
|
||||
for (const entry of entries) {
|
||||
const body = stripMarkup(`${entry.categoryName} ${entry.description ?? ""}`);
|
||||
const score = scoreText(query, entry.title, body);
|
||||
if (score > 0 || !query.trim()) {
|
||||
results.push({
|
||||
type: "cv",
|
||||
title: entry.title,
|
||||
snippet: snippet(body, query),
|
||||
url: "/cv",
|
||||
score,
|
||||
metadata: {
|
||||
category: entry.categoryName,
|
||||
fromTime: entry.fromTime,
|
||||
toTime: entry.toTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.has("project")) {
|
||||
const projects = await loadProjects();
|
||||
const catalogQuery = isProjectCatalogQuery(query);
|
||||
for (const [index, item] of projects.entries()) {
|
||||
const stackItems = item.techStack?.stackItems ?? [];
|
||||
const body = stripMarkup(`${item.description ?? ""} ${stackItems.join(" ")}`);
|
||||
const score = catalogQuery ? 1000 - index : scoreText(query, item.title, body, stackItems);
|
||||
if (score > 0 || !query.trim() || catalogQuery) {
|
||||
results.push({
|
||||
type: "project",
|
||||
title: item.title,
|
||||
snippet: snippet(body || item.title, query),
|
||||
url: `/projects#${item.id}`,
|
||||
score,
|
||||
metadata: {
|
||||
id: item.id,
|
||||
stackItems,
|
||||
sourceType: item.sourceType,
|
||||
releaseStatus: item.releaseStatus,
|
||||
sourceLink: item.sourceLink,
|
||||
releaseLink: item.releaseLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.has("blog")) {
|
||||
const posts = await db
|
||||
.select({
|
||||
slug: blogPost.slug,
|
||||
title: blogPost.title,
|
||||
date: blogPost.date,
|
||||
description: blogPost.description,
|
||||
tags: blogPost.tags,
|
||||
})
|
||||
.from(blogPost)
|
||||
.orderBy(desc(blogPost.date), desc(blogPost.createdAt));
|
||||
|
||||
for (const post of posts) {
|
||||
const tags = post.tags ?? [];
|
||||
const body = stripMarkup(`${post.description ?? ""} ${tags.join(" ")}`);
|
||||
const score = scoreText(query, post.title, body, tags);
|
||||
if (score > 0 || !query.trim()) {
|
||||
results.push({
|
||||
type: "blog",
|
||||
title: post.title,
|
||||
snippet: snippet(body || post.title, query),
|
||||
url: `/blog/${post.slug}`,
|
||||
score,
|
||||
metadata: {
|
||||
slug: post.slug,
|
||||
date: post.date,
|
||||
tags,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.has("music")) {
|
||||
const tracks = await db.select().from(music).orderBy(desc(music.createdAt));
|
||||
for (const track of tracks) {
|
||||
const body = stripMarkup(track.description);
|
||||
const score = scoreText(query, track.title, body);
|
||||
if (score > 0 || !query.trim()) {
|
||||
results.push({
|
||||
type: "music",
|
||||
title: track.title,
|
||||
snippet: snippet(body || track.fileName, query),
|
||||
url: "/music",
|
||||
score,
|
||||
metadata: {
|
||||
id: track.id,
|
||||
fileName: track.fileName,
|
||||
hasStream: Boolean(track.streamUrl),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueByUrl(results).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function projectMatches(projectItem: ProjectWithStack, idOrTitle: string) {
|
||||
const normalized = idOrTitle.trim().toLowerCase();
|
||||
const title = projectItem.title.toLowerCase();
|
||||
return projectItem.id === idOrTitle || title === normalized || title.includes(normalized);
|
||||
}
|
||||
|
||||
function matchedTerms(text: string, terms: string[]) {
|
||||
const lower = text.toLowerCase();
|
||||
return terms.filter((term) => lower.includes(term.toLowerCase()));
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined, fallback: Date) {
|
||||
if (!value?.trim()) return fallback;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? fallback : date;
|
||||
}
|
||||
|
||||
function safeTimeZone(value: string | undefined) {
|
||||
const timeZone = value?.trim() || "Europe/Berlin";
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
|
||||
return timeZone;
|
||||
} catch {
|
||||
return "Europe/Berlin";
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
function overlaps(
|
||||
start: Date,
|
||||
end: Date,
|
||||
busy: Array<{ start: Date; end: Date }>,
|
||||
) {
|
||||
return busy.some((item) => start < item.end && end > item.start);
|
||||
}
|
||||
|
||||
function getZonedParts(date: Date, timeZone: string) {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
hourCycle: "h23",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
|
||||
return {
|
||||
weekday: parts.weekday ?? "",
|
||||
hour: Number(parts.hour ?? 0),
|
||||
minute: Number(parts.minute ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function isInsideWorkingHours(start: Date, end: Date, timeZone: string, workdayStartHour: number, workdayEndHour: number) {
|
||||
const startParts = getZonedParts(start, timeZone);
|
||||
const endParts = getZonedParts(end, timeZone);
|
||||
const weekend = startParts.weekday === "Sat" || startParts.weekday === "Sun";
|
||||
const startMinutes = startParts.hour * 60 + startParts.minute;
|
||||
const endMinutes = endParts.hour * 60 + endParts.minute;
|
||||
|
||||
return !weekend && startMinutes >= workdayStartHour * 60 && endMinutes <= workdayEndHour * 60;
|
||||
}
|
||||
|
||||
function availabilitySlots({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
busy,
|
||||
durationMinutes,
|
||||
timeZone,
|
||||
workdayStartHour,
|
||||
workdayEndHour,
|
||||
}: {
|
||||
rangeStart: Date;
|
||||
rangeEnd: Date;
|
||||
busy: Array<{ start: Date; end: Date }>;
|
||||
durationMinutes: number;
|
||||
timeZone: string;
|
||||
workdayStartHour: number;
|
||||
workdayEndHour: number;
|
||||
}) {
|
||||
const slots: Array<{ start: string; end: string }> = [];
|
||||
const stepMinutes = 30;
|
||||
const durationMs = durationMinutes * 60 * 1000;
|
||||
const cursor = new Date(Math.ceil(rangeStart.getTime() / (stepMinutes * 60 * 1000)) * stepMinutes * 60 * 1000);
|
||||
|
||||
while (cursor.getTime() + durationMs <= rangeEnd.getTime() && slots.length < 10) {
|
||||
const end = new Date(cursor.getTime() + durationMs);
|
||||
if (
|
||||
isInsideWorkingHours(cursor, end, timeZone, workdayStartHour, workdayEndHour)
|
||||
&& !overlaps(cursor, end, busy)
|
||||
) {
|
||||
slots.push({ start: cursor.toISOString(), end: end.toISOString() });
|
||||
}
|
||||
cursor.setMinutes(cursor.getMinutes() + stepMinutes);
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function logAvailability(requestId: string, message: string, data?: Record<string, unknown>) {
|
||||
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({
|
||||
description: "Schedule a meeting with Gregor Lohaus and add it to his Google Calendar. Use getAvailability first when the user asks for a meeting at a flexible or uncertain time.",
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe("Meeting title, make something up if not provided"),
|
||||
description: z.string().describe("Meeting description / agenda, make something up if not provided"),
|
||||
dateTime: z
|
||||
.string()
|
||||
.describe("ISO 8601 datetime for the meeting start, e.g. 2026-06-18T10:00:00+02:00"),
|
||||
durationMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(15)
|
||||
.max(120)
|
||||
.describe("Duration of the meeting in minutes, if none provided ask if 20 minutes is ok"),
|
||||
attendeeEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional email of the visitor to invite, if provided"),
|
||||
attendeeName: z.string().optional().describe("Name of the visitor"),
|
||||
}),
|
||||
execute: async (input) => {
|
||||
if (input.attendeeEmail && !isLikelyEmail(input.attendeeEmail)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "The attendee email does not look valid. Ask the visitor to provide a valid email address before scheduling.",
|
||||
};
|
||||
}
|
||||
|
||||
return scheduleMeeting({ ...input });
|
||||
},
|
||||
}),
|
||||
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({
|
||||
query: z.string().describe("Search query, skill, technology, topic, or phrase."),
|
||||
types: z.array(contentTypeSchema).optional().describe("Optional content types to search. Omit to search all site content."),
|
||||
limit: z.number().int().min(1).max(12).optional().describe("Maximum number of results to return."),
|
||||
}),
|
||||
execute: async ({ query, types, limit }) => {
|
||||
const results = await buildSearchResults(query, types?.length ? types : ["cv", "project", "blog", "music"]);
|
||||
return {
|
||||
success: true,
|
||||
query,
|
||||
results: results.slice(0, limit ?? 8).map(({ score, ...result }) => result),
|
||||
};
|
||||
},
|
||||
}),
|
||||
getRelevantExperience: tool({
|
||||
description: "Find Gregor's most relevant CV entries and projects for a role, skill set, seniority, or domain. Use this for recruiter-style qualification questions.",
|
||||
inputSchema: z.object({
|
||||
role: z.string().optional().describe("Role or job title, such as full-stack engineer or React Native developer."),
|
||||
skills: z.array(z.string()).optional().describe("Technologies, tools, or skills to match."),
|
||||
domain: z.string().optional().describe("Product or business domain to match, if any."),
|
||||
seniority: z.string().optional().describe("Seniority or responsibility level to match, if any."),
|
||||
limit: z.number().int().min(1).max(10).optional().describe("Maximum matching entries to return."),
|
||||
}),
|
||||
execute: async ({ role, skills, domain, seniority, limit }) => {
|
||||
const terms = [role, domain, seniority, ...(skills ?? [])].filter((value): value is string => Boolean(value?.trim()));
|
||||
const query = terms.join(" ");
|
||||
const results = await buildSearchResults(query, ["cv", "project"]);
|
||||
const selected = results.slice(0, limit ?? 6);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query,
|
||||
matches: selected.map(({ score, ...result }) => ({
|
||||
...result,
|
||||
matchedTerms: matchedTerms(`${result.title} ${result.snippet} ${(result.metadata?.stackItems as string[] | undefined)?.join(" ") ?? ""}`, terms),
|
||||
whyRelevant: result.type === "project"
|
||||
? "Project match based on title, description, and technology stack."
|
||||
: "CV match based on experience title, category, and description.",
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
getProjectDetails: tool({
|
||||
description: "Get detailed information for one of Gregor's projects, including description, stack, source link, release link, and project page URL.",
|
||||
inputSchema: z.object({
|
||||
idOrTitle: z.string().min(1).describe("Project id, exact title, or partial project title."),
|
||||
}),
|
||||
execute: async ({ idOrTitle }) => {
|
||||
const projects = await loadProjects();
|
||||
const found = projects.find((item) => projectMatches(item, idOrTitle));
|
||||
|
||||
if (!found) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No project matched "${idOrTitle}".`,
|
||||
suggestions: projects.slice(0, 5).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: `/projects#${item.id}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
project: {
|
||||
id: found.id,
|
||||
title: found.title,
|
||||
description: stripMarkup(found.description),
|
||||
sourceType: found.sourceType,
|
||||
sourceLink: found.sourceLink,
|
||||
releaseStatus: found.releaseStatus,
|
||||
releaseLink: found.releaseLink,
|
||||
stackItems: found.techStack?.stackItems ?? [],
|
||||
url: `/projects#${found.id}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
getAvailability: tool({
|
||||
description: "Check Gregor's Google Calendar availability and suggest open meeting slots. Use this directly for questions like 'when is the next open spot?' or 'what times are available?'. If no date range is provided, it checks from now. Use before scheduling when the requested time is flexible.",
|
||||
inputSchema: z.object({
|
||||
fromDateTime: z.string().optional().describe("ISO 8601 range start. Defaults to now."),
|
||||
toDateTime: z.string().optional().describe("ISO 8601 range end. Defaults to 14 days after the range start."),
|
||||
durationMinutes: z.number().int().min(15).max(120).optional().describe("Desired meeting duration. Defaults to 30 minutes."),
|
||||
timeZone: z.string().optional().describe("IANA time zone for working-hours filtering. Defaults to Europe/Berlin."),
|
||||
workdayStartHour: z.number().int().min(0).max(23).optional().describe("Earliest local start hour. Defaults to 9."),
|
||||
workdayEndHour: z.number().int().min(1).max(24).optional().describe("Latest local end hour. Defaults to 17."),
|
||||
}),
|
||||
execute: async (input) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
logAvailability(requestId, "start", {
|
||||
input,
|
||||
});
|
||||
|
||||
const durationMinutes = input.durationMinutes ?? 30;
|
||||
const timeZone = safeTimeZone(input.timeZone);
|
||||
const workdayStartHour = input.workdayStartHour ?? 9;
|
||||
const workdayEndHour = Math.max(input.workdayEndHour ?? 17, workdayStartHour + 1);
|
||||
const rangeStart = parseDate(input.fromDateTime, new Date());
|
||||
const defaultEnd = new Date(rangeStart.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||||
const requestedEnd = parseDate(input.toDateTime, defaultEnd);
|
||||
const maxEnd = new Date(rangeStart.getTime() + 31 * 24 * 60 * 60 * 1000);
|
||||
const rangeEnd = requestedEnd <= rangeStart ? defaultEnd : requestedEnd > maxEnd ? maxEnd : requestedEnd;
|
||||
logAvailability(requestId, "resolved range", {
|
||||
durationMinutes,
|
||||
timeZone,
|
||||
workdayStartHour,
|
||||
workdayEndHour,
|
||||
rangeStart: rangeStart.toISOString(),
|
||||
rangeEnd: rangeEnd.toISOString(),
|
||||
});
|
||||
|
||||
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 adminCalendar.calendar.freebusy.query({
|
||||
requestBody: {
|
||||
timeMin: rangeStart.toISOString(),
|
||||
timeMax: rangeEnd.toISOString(),
|
||||
timeZone,
|
||||
items: [{ id: "primary" }],
|
||||
},
|
||||
});
|
||||
busy = (response.data.calendars?.["primary"]?.busy ?? [])
|
||||
.map((item) => ({
|
||||
start: parseDate(item.start ?? undefined, rangeStart),
|
||||
end: parseDate(item.end ?? undefined, rangeStart),
|
||||
}))
|
||||
.filter((item) => item.end > item.start);
|
||||
logAvailability(requestId, "freebusy response", {
|
||||
busyCount: busy.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[ai:getAvailability:${requestId}] freebusy failed`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to read Gregor's Google Calendar availability.",
|
||||
};
|
||||
}
|
||||
|
||||
const availableSlots = availabilitySlots({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
busy,
|
||||
durationMinutes,
|
||||
timeZone,
|
||||
workdayStartHour,
|
||||
workdayEndHour,
|
||||
});
|
||||
logAvailability(requestId, "complete", {
|
||||
busyCount: busy.length,
|
||||
availableSlotCount: availableSlots.length,
|
||||
firstAvailableSlot: availableSlots[0],
|
||||
});
|
||||
const nextAvailableSlot = availableSlots[0] ?? null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
range: {
|
||||
start: rangeStart.toISOString(),
|
||||
end: rangeEnd.toISOString(),
|
||||
timeZone,
|
||||
},
|
||||
durationMinutes,
|
||||
busy: busy.map((item) => ({
|
||||
start: item.start.toISOString(),
|
||||
end: item.end.toISOString(),
|
||||
})),
|
||||
nextAvailableSlot,
|
||||
availableSlots,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user