feat: replace Claude with GPT for AI chat integration

- Replace ClaudeAdapter with GPTAdapter using OpenAI GPT (gpt-5-mini)
- Implement function calling for calendar operations (getDay, proposeCreate/Update/Delete, searchEvents)
- Add provider-agnostic AI utilities in ai/utils/ (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
- Add USE_TEST_RESPONSES env var to toggle between real AI and test responses
- Switch ChatService.processMessage to use real AI provider
- Add npm run format command for Prettier
- Update CLAUDE.md with new architecture
This commit is contained in:
2026-01-10 00:22:59 +01:00
parent c897b6d680
commit 675785ec93
17 changed files with 599 additions and 61 deletions

View File

@@ -0,0 +1,29 @@
import { CalendarEvent } from "@caldav/shared";
// German date/time formatting helpers
export const formatDate = (d: Date) => d.toLocaleDateString("de-DE");
export const formatTime = (d: Date) =>
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
export const formatDateTime = (d: Date) =>
`${formatDate(d)} ${d.toLocaleTimeString("de-DE")}`;
/**
* Format a list of events for display in the system prompt.
* Output is in German with date/time formatting.
*/
export function formatExistingEvents(events: CalendarEvent[]): string {
if (events.length === 0) {
return "Keine Termine vorhanden.";
}
return events
.map((e) => {
const start = new Date(e.startTime);
const end = new Date(e.endTime);
const timeStr = `${formatTime(start)} - ${formatTime(end)}`;
const recurring = e.isRecurring ? " (wiederkehrend)" : "";
const desc = e.description ? ` | ${e.description}` : "";
return `- ${e.title} (ID: ${e.id}) | ${formatDate(start)} ${timeStr}${recurring}${desc}`;
})
.join("\n");
}

View File

@@ -0,0 +1,13 @@
export {
formatExistingEvents,
formatDate,
formatTime,
formatDateTime,
} from "./eventFormatter";
export { buildSystemPrompt } from "./systemPrompt";
export {
TOOL_DEFINITIONS,
type ToolDefinition,
type ParameterDef,
} from "./toolDefinitions";
export { executeToolCall, type ToolResult } from "./toolExecutor";

View File

@@ -0,0 +1,37 @@
import { AIContext } from "../../services/interfaces";
import { formatExistingEvents } from "./eventFormatter";
/**
* Build the system prompt for the AI assistant.
* This prompt is provider-agnostic and can be used with any LLM.
*/
export function buildSystemPrompt(context: AIContext): string {
const currentDate = context.currentDate.toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const eventsText = formatExistingEvents(context.existingEvents);
return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat".
Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen.
Antworte immer auf Deutsch.
Aktuelles Datum und Uhrzeit: ${currentDate}
Wichtige Regeln:
- Nutze getDay() für relative Datumsberechnungen (z.B. "nächsten Freitag um 14 Uhr")
- Wenn der Benutzer einen Termin erstellen will, nutze proposeCreateEvent
- Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID
- Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID
- Du kannst NUR EINEN Event-Vorschlag pro Antwort machen
- Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst
Existierende Termine des Benutzers:
${eventsText}`;
}

View File

@@ -0,0 +1,170 @@
/**
* Parameter definition for tool parameters.
*/
export interface ParameterDef {
type: "string" | "number" | "boolean" | "object" | "array";
description?: string;
enum?: string[];
}
/**
* Provider-agnostic tool definition format.
* Can be converted to OpenAI, Claude, or other provider formats.
*/
export interface ToolDefinition {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, ParameterDef>;
required: string[];
};
}
/**
* All available tools for the calendar assistant.
*/
export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: "getDay",
description:
"Get a date for a specific weekday relative to today. Returns an ISO date string.",
parameters: {
type: "object",
properties: {
day: {
type: "string",
enum: [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
],
description: "The target weekday",
},
offset: {
type: "number",
description:
"1 = next occurrence, 2 = the one after, -1 = last occurrence, etc.",
},
hour: {
type: "number",
description: "Hour of day (0-23)",
},
minute: {
type: "number",
description: "Minute (0-59)",
},
},
required: ["day", "offset", "hour", "minute"],
},
},
{
name: "getCurrentDateTime",
description: "Get the current date and time as an ISO string",
parameters: {
type: "object",
properties: {},
required: [],
},
},
{
name: "proposeCreateEvent",
description:
"Propose creating a new calendar event. The user must confirm before it's saved. Call this when the user wants to create a new appointment.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Event title",
},
startTime: {
type: "string",
description: "Start time as ISO date string",
},
endTime: {
type: "string",
description: "End time as ISO date string",
},
description: {
type: "string",
description: "Optional event description",
},
isRecurring: {
type: "boolean",
description: "Whether this is a recurring event",
},
recurrenceRule: {
type: "string",
description: "RRULE format string for recurring events",
},
},
required: ["title", "startTime", "endTime"],
},
},
{
name: "proposeUpdateEvent",
description:
"Propose updating an existing event. The user must confirm. Use this when the user wants to modify an appointment.",
parameters: {
type: "object",
properties: {
eventId: {
type: "string",
description: "ID of the event to update",
},
title: {
type: "string",
description: "New title (optional)",
},
startTime: {
type: "string",
description: "New start time as ISO date string (optional)",
},
endTime: {
type: "string",
description: "New end time as ISO date string (optional)",
},
description: {
type: "string",
description: "New description (optional)",
},
},
required: ["eventId"],
},
},
{
name: "proposeDeleteEvent",
description:
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment.",
parameters: {
type: "object",
properties: {
eventId: {
type: "string",
description: "ID of the event to delete",
},
},
required: ["eventId"],
},
},
{
name: "searchEvents",
description:
"Search for events by title in the user's calendar. Returns matching events.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query to match against event titles",
},
},
required: ["query"],
},
},
];

View File

@@ -0,0 +1,156 @@
import {
ProposedEventChange,
getDay,
Day,
DAY_TO_GERMAN,
} from "@caldav/shared";
import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
/**
* Result of executing a tool call.
*/
export interface ToolResult {
content: string;
proposedChange?: ProposedEventChange;
}
/**
* Execute a tool call and return the result.
* This function is provider-agnostic and can be used with any LLM.
*/
export function executeToolCall(
name: string,
args: Record<string, unknown>,
context: AIContext,
): ToolResult {
switch (name) {
case "getDay": {
const date = getDay(
args.day as Day,
args.offset as number,
args.hour as number,
args.minute as number,
);
const dayName = DAY_TO_GERMAN[args.day as Day];
return {
content: `${date.toISOString()} (${dayName}, ${formatDate(date)} um ${formatTime(date)} Uhr)`,
};
}
case "getCurrentDateTime": {
const now = context.currentDate;
return {
content: `${now.toISOString()} (${formatDateTime(now)})`,
};
}
case "proposeCreateEvent": {
const event = {
title: args.title as string,
startTime: new Date(args.startTime as string),
endTime: new Date(args.endTime as string),
description: args.description as string | undefined,
isRecurring: args.isRecurring as boolean | undefined,
recurrenceRule: args.recurrenceRule as string | undefined,
};
const dateStr = formatDate(event.startTime);
const startStr = formatTime(event.startTime);
const endStr = formatTime(event.endTime);
return {
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr`,
proposedChange: {
action: "create",
event,
},
};
}
case "proposeUpdateEvent": {
const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
}
const updates: Record<string, unknown> = {};
if (args.title) updates.title = args.title;
if (args.startTime)
updates.startTime = new Date(args.startTime as string);
if (args.endTime) updates.endTime = new Date(args.endTime as string);
if (args.description) updates.description = args.description;
// Build event object for display (merge existing with updates)
const displayEvent = {
title: (updates.title as string) || existingEvent.title,
startTime: (updates.startTime as Date) || existingEvent.startTime,
endTime: (updates.endTime as Date) || existingEvent.endTime,
description:
(updates.description as string) || existingEvent.description,
isRecurring: existingEvent.isRecurring,
};
return {
content: `Update-Vorschlag für "${existingEvent.title}" erstellt.`,
proposedChange: {
action: "update",
eventId,
updates,
event: displayEvent,
},
};
}
case "proposeDeleteEvent": {
const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
}
return {
content: `Lösch-Vorschlag für "${existingEvent.title}" erstellt.`,
proposedChange: {
action: "delete",
eventId,
event: {
title: existingEvent.title,
startTime: existingEvent.startTime,
endTime: existingEvent.endTime,
description: existingEvent.description,
isRecurring: existingEvent.isRecurring,
},
},
};
}
case "searchEvents": {
const query = (args.query as string).toLowerCase();
const matches = context.existingEvents.filter((e) =>
e.title.toLowerCase().includes(query),
);
if (matches.length === 0) {
return { content: `Keine Termine mit "${args.query}" gefunden.` };
}
const results = matches
.map((e) => {
const start = new Date(e.startTime);
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr`;
})
.join("\n");
return { content: `Gefundene Termine:\n${results}` };
}
default:
return { content: `Unbekannte Funktion: ${name}` };
}
}