refactor: improve AI event handling and conflict display in chat
- AI fetches events on-demand via callbacks for better efficiency - Add conflict detection with warning display when proposing overlapping events - Improve event search and display in chat interface - Load full chat history for display while limiting AI context
This commit is contained in:
@@ -1,30 +1,4 @@
|
||||
import {
|
||||
CalendarEvent,
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
} from "@calchat/shared";
|
||||
import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
// Re-export from shared package for use in toolExecutor
|
||||
export { formatDate, formatTime, formatDateTime };
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export {
|
||||
formatExistingEvents,
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
} from "./eventFormatter";
|
||||
export { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
||||
export { buildSystemPrompt } from "./systemPrompt";
|
||||
export {
|
||||
TOOL_DEFINITIONS,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AIContext } from "../../services/interfaces";
|
||||
import { formatExistingEvents } from "./eventFormatter";
|
||||
|
||||
/**
|
||||
* Build the system prompt for the AI assistant.
|
||||
@@ -15,8 +14,6 @@ export function buildSystemPrompt(context: AIContext): string {
|
||||
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.
|
||||
@@ -29,8 +26,16 @@ Wichtige Regeln:
|
||||
- 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 mehrere Event-Vorschläge in einer Antwort machen (z.B. für mehrere Termine auf einmal)
|
||||
- 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
|
||||
- WICHTIG: Bei Terminen in der VERGANGENHEIT: Weise den Benutzer darauf hin und erstelle KEIN Event. Beispiel: "Das Datum liegt in der Vergangenheit. Meintest du vielleicht [nächstes Jahr]?"
|
||||
- KRITISCH: Wenn ein Tool-Result eine ⚠️-Warnung enthält (z.B. "Zeitkonflikt mit..."), MUSST du diese dem Benutzer mitteilen! Ignoriere NIEMALS solche Warnungen! Beispiel: "An diesem Tag hast du bereits 'Jannes Geburtstag'. Soll ich den Termin trotzdem erstellen?"
|
||||
|
||||
WICHTIG - Event-Abfragen:
|
||||
- Du hast KEINEN vorgeladenen Kalender-Kontext!
|
||||
- Nutze IMMER getEventsInRange um Events zu laden, wenn der Benutzer nach Terminen fragt
|
||||
- Nutze searchEvents um nach Terminen per Titel zu suchen (gibt auch die Event-ID zurück)
|
||||
- Beispiel: "Was habe ich heute?" → getEventsInRange für heute
|
||||
- Beispiel: "Was habe ich diese Woche?" → getEventsInRange für die Woche
|
||||
- Beispiel: "Wann ist der Zahnarzt?" → searchEvents mit "Zahnarzt"
|
||||
|
||||
WICHTIG - Tool-Verwendung:
|
||||
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen!
|
||||
@@ -50,18 +55,21 @@ WICHTIG - Wiederkehrende Termine (RRULE):
|
||||
- WICHTIG: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld!
|
||||
|
||||
WICHTIG - Antwortformat:
|
||||
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal)
|
||||
- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt
|
||||
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten
|
||||
- Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
|
||||
- Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
|
||||
- Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:"
|
||||
- Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:"
|
||||
- Bei Übersichten: "Hier sind deine Termine:"
|
||||
- WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform!
|
||||
- Schlechte Beispiele: "Alles klar!" (zu unspezifisch), lange Listen mit Termin-Details im Text
|
||||
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
|
||||
|
||||
Existierende Termine des Benutzers:
|
||||
${eventsText}`;
|
||||
WICHTIG - Unterscheide zwischen PROPOSALS und ABFRAGEN:
|
||||
1. Bei PROPOSALS (proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent):
|
||||
- Halte deine Textantworten SEHR KURZ (1-2 Sätze)
|
||||
- Die Event-Details werden automatisch in Karten angezeigt
|
||||
- Wiederhole NICHT die Details im Text
|
||||
2. Bei ABFRAGEN (searchEvents, getEventsInRange, oder Fragen zu existierenden Terminen):
|
||||
- Du MUSST die gefundenen Termine im Text nennen!
|
||||
- Liste die relevanten Termine mit Titel, Datum und Uhrzeit auf
|
||||
- NIEMALS Event-IDs dem Benutzer zeigen! Die IDs sind nur für dich intern
|
||||
- Wenn keine Termine gefunden wurden, sage das explizit (z.B. "In diesem Zeitraum hast du keine Termine.")
|
||||
- Beispiel: "Heute hast du: Zahnarzt um 10:00 Uhr, Meeting um 14:00 Uhr."`;
|
||||
}
|
||||
|
||||
@@ -179,4 +179,23 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "getEventsInRange",
|
||||
description:
|
||||
"Load events from a specific date range. Use this when the user asks about a time period beyond the default 4 weeks (e.g., 'birthdays in the next 6 months', 'what do I have planned for summer').",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
startDate: {
|
||||
type: "string",
|
||||
description: "Start date as ISO string (YYYY-MM-DD)",
|
||||
},
|
||||
endDate: {
|
||||
type: "string",
|
||||
description: "End date as ISO string (YYYY-MM-DD)",
|
||||
},
|
||||
},
|
||||
required: ["startDate", "endDate"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,6 +8,18 @@ import {
|
||||
import { AIContext } from "../../services/interfaces";
|
||||
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
||||
|
||||
/**
|
||||
* Check if two time ranges overlap.
|
||||
*/
|
||||
function hasTimeOverlap(
|
||||
start1: Date,
|
||||
end1: Date,
|
||||
start2: Date,
|
||||
end2: Date,
|
||||
): boolean {
|
||||
return start1 < end2 && end1 > start2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposed change without ID - ID is added by GPTAdapter when collecting proposals
|
||||
*/
|
||||
@@ -24,12 +36,13 @@ export interface ToolResult {
|
||||
/**
|
||||
* Execute a tool call and return the result.
|
||||
* This function is provider-agnostic and can be used with any LLM.
|
||||
* Async to support tools that need to fetch data (e.g., getEventsInRange).
|
||||
*/
|
||||
export function executeToolCall(
|
||||
export async function executeToolCall(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
context: AIContext,
|
||||
): ToolResult {
|
||||
): Promise<ToolResult> {
|
||||
switch (name) {
|
||||
case "getDay": {
|
||||
const date = getDay(
|
||||
@@ -62,20 +75,52 @@ export function executeToolCall(
|
||||
const dateStr = formatDate(event.startTime);
|
||||
const startStr = formatTime(event.startTime);
|
||||
const endStr = formatTime(event.endTime);
|
||||
|
||||
// Check for conflicts - fetch events for the specific day
|
||||
const dayStart = new Date(event.startTime);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayStart.getDate() + 1);
|
||||
|
||||
const dayEvents = await context.fetchEventsInRange(dayStart, dayEnd);
|
||||
|
||||
// Use occurrenceStart/occurrenceEnd for expanded recurring events
|
||||
const conflicts = dayEvents.filter((e) =>
|
||||
hasTimeOverlap(
|
||||
event.startTime,
|
||||
event.endTime,
|
||||
new Date(e.occurrenceStart),
|
||||
new Date(e.occurrenceEnd),
|
||||
),
|
||||
);
|
||||
|
||||
// Build conflict warning if any
|
||||
let conflictWarning = "";
|
||||
if (conflicts.length > 0) {
|
||||
const conflictNames = conflicts.map((c) => `"${c.title}"`).join(", ");
|
||||
conflictWarning = `\n⚠️ ACHTUNG: Zeitkonflikt mit ${conflictNames}!`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr`,
|
||||
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr${conflictWarning}`,
|
||||
proposedChange: {
|
||||
action: "create",
|
||||
event,
|
||||
conflictingEvents:
|
||||
conflicts.length > 0
|
||||
? conflicts.map((c) => ({
|
||||
title: c.title,
|
||||
startTime: new Date(c.occurrenceStart),
|
||||
endTime: new Date(c.occurrenceEnd),
|
||||
}))
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "proposeUpdateEvent": {
|
||||
const eventId = args.eventId as string;
|
||||
const existingEvent = context.existingEvents.find(
|
||||
(e) => e.id === eventId,
|
||||
);
|
||||
const existingEvent = await context.fetchEventById(eventId);
|
||||
|
||||
if (!existingEvent) {
|
||||
return { content: `Event mit ID ${eventId} nicht gefunden.` };
|
||||
@@ -116,9 +161,7 @@ export function executeToolCall(
|
||||
const eventId = args.eventId as string;
|
||||
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
|
||||
const occurrenceDate = args.occurrenceDate as string | undefined;
|
||||
const existingEvent = context.existingEvents.find(
|
||||
(e) => e.id === eventId,
|
||||
);
|
||||
const existingEvent = await context.fetchEventById(eventId);
|
||||
|
||||
if (!existingEvent) {
|
||||
return { content: `Event mit ID ${eventId} nicht gefunden.` };
|
||||
@@ -162,25 +205,46 @@ export function executeToolCall(
|
||||
}
|
||||
|
||||
case "searchEvents": {
|
||||
const query = (args.query as string).toLowerCase();
|
||||
const matches = context.existingEvents.filter((e) =>
|
||||
e.title.toLowerCase().includes(query),
|
||||
);
|
||||
const query = args.query as string;
|
||||
const matches = await context.searchEvents(query);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { content: `Keine Termine mit "${args.query}" gefunden.` };
|
||||
return { content: `Keine Termine mit "${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`;
|
||||
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
|
||||
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return { content: `Gefundene Termine:\n${results}` };
|
||||
}
|
||||
|
||||
case "getEventsInRange": {
|
||||
const startDate = new Date(args.startDate as string);
|
||||
const endDate = new Date(args.endDate as string);
|
||||
const events = await context.fetchEventsInRange(startDate, endDate);
|
||||
|
||||
if (events.length === 0) {
|
||||
return { content: "Keine Termine in diesem Zeitraum." };
|
||||
}
|
||||
|
||||
const eventsText = events
|
||||
.map((e) => {
|
||||
const start = new Date(e.startTime);
|
||||
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
|
||||
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
content: `Termine von ${formatDate(startDate)} bis ${formatDate(endDate)}:\n${eventsText}`,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { content: `Unbekannte Funktion: ${name}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user