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:
@@ -22,9 +22,6 @@ import { EventService, ChatService } from "../services";
|
||||
import { buildRRule, CreateEventDTO } from "@calchat/shared";
|
||||
import { useChatStore } from "../stores";
|
||||
|
||||
// Direct store access for getting current state in callbacks
|
||||
const getChatStoreState = () => useChatStore.getState();
|
||||
|
||||
type EditEventTextFieldProps = {
|
||||
titel: string;
|
||||
text?: string;
|
||||
@@ -443,31 +440,25 @@ const EditEventScreen = () => {
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Chat mode: update proposal locally and on server
|
||||
// Chat mode: update proposal on server and sync response to local store
|
||||
if (mode === "chat" && proposalContext) {
|
||||
try {
|
||||
const context = JSON.parse(proposalContext) as ProposalContext;
|
||||
|
||||
// Update locally in ChatStore
|
||||
const currentMessages = getChatStoreState().messages;
|
||||
const message = currentMessages.find((m) => m.id === context.messageId);
|
||||
|
||||
if (message?.proposedChanges) {
|
||||
const updatedProposals = message.proposedChanges.map((p) =>
|
||||
p.id === context.proposalId ? { ...p, event: eventObject } : p,
|
||||
);
|
||||
updateMessage(context.messageId, {
|
||||
proposedChanges: updatedProposals,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist to server
|
||||
await ChatService.updateProposalEvent(
|
||||
// Persist to server - returns updated message with recalculated conflictingEvents
|
||||
const updatedMessage = await ChatService.updateProposalEvent(
|
||||
context.messageId,
|
||||
context.proposalId,
|
||||
eventObject,
|
||||
);
|
||||
|
||||
// Update local ChatStore with server response (includes updated conflicts)
|
||||
if (updatedMessage?.proposedChanges) {
|
||||
updateMessage(context.messageId, {
|
||||
proposedChanges: updatedMessage.proposedChanges,
|
||||
});
|
||||
}
|
||||
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error("Failed to update proposal:", error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { ProposedEventChange, formatDate } from "@calchat/shared";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { ProposedEventChange, formatDate, formatTime } from "@calchat/shared";
|
||||
import { rrulestr } from "rrule";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { EventCardBase } from "./EventCardBase";
|
||||
@@ -143,6 +143,29 @@ export const ProposedEventCard = ({
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Show conflicting events warning */}
|
||||
{proposedChange.conflictingEvents &&
|
||||
proposedChange.conflictingEvents.length > 0 && (
|
||||
<View className="mb-2">
|
||||
{proposedChange.conflictingEvents.map((conflict, index) => (
|
||||
<View key={index} className="flex-row items-center mt-1">
|
||||
<Ionicons
|
||||
name="alert-circle"
|
||||
size={16}
|
||||
color={theme.rejectButton}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: theme.rejectButton }}
|
||||
className="text-sm flex-1"
|
||||
>
|
||||
Konflikt: {conflict.title} ({formatTime(conflict.startTime)}{" "}
|
||||
- {formatTime(conflict.endTime)})
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<ActionButtons
|
||||
isDisabled={isDisabled}
|
||||
respondedAction={proposedChange.respondedAction}
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
ToolDefinition,
|
||||
} from "./utils";
|
||||
import { Logged } from "../logging";
|
||||
import {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionTool,
|
||||
} from "openai/resources/chat/completions/completions";
|
||||
|
||||
/**
|
||||
* Convert tool definitions to OpenAI format.
|
||||
@@ -29,7 +34,7 @@ function toOpenAITools(
|
||||
export class GPTAdapter implements AIProvider {
|
||||
private client: OpenAI;
|
||||
private model: string;
|
||||
private tools: OpenAI.Chat.Completions.ChatCompletionTool[];
|
||||
private tools: ChatCompletionTool[];
|
||||
|
||||
constructor(apiKey?: string, model: string = "gpt-5-mini") {
|
||||
this.client = new OpenAI({
|
||||
@@ -46,7 +51,7 @@ export class GPTAdapter implements AIProvider {
|
||||
const systemPrompt = buildSystemPrompt(context);
|
||||
|
||||
// Build messages array with conversation history
|
||||
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "developer", content: systemPrompt },
|
||||
];
|
||||
|
||||
@@ -87,17 +92,21 @@ export class GPTAdapter implements AIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
// Process tool calls
|
||||
// Process all tool calls and collect results
|
||||
const toolResults: Array<{
|
||||
toolCall: ChatCompletionMessageToolCall;
|
||||
content: string;
|
||||
}> = [];
|
||||
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
// Skip non-function tool calls
|
||||
if (toolCall.type !== "function") continue;
|
||||
|
||||
const { name, arguments: argsRaw } = toolCall.function;
|
||||
const args = JSON.parse(argsRaw);
|
||||
|
||||
const result = executeToolCall(name, args, context);
|
||||
const result = await executeToolCall(name, args, context);
|
||||
|
||||
// If the tool returned a proposedChange, add it to the array with unique ID
|
||||
// Collect proposed changes
|
||||
if (result.proposedChange) {
|
||||
proposedChanges.push({
|
||||
id: `proposal-${proposalIndex++}`,
|
||||
@@ -105,17 +114,22 @@ export class GPTAdapter implements AIProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// Add assistant message with tool call
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
tool_calls: [toolCall],
|
||||
});
|
||||
toolResults.push({ toolCall, content: result.content });
|
||||
}
|
||||
|
||||
// Add tool result
|
||||
// Add assistant message with ALL tool calls at once
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
tool_calls: assistantMessage.tool_calls,
|
||||
content: assistantMessage.content,
|
||||
});
|
||||
|
||||
// Add all tool results
|
||||
for (const { toolCall, content } of toolResults) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolCall.id,
|
||||
content: result.content,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -96,8 +96,10 @@ app.post("/api/ai/test", async (req, res) => {
|
||||
const result = await aiProvider.processMessage(message, {
|
||||
userId: "test-user",
|
||||
conversationHistory: [],
|
||||
existingEvents: [],
|
||||
currentDate: new Date(),
|
||||
fetchEventsInRange: async () => [],
|
||||
searchEvents: async () => [],
|
||||
fetchEventById: async () => null,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from "./logger";
|
||||
* Summarize args for logging to avoid huge log entries.
|
||||
* - Arrays: show length only
|
||||
* - Long strings: truncate
|
||||
* - Objects with conversationHistory/existingEvents: summarize
|
||||
* - Objects with conversationHistory: summarize
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function summarizeArgs(args: any[]): any[] {
|
||||
@@ -31,8 +31,6 @@ function summarizeValue(value: any, depth = 0): any {
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (key === "conversationHistory" && Array.isArray(val)) {
|
||||
summarized[key] = `[${val.length} messages]`;
|
||||
} else if (key === "existingEvents" && Array.isArray(val)) {
|
||||
summarized[key] = `[${val.length} events]`;
|
||||
} else if (key === "proposedChanges" && Array.isArray(val)) {
|
||||
// Log full proposedChanges for debugging AI issues
|
||||
summarized[key] = val.map((p) => summarizeValue(p, depth + 1));
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreateEventDTO,
|
||||
GetMessagesOptions,
|
||||
UpdateMessageDTO,
|
||||
ConflictingEvent,
|
||||
} from "@calchat/shared";
|
||||
import { ChatRepository } from "../../services/interfaces";
|
||||
import { Logged } from "../../logging";
|
||||
@@ -25,12 +26,20 @@ export class MongoChatRepository implements ChatRepository {
|
||||
return conversation.toJSON() as unknown as Conversation;
|
||||
}
|
||||
|
||||
async getConversationById(
|
||||
conversationId: string,
|
||||
): Promise<Conversation | null> {
|
||||
const conversation = await ConversationModel.findById(conversationId);
|
||||
return conversation
|
||||
? (conversation.toJSON() as unknown as Conversation)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Messages (cursor-based pagination)
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
options?: GetMessagesOptions,
|
||||
): Promise<ChatMessage[]> {
|
||||
const limit = options?.limit ?? 20;
|
||||
const query: Record<string, unknown> = { conversationId };
|
||||
|
||||
// Cursor: load messages before this ID (for "load more" scrolling up)
|
||||
@@ -39,9 +48,12 @@ export class MongoChatRepository implements ChatRepository {
|
||||
}
|
||||
|
||||
// Fetch newest first, then reverse for chronological order
|
||||
const docs = await ChatMessageModel.find(query)
|
||||
.sort({ _id: -1 })
|
||||
.limit(limit);
|
||||
// Only apply limit if explicitly specified (no default - load all messages)
|
||||
let queryBuilder = ChatMessageModel.find(query).sort({ _id: -1 });
|
||||
if (options?.limit) {
|
||||
queryBuilder = queryBuilder.limit(options.limit);
|
||||
}
|
||||
const docs = await queryBuilder;
|
||||
|
||||
return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage);
|
||||
}
|
||||
@@ -88,12 +100,28 @@ export class MongoChatRepository implements ChatRepository {
|
||||
messageId: string,
|
||||
proposalId: string,
|
||||
event: CreateEventDTO,
|
||||
conflictingEvents?: ConflictingEvent[],
|
||||
): Promise<ChatMessage | null> {
|
||||
// Always set both fields - use empty array when no conflicts
|
||||
// (MongoDB has issues combining $set and $unset on positional operator)
|
||||
const setFields: Record<string, unknown> = {
|
||||
"proposedChanges.$.event": event,
|
||||
"proposedChanges.$.conflictingEvents":
|
||||
conflictingEvents && conflictingEvents.length > 0
|
||||
? conflictingEvents
|
||||
: [],
|
||||
};
|
||||
|
||||
const doc = await ChatMessageModel.findOneAndUpdate(
|
||||
{ _id: messageId, "proposedChanges.id": proposalId },
|
||||
{ $set: { "proposedChanges.$.event": event } },
|
||||
{ $set: setFields },
|
||||
{ new: true },
|
||||
);
|
||||
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
|
||||
}
|
||||
|
||||
async getMessageById(messageId: string): Promise<ChatMessage | null> {
|
||||
const doc = await ChatMessageModel.findById(messageId);
|
||||
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ export class MongoEventRepository implements EventRepository {
|
||||
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
|
||||
}
|
||||
|
||||
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
|
||||
const events = await EventModel.find({
|
||||
userId,
|
||||
title: { $regex: query, $options: "i" },
|
||||
}).sort({ startTime: 1 });
|
||||
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
|
||||
const event = await EventModel.create({ userId, ...data });
|
||||
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreateEventDTO,
|
||||
UpdateEventDTO,
|
||||
ProposedEventChange,
|
||||
ConflictingEvent,
|
||||
} from "@calchat/shared";
|
||||
import { IdVirtual } from "./types";
|
||||
|
||||
@@ -41,6 +42,15 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const ConflictingEventSchema = new Schema<ConflictingEvent>(
|
||||
{
|
||||
title: { type: String, required: true },
|
||||
startTime: { type: Date, required: true },
|
||||
endTime: { type: Date, required: true },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
||||
{
|
||||
id: { type: String, required: true },
|
||||
@@ -61,6 +71,7 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
||||
enum: ["single", "future", "all"],
|
||||
},
|
||||
occurrenceDate: { type: String },
|
||||
conflictingEvents: { type: [ConflictingEventSchema] },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
CreateEventDTO,
|
||||
UpdateEventDTO,
|
||||
EventAction,
|
||||
CreateMessageDTO,
|
||||
RecurringDeleteMode,
|
||||
ConflictingEvent,
|
||||
} from "@calchat/shared";
|
||||
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
||||
import { EventService } from "./EventService";
|
||||
@@ -570,7 +570,6 @@ export class ChatService {
|
||||
responseIndex++;
|
||||
} else {
|
||||
// Production mode: use real AI
|
||||
const events = await this.eventRepo.findByUserId(userId);
|
||||
const history = await this.chatRepo.getMessages(conversationId, {
|
||||
limit: 20,
|
||||
});
|
||||
@@ -578,8 +577,16 @@ export class ChatService {
|
||||
response = await this.aiProvider.processMessage(data.content, {
|
||||
userId,
|
||||
conversationHistory: history,
|
||||
existingEvents: events,
|
||||
currentDate: new Date(),
|
||||
fetchEventsInRange: async (start, end) => {
|
||||
return this.eventService.getByDateRange(userId, start, end);
|
||||
},
|
||||
searchEvents: async (query) => {
|
||||
return this.eventRepo.searchByTitle(userId, query);
|
||||
},
|
||||
fetchEventById: async (eventId) => {
|
||||
return this.eventService.getById(eventId, userId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -713,6 +720,56 @@ export class ChatService {
|
||||
proposalId: string,
|
||||
event: CreateEventDTO,
|
||||
): Promise<ChatMessage | null> {
|
||||
return this.chatRepo.updateProposalEvent(messageId, proposalId, event);
|
||||
// Get the message to find the conversation
|
||||
const message = await this.chatRepo.getMessageById(messageId);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the conversation to find the userId
|
||||
const conversation = await this.chatRepo.getConversationById(
|
||||
message.conversationId,
|
||||
);
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
const userId = conversation.userId;
|
||||
|
||||
// Get event times
|
||||
const eventStart = new Date(event.startTime);
|
||||
const eventEnd = new Date(event.endTime);
|
||||
|
||||
// Get day range for conflict checking
|
||||
const dayStart = new Date(eventStart);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayStart.getDate() + 1);
|
||||
|
||||
// Fetch events for the day
|
||||
const dayEvents = await this.eventService.getByDateRange(
|
||||
userId,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
);
|
||||
|
||||
// Check for time overlaps (use occurrenceStart/End for expanded recurring events)
|
||||
const conflicts: ConflictingEvent[] = dayEvents
|
||||
.filter(
|
||||
(e) =>
|
||||
new Date(e.occurrenceStart) < eventEnd &&
|
||||
new Date(e.occurrenceEnd) > eventStart,
|
||||
)
|
||||
.map((e) => ({
|
||||
title: e.title,
|
||||
startTime: new Date(e.occurrenceStart),
|
||||
endTime: new Date(e.occurrenceEnd),
|
||||
}));
|
||||
|
||||
return this.chatRepo.updateProposalEvent(
|
||||
messageId,
|
||||
proposalId,
|
||||
event,
|
||||
conflicts.length > 0 ? conflicts : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import {
|
||||
CalendarEvent,
|
||||
ChatMessage,
|
||||
ProposedEventChange,
|
||||
ExpandedEvent,
|
||||
CalendarEvent,
|
||||
} from "@calchat/shared";
|
||||
|
||||
export interface AIContext {
|
||||
userId: string;
|
||||
conversationHistory: ChatMessage[];
|
||||
existingEvents: CalendarEvent[];
|
||||
currentDate: Date;
|
||||
// Callback to load events from a specific date range
|
||||
// Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events
|
||||
fetchEventsInRange: (startDate: Date, endDate: Date) => Promise<ExpandedEvent[]>;
|
||||
// Callback to search events by title
|
||||
searchEvents: (query: string) => Promise<CalendarEvent[]>;
|
||||
// Callback to fetch a single event by ID
|
||||
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
|
||||
}
|
||||
|
||||
export interface AIResponse {
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
CreateEventDTO,
|
||||
GetMessagesOptions,
|
||||
UpdateMessageDTO,
|
||||
ConflictingEvent,
|
||||
} from "@calchat/shared";
|
||||
|
||||
export interface ChatRepository {
|
||||
// Conversations
|
||||
getConversationsByUser(userId: string): Promise<Conversation[]>;
|
||||
getConversationById(conversationId: string): Promise<Conversation | null>;
|
||||
createConversation(userId: string): Promise<Conversation>;
|
||||
|
||||
// Messages (cursor-based pagination)
|
||||
@@ -38,5 +40,8 @@ export interface ChatRepository {
|
||||
messageId: string,
|
||||
proposalId: string,
|
||||
event: CreateEventDTO,
|
||||
conflictingEvents?: ConflictingEvent[],
|
||||
): Promise<ChatMessage | null>;
|
||||
|
||||
getMessageById(messageId: string): Promise<ChatMessage | null>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface EventRepository {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<CalendarEvent[]>;
|
||||
searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>;
|
||||
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
|
||||
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
|
||||
Reference in New Issue
Block a user