diff --git a/CLAUDE.md b/CLAUDE.md index fdfcb73..0050530 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,15 +205,17 @@ src/ - `User`: id, email, userName, passwordHash?, createdAt?, updatedAt? - `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) -- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?, respondedAction? -- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates? +- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges? +- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction? + - Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject + - `respondedAction` tracks user response per proposal (not per message) - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `CreateUserDTO`: email, userName, password (for registration) - `LoginDTO`: identifier (email OR userName), password - `CreateEventDTO`: Used for creating events AND for AI-proposed events - `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number` - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) -- `UpdateMessageDTO`: respondedAction? (for marking messages as confirmed/rejected) +- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected) - `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events) - `Day`: "Monday" | "Tuesday" | ... | "Sunday" - `Month`: "January" | "February" | ... | "December" @@ -275,6 +277,14 @@ export class GPTAdapter implements AIProvider { ... } The decorator uses a Proxy to intercept method calls lazily, preserves sync/async nature, and logs start/completion/failure with duration. +**Log Summarization:** +The `@Logged` decorator automatically summarizes large arguments to keep logs readable: +- `conversationHistory` → `"[5 messages]"` +- `existingEvents` → `"[3 events]"` +- `proposedChanges` → logged in full (for debugging AI issues) +- Long strings (>100 chars) → truncated +- Arrays → `"[Array(n)]"` + **Client Logging:** - `react-native-logs` with namespaced loggers (apiLogger, storeLogger) - ApiClient logs all requests with method, endpoint, status, duration @@ -339,10 +349,12 @@ NODE_ENV=development # development = pretty logs, production = JSON - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - `ChatController`: getConversations(), getConversation() with cursor-based pagination support - `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages - - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage) - - `ChatRepository` interface: updateMessage() added for respondedAction tracking - - `GPTAdapter`: Full implementation with OpenAI GPT (gpt-5-mini model), function calling for calendar operations + - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse) + - `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking + - `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter) + - `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day + - `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it) - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator - All repositories and GPTAdapter decorated with @Logged for automatic method logging - CORS configured to allow X-User-Id header @@ -373,6 +385,9 @@ NODE_ENV=development # development = pretty logs, production = JSON - Supports events from adjacent months visible in grid - Uses `useFocusEffect` for automatic reload on tab focus - Chat screen fully functional with FlashList, message sending, and event confirm/reject + - **Multiple event proposals**: AI can propose multiple events in one response + - Arrow navigation between proposals with "Event X von Y" counter + - Each proposal individually confirmable/rejectable - Messages persisted to database via ChatService and loaded on mount - Tracks conversationId for message continuity across sessions - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index 2c56400..0f0d7c1 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -20,6 +20,7 @@ import { } from "../../stores"; import { ProposedEventChange } from "@caldav/shared"; import { ProposedEventCard } from "../../components/ProposedEventCard"; +import { Ionicons } from "@expo/vector-icons"; // TODO: better shadows for everything // (maybe with extra library because of differences between android and ios) @@ -30,10 +31,9 @@ type BubbleSide = "left" | "right"; type ChatMessageProps = { side: BubbleSide; content: string; - proposedChange?: ProposedEventChange; - respondedAction?: "confirm" | "reject"; - onConfirm?: () => void; - onReject?: () => void; + proposedChanges?: ProposedEventChange[]; + onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void; + onReject?: (proposalId: string) => void; }; type ChatInputProps = { @@ -88,10 +88,17 @@ const Chat = () => { action: "confirm" | "reject", messageId: string, conversationId: string, + proposalId: string, proposedChange?: ProposedEventChange, ) => { - // Mark message as responded (optimistic update) - updateMessage(messageId, { respondedAction: action }); + // Mark proposal as responded (optimistic update) + const message = messages.find((m) => m.id === messageId); + if (message?.proposedChanges) { + const updatedProposals = message.proposedChanges.map((p) => + p.id === proposalId ? { ...p, respondedAction: action as "confirm" | "reject" } : p, + ); + updateMessage(messageId, { proposedChanges: updatedProposals }); + } try { const response = @@ -99,12 +106,13 @@ const Chat = () => { ? await ChatService.confirmEvent( conversationId, messageId, + proposalId, proposedChange.action, proposedChange.event, proposedChange.eventId, proposedChange.updates, ) - : await ChatService.rejectEvent(conversationId, messageId); + : await ChatService.rejectEvent(conversationId, messageId, proposalId); const botMessage: MessageData = { id: response.message.id, @@ -117,7 +125,12 @@ const Chat = () => { } catch (error) { console.error(`Failed to ${action} event:`, error); // Revert on error - updateMessage(messageId, { respondedAction: undefined }); + if (message?.proposedChanges) { + const revertedProposals = message.proposedChanges.map((p) => + p.id === proposalId ? { ...p, respondedAction: undefined } : p, + ); + updateMessage(messageId, { proposedChanges: revertedProposals }); + } } }; @@ -149,7 +162,7 @@ const Chat = () => { id: response.message.id, side: "left", content: response.message.content, - proposedChange: response.message.proposedChange, + proposedChanges: response.message.proposedChanges, conversationId: response.conversationId, }; addMessage(botMessage); @@ -173,18 +186,18 @@ const Chat = () => { + proposedChanges={item.proposedChanges} + onConfirm={(proposalId, proposal) => handleEventResponse( "confirm", item.id, item.conversationId!, - item.proposedChange, + proposalId, + proposal, ) } - onReject={() => - handleEventResponse("reject", item.id, item.conversationId!) + onReject={(proposalId) => + handleEventResponse("reject", item.id, item.conversationId!, proposalId) } /> )} @@ -271,11 +284,12 @@ const ChatInput = ({ onSend }: ChatInputProps) => { const ChatMessage = ({ side, content, - proposedChange, - respondedAction, + proposedChanges, onConfirm, onReject, }: ChatMessageProps) => { + const [currentIndex, setCurrentIndex] = useState(0); + const borderColor = side === "left" ? currentTheme.chatBot : currentTheme.primeFg; const selfSide = @@ -283,24 +297,87 @@ const ChatMessage = ({ ? "self-start ml-2 rounded-bl-sm" : "self-end mr-2 rounded-br-sm"; + const hasProposals = proposedChanges && proposedChanges.length > 0; + const hasMultiple = proposedChanges && proposedChanges.length > 1; + const currentProposal = proposedChanges?.[currentIndex]; + + const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1)); + const goToNext = () => + setCurrentIndex((i) => + Math.min((proposedChanges?.length || 1) - 1, i + 1), + ); + + const canGoPrev = currentIndex > 0; + const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1; + return ( {content} - {proposedChange && onConfirm && onReject && ( - + {hasProposals && currentProposal && onConfirm && onReject && ( + + {/* Event card with optional navigation arrows */} + + {/* Left arrow */} + {hasMultiple && ( + + + + )} + + {/* Event Card */} + + onConfirm(currentProposal.id, currentProposal)} + onReject={() => onReject(currentProposal.id)} + /> + + + {/* Right arrow */} + {hasMultiple && ( + + + + )} + + + {/* Event counter */} + {hasMultiple && ( + + Event {currentIndex + 1} von {proposedChanges.length} + + )} + )} ); diff --git a/apps/client/src/components/Header.tsx b/apps/client/src/components/Header.tsx index d10e21b..8372847 100644 --- a/apps/client/src/components/Header.tsx +++ b/apps/client/src/components/Header.tsx @@ -27,7 +27,7 @@ const Header = (props: HeaderProps) => { {props.children} diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx index 973f11c..0509be1 100644 --- a/apps/client/src/components/ProposedEventCard.tsx +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -5,7 +5,6 @@ import { EventCardBase } from "./EventCardBase"; type ProposedEventCardProps = { proposedChange: ProposedEventChange; - respondedAction?: "confirm" | "reject"; onConfirm: () => void; onReject: () => void; }; @@ -59,12 +58,12 @@ const ConfirmRejectButtons = ({ export const ProposedEventCard = ({ proposedChange, - respondedAction, onConfirm, onReject, }: ProposedEventCardProps) => { const event = proposedChange.event; - const isDisabled = !!respondedAction; + // respondedAction is now part of the proposedChange + const isDisabled = !!proposedChange.respondedAction; if (!event) { return null; @@ -82,7 +81,7 @@ export const ProposedEventCard = ({ > diff --git a/apps/client/src/services/ChatService.ts b/apps/client/src/services/ChatService.ts index d132d2e..5731644 100644 --- a/apps/client/src/services/ChatService.ts +++ b/apps/client/src/services/ChatService.ts @@ -11,12 +11,17 @@ import { import { ApiClient } from "./ApiClient"; interface ConfirmEventRequest { + proposalId: string; action: EventAction; event?: CreateEventDTO; eventId?: string; updates?: UpdateEventDTO; } +interface RejectEventRequest { + proposalId: string; +} + export const ChatService = { sendMessage: async (data: SendMessageDTO): Promise => { return ApiClient.post("/chat/message", data); @@ -25,12 +30,19 @@ export const ChatService = { confirmEvent: async ( conversationId: string, messageId: string, + proposalId: string, action: EventAction, event?: CreateEventDTO, eventId?: string, updates?: UpdateEventDTO, ): Promise => { - const body: ConfirmEventRequest = { action, event, eventId, updates }; + const body: ConfirmEventRequest = { + proposalId, + action, + event, + eventId, + updates, + }; return ApiClient.post( `/chat/confirm/${conversationId}/${messageId}`, body, @@ -40,9 +52,12 @@ export const ChatService = { rejectEvent: async ( conversationId: string, messageId: string, + proposalId: string, ): Promise => { + const body: RejectEventRequest = { proposalId }; return ApiClient.post( `/chat/reject/${conversationId}/${messageId}`, + body, ); }, diff --git a/apps/client/src/stores/ChatStore.ts b/apps/client/src/stores/ChatStore.ts index f818bf7..8f2aea4 100644 --- a/apps/client/src/stores/ChatStore.ts +++ b/apps/client/src/stores/ChatStore.ts @@ -7,8 +7,7 @@ export type MessageData = { id: string; side: BubbleSide; content: string; - proposedChange?: ProposedEventChange; - respondedAction?: "confirm" | "reject"; + proposedChanges?: ProposedEventChange[]; conversationId?: string; }; @@ -46,8 +45,7 @@ export function chatMessageToMessageData(msg: ChatMessage): MessageData { id: msg.id, side: msg.sender === "assistant" ? "left" : "right", content: msg.content, - proposedChange: msg.proposedChange, - respondedAction: msg.respondedAction, + proposedChanges: msg.proposedChanges, conversationId: msg.conversationId, }; } diff --git a/apps/server/src/ai/GPTAdapter.ts b/apps/server/src/ai/GPTAdapter.ts index 5f8e50c..8717299 100644 --- a/apps/server/src/ai/GPTAdapter.ts +++ b/apps/server/src/ai/GPTAdapter.ts @@ -61,7 +61,8 @@ export class GPTAdapter implements AIProvider { // Add current user message messages.push({ role: "user", content: message }); - let proposedChange: ProposedEventChange | undefined; + const proposedChanges: ProposedEventChange[] = []; + let proposalIndex = 0; // Tool calling loop while (true) { @@ -81,7 +82,8 @@ export class GPTAdapter implements AIProvider { return { content: assistantMessage.content || "Ich konnte keine Antwort generieren.", - proposedChange, + proposedChanges: + proposedChanges.length > 0 ? proposedChanges : undefined, }; } @@ -95,9 +97,12 @@ export class GPTAdapter implements AIProvider { const result = executeToolCall(name, args, context); - // If the tool returned a proposedChange, capture it + // If the tool returned a proposedChange, add it to the array with unique ID if (result.proposedChange) { - proposedChange = result.proposedChange; + proposedChanges.push({ + id: `proposal-${proposalIndex++}`, + ...result.proposedChange, + }); } // Add assistant message with tool call diff --git a/apps/server/src/ai/utils/systemPrompt.ts b/apps/server/src/ai/utils/systemPrompt.ts index c338f1c..ec0fe90 100644 --- a/apps/server/src/ai/utils/systemPrompt.ts +++ b/apps/server/src/ai/utils/systemPrompt.ts @@ -28,10 +28,33 @@ Wichtige Regeln: - 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 +- 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 - Tool-Verwendung: +- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen! +- Sage NIEMALS einfach nur "ich habe einen Termin erstellt" ohne das Tool zu verwenden +- Die Tools erzeugen Karten, die dem Benutzer angezeigt werden - ohne Tool-Aufruf sieht er nichts + +WICHTIG - Wiederkehrende Termine (RRULE): +- Ein wiederkehrendes Event hat EINE FESTE Start- und Endzeit +- RRULE bestimmt NUR an welchen Tagen das Event wiederholt wird, NICHT unterschiedliche Uhrzeiten pro Tag! +- Wenn der Benutzer UNTERSCHIEDLICHE ZEITEN an verschiedenen Tagen will, MUSST du SEPARATE Events erstellen +- Beispiel: "Arbeit Mo+Do 9-17:30, Fr 9-13" → ZWEI Events: + 1. "Arbeit" Mo+Do 9:00-17:30 (RRULE mit BYDAY=MO,TH) + 2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR) +- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet! +- Gültige RRULE-Optionen: FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), BYDAY (MO,TU,WE,TH,FR,SA,SU), INTERVAL, COUNT, UNTIL + +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 +- Gute Beispiele: "Alles klar!" oder "Hier sind deine Termine:" +- Schlechte Beispiele: Lange Listen mit allen Terminen und ihren Details im Text +- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten + Existierende Termine des Benutzers: ${eventsText}`; } diff --git a/apps/server/src/ai/utils/toolExecutor.ts b/apps/server/src/ai/utils/toolExecutor.ts index 48589b0..7e6a175 100644 --- a/apps/server/src/ai/utils/toolExecutor.ts +++ b/apps/server/src/ai/utils/toolExecutor.ts @@ -7,12 +7,17 @@ import { import { AIContext } from "../../services/interfaces"; import { formatDate, formatTime, formatDateTime } from "./eventFormatter"; +/** + * Proposed change without ID - ID is added by GPTAdapter when collecting proposals + */ +type ToolProposedChange = Omit; + /** * Result of executing a tool call. */ export interface ToolResult { content: string; - proposedChange?: ProposedEventChange; + proposedChange?: ToolProposedChange; } /** diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index 6bc9d29..5efd21d 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -31,7 +31,8 @@ export class ChatController { try { const userId = req.user!.userId; const { conversationId, messageId } = req.params; - const { action, event, eventId, updates } = req.body as { + const { proposalId, action, event, eventId, updates } = req.body as { + proposalId: string; action: EventAction; event?: CreateEventDTO; eventId?: string; @@ -41,6 +42,7 @@ export class ChatController { userId, conversationId, messageId, + proposalId, action, event, eventId, @@ -57,10 +59,12 @@ export class ChatController { try { const userId = req.user!.userId; const { conversationId, messageId } = req.params; + const { proposalId } = req.body as { proposalId: string }; const response = await this.chatService.rejectEvent( userId, conversationId, messageId, + proposalId, ); res.json(response); } catch (error) { diff --git a/apps/server/src/logging/Logged.ts b/apps/server/src/logging/Logged.ts index 91703e9..2d7974e 100644 --- a/apps/server/src/logging/Logged.ts +++ b/apps/server/src/logging/Logged.ts @@ -1,5 +1,57 @@ 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 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function summarizeArgs(args: any[]): any[] { + return args.map((arg) => summarizeValue(arg)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function summarizeValue(value: any, depth = 0): any { + if (depth > 2) return "[...]"; + + if (value === null || value === undefined) return value; + + if (Array.isArray(value)) { + return `[Array(${value.length})]`; + } + + if (typeof value === "string" && value.length > 100) { + return value.substring(0, 100) + "..."; + } + + if (typeof value === "object") { + // Summarize known large fields + const summarized: Record = {}; + 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)); + } else if (Array.isArray(val)) { + summarized[key] = `[Array(${val.length})]`; + } else if (typeof val === "object" && val !== null) { + summarized[key] = summarizeValue(val, depth + 1); + } else if (typeof val === "string" && val.length > 100) { + summarized[key] = val.substring(0, 100) + "..."; + } else { + summarized[key] = val; + } + } + return summarized; + } + + return value; +} + export function Logged(name: string) { const log = createLogger(name); @@ -27,8 +79,8 @@ export function Logged(name: string) { const start = performance.now(); const method = String(propKey); - // Pino's redact handles sanitization - just pass args directly - log.debug({ method, args: methodArgs }, `${method} started`); + // Summarize args to avoid huge log entries + log.debug({ method, args: summarizeArgs(methodArgs) }, `${method} started`); const logCompletion = (err?: unknown) => { const duration = Math.round(performance.now() - start); diff --git a/apps/server/src/repositories/mongo/MongoChatRepository.ts b/apps/server/src/repositories/mongo/MongoChatRepository.ts index ea432ba..3e6622d 100644 --- a/apps/server/src/repositories/mongo/MongoChatRepository.ts +++ b/apps/server/src/repositories/mongo/MongoChatRepository.ts @@ -53,7 +53,7 @@ export class MongoChatRepository implements ChatRepository { conversationId: conversationId, sender: message.sender, content: message.content, - proposedChange: message.proposedChange, + proposedChanges: message.proposedChanges, }); return repoMessage.toJSON() as unknown as ChatMessage; } @@ -69,4 +69,17 @@ export class MongoChatRepository implements ChatRepository { ); return doc ? (doc.toJSON() as unknown as ChatMessage) : null; } + + async updateProposalResponse( + messageId: string, + proposalId: string, + respondedAction: "confirm" | "reject", + ): Promise { + const doc = await ChatMessageModel.findOneAndUpdate( + { _id: messageId, "proposedChanges.id": proposalId }, + { $set: { "proposedChanges.$.respondedAction": respondedAction } }, + { new: true }, + ); + return doc ? (doc.toJSON() as unknown as ChatMessage) : null; + } } diff --git a/apps/server/src/repositories/mongo/models/ChatModel.ts b/apps/server/src/repositories/mongo/models/ChatModel.ts index 493672c..328394e 100644 --- a/apps/server/src/repositories/mongo/models/ChatModel.ts +++ b/apps/server/src/repositories/mongo/models/ChatModel.ts @@ -44,6 +44,7 @@ const UpdatesSchema = new Schema( const ProposedChangeSchema = new Schema( { + id: { type: String, required: true }, action: { type: String, enum: ["create", "update", "delete"], @@ -52,6 +53,10 @@ const ProposedChangeSchema = new Schema( eventId: { type: String }, event: { type: EventSchema }, updates: { type: UpdatesSchema }, + respondedAction: { + type: String, + enum: ["confirm", "reject"], + }, }, { _id: false }, ); @@ -77,12 +82,9 @@ const ChatMessageSchema = new Schema< type: String, required: true, }, - proposedChange: { - type: ProposedChangeSchema, - }, - respondedAction: { - type: String, - enum: ["confirm", "reject"], + proposedChanges: { + type: [ProposedChangeSchema], + default: undefined, }, }, { diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index aad270a..0dbe3ce 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -14,7 +14,7 @@ import { import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; -type TestResponse = { content: string; proposedChange?: ProposedEventChange }; +type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] }; // Test response index (cycles through responses) let responseIndex = 0; @@ -22,7 +22,134 @@ let responseIndex = 0; // Static test responses (event proposals) const staticResponses: TestResponse[] = [ // {{{ - // Response 0: Help response (text only) + // === MULTI-EVENT TEST RESPONSES === + // Response 0: 3 Meetings an verschiedenen Tagen + { + content: + "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:", + proposedChanges: [ + { + id: "multi-1-a", + action: "create", + event: { + title: "Team-Meeting Montag", + startTime: getDay("Monday", 1, 10, 0), + endTime: getDay("Monday", 1, 11, 0), + description: "Wöchentliches Standup", + }, + }, + { + id: "multi-1-b", + action: "create", + event: { + title: "Team-Meeting Mittwoch", + startTime: getDay("Wednesday", 1, 10, 0), + endTime: getDay("Wednesday", 1, 11, 0), + description: "Sprint Planning", + }, + }, + { + id: "multi-1-c", + action: "create", + event: { + title: "Team-Meeting Freitag", + startTime: getDay("Friday", 1, 10, 0), + endTime: getDay("Friday", 1, 11, 0), + description: "Retrospektive", + }, + }, + ], + }, + // Response 1: 5 Termine für einen Projekttag + { + content: + "Ich habe deinen kompletten Projekttag am Dienstag geplant:", + proposedChanges: [ + { + id: "multi-2-a", + action: "create", + event: { + title: "Kickoff-Meeting", + startTime: getDay("Tuesday", 1, 9, 0), + endTime: getDay("Tuesday", 1, 10, 0), + description: "Projektstart mit dem Team", + }, + }, + { + id: "multi-2-b", + action: "create", + event: { + title: "Design Review", + startTime: getDay("Tuesday", 1, 10, 30), + endTime: getDay("Tuesday", 1, 11, 30), + description: "UI/UX Besprechung", + }, + }, + { + id: "multi-2-c", + action: "create", + event: { + title: "Mittagspause", + startTime: getDay("Tuesday", 1, 12, 0), + endTime: getDay("Tuesday", 1, 13, 0), + description: "Team-Lunch", + }, + }, + { + id: "multi-2-d", + action: "create", + event: { + title: "Tech Review", + startTime: getDay("Tuesday", 1, 14, 0), + endTime: getDay("Tuesday", 1, 15, 30), + description: "Architektur-Diskussion", + }, + }, + { + id: "multi-2-e", + action: "create", + event: { + title: "Wrap-up", + startTime: getDay("Tuesday", 1, 16, 0), + endTime: getDay("Tuesday", 1, 16, 30), + description: "Zusammenfassung und nächste Schritte", + }, + }, + ], + }, + // Response 2: 2 wiederkehrende Termine + { + content: + "Ich erstelle dir zwei wiederkehrende Fitness-Termine:", + proposedChanges: [ + { + id: "multi-3-a", + action: "create", + event: { + title: "Yoga", + startTime: getDay("Monday", 1, 7, 0), + endTime: getDay("Monday", 1, 8, 0), + description: "Morgen-Yoga", + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", + }, + }, + { + id: "multi-3-b", + action: "create", + event: { + title: "Laufen", + startTime: getDay("Tuesday", 1, 18, 0), + endTime: getDay("Tuesday", 1, 19, 0), + description: "Abendlauf im Park", + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH", + }, + }, + ], + }, + // === ORIGINAL RESPONSES === + // Response 3: Help response (text only) { content: "Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" + @@ -35,30 +162,36 @@ const staticResponses: TestResponse[] = [ { content: "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:", - proposedChange: { - action: "create", - event: { - title: "Meeting mit Jens", - startTime: getDay("Friday", 1, 14, 0), - endTime: getDay("Friday", 1, 15, 0), - description: "Arbeitstreffen", + proposedChanges: [ + { + id: "test-1", + action: "create", + event: { + title: "Meeting mit Jens", + startTime: getDay("Friday", 1, 14, 0), + endTime: getDay("Friday", 1, 15, 0), + description: "Arbeitstreffen", + }, }, - }, + ], }, // Response 2: Recurring event - every Saturday 10:00 { content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:", - proposedChange: { - action: "create", - event: { - title: "Badezimmer putzen", - startTime: getDay("Saturday", 1, 10, 0), - endTime: getDay("Saturday", 1, 11, 0), - isRecurring: true, - recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", + proposedChanges: [ + { + id: "test-2", + action: "create", + event: { + title: "Badezimmer putzen", + startTime: getDay("Saturday", 1, 10, 0), + endTime: getDay("Saturday", 1, 11, 0), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", + }, }, - }, + ], }, // Response 3: 2-week overview (DYNAMIC - placeholder) { content: "" }, @@ -68,45 +201,54 @@ const staticResponses: TestResponse[] = [ { content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!", - proposedChange: { - action: "create", - event: { - title: "Arzttermin Dr. Müller", - startTime: getDay("Wednesday", 1, 9, 30), - endTime: getDay("Wednesday", 1, 10, 30), - description: "Routineuntersuchung - Versichertenkarte nicht vergessen", + proposedChanges: [ + { + id: "test-5", + action: "create", + event: { + title: "Arzttermin Dr. Müller", + startTime: getDay("Wednesday", 1, 9, 30), + endTime: getDay("Wednesday", 1, 10, 30), + description: "Routineuntersuchung - Versichertenkarte nicht vergessen", + }, }, - }, + ], }, // Response 6: Birthday - yearly recurring { content: "Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:", - proposedChange: { - action: "create", - event: { - title: "Mamas Geburtstag", - startTime: getDay("Thursday", 2, 0, 0), - endTime: getDay("Thursday", 2, 23, 59), - isRecurring: true, - recurrenceRule: "FREQ=YEARLY", + proposedChanges: [ + { + id: "test-6", + action: "create", + event: { + title: "Mamas Geburtstag", + startTime: getDay("Thursday", 2, 0, 0), + endTime: getDay("Thursday", 2, 23, 59), + isRecurring: true, + recurrenceRule: "FREQ=YEARLY", + }, }, - }, + ], }, // Response 7: Gym - recurring for 2 months (8 weeks) { content: "Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:", - proposedChange: { - action: "create", - event: { - title: "Fitnessstudio Probetraining", - startTime: getDay("Tuesday", 1, 18, 0), - endTime: getDay("Tuesday", 1, 19, 30), - isRecurring: true, - recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", + proposedChanges: [ + { + id: "test-7", + action: "create", + event: { + title: "Fitnessstudio Probetraining", + startTime: getDay("Tuesday", 1, 18, 0), + endTime: getDay("Tuesday", 1, 19, 30), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", + }, }, - }, + ], }, // Response 8: 1-week overview (DYNAMIC - placeholder) { content: "" }, @@ -114,44 +256,53 @@ const staticResponses: TestResponse[] = [ { content: "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:", - proposedChange: { - action: "create", - event: { - title: "Telefonat mit Mama", - startTime: getDay("Wednesday", 1, 11, 0), - endTime: getDay("Wednesday", 1, 11, 30), + proposedChanges: [ + { + id: "test-9", + action: "create", + event: { + title: "Telefonat mit Mama", + startTime: getDay("Wednesday", 1, 11, 0), + endTime: getDay("Wednesday", 1, 11, 30), + }, }, - }, + ], }, // Response 10: Update "Telefonat mit Mama" +3 days (DYNAMIC - placeholder) { content: "" }, // Response 11: Birthday party - evening event { content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!", - proposedChange: { - action: "create", - event: { - title: "Geburtstagsfeier Lisa", - startTime: getDay("Saturday", 2, 19, 0), - endTime: getDay("Saturday", 2, 23, 0), - description: "Geschenk: Buch über Fotografie", + proposedChanges: [ + { + id: "test-11", + action: "create", + event: { + title: "Geburtstagsfeier Lisa", + startTime: getDay("Saturday", 2, 19, 0), + endTime: getDay("Saturday", 2, 23, 0), + description: "Geschenk: Buch über Fotografie", + }, }, - }, + ], }, // Response 12: Language course - limited to 8 weeks (Thu + Sat) { content: "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag und Samstag für die nächsten 8 Wochen:", - proposedChange: { - action: "create", - event: { - title: "Spanischkurs VHS", - startTime: getDay("Thursday", 1, 19, 0), - endTime: getDay("Thursday", 1, 20, 30), - isRecurring: true, - recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8", + proposedChanges: [ + { + id: "test-12", + action: "create", + event: { + title: "Spanischkurs VHS", + startTime: getDay("Thursday", 1, 19, 0), + endTime: getDay("Thursday", 1, 20, 30), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8", + }, }, - }, + ], }, // Response 13: Monthly overview (DYNAMIC - placeholder) { content: "" }, @@ -177,17 +328,20 @@ async function getTestResponse( if (jensEvent) { return { content: "Soll ich diesen Termin wirklich löschen?", - proposedChange: { - action: "delete", - eventId: jensEvent.id, - event: { - title: jensEvent.title, - startTime: jensEvent.startTime, - endTime: jensEvent.endTime, - description: jensEvent.description, - isRecurring: jensEvent.isRecurring, + proposedChanges: [ + { + id: "test-4", + action: "delete", + eventId: jensEvent.id, + event: { + title: jensEvent.title, + startTime: jensEvent.startTime, + endTime: jensEvent.endTime, + description: jensEvent.description, + isRecurring: jensEvent.isRecurring, + }, }, - }, + ], }; } return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." }; @@ -210,18 +364,21 @@ async function getTestResponse( return { content: "Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:", - proposedChange: { - action: "update", - eventId: mamaEvent.id, - updates: { startTime: newStart, endTime: newEnd }, - // Include event with new times for display - event: { - title: mamaEvent.title, - startTime: newStart, - endTime: newEnd, - description: mamaEvent.description, + proposedChanges: [ + { + id: "test-10", + action: "update", + eventId: mamaEvent.id, + updates: { startTime: newStart, endTime: newEnd }, + // Include event with new times for display + event: { + title: mamaEvent.title, + startTime: newStart, + endTime: newEnd, + description: mamaEvent.description, + }, }, - }, + ], }; } return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." }; @@ -290,7 +447,7 @@ export class ChatService { const answerMessage = await this.chatRepo.createMessage(conversationId, { sender: "assistant", content: response.content, - proposedChange: response.proposedChange, + proposedChanges: response.proposedChanges, }); return { message: answerMessage, conversationId: conversationId }; @@ -300,15 +457,14 @@ export class ChatService { userId: string, conversationId: string, messageId: string, + proposalId: string, action: EventAction, event?: CreateEventDTO, eventId?: string, updates?: UpdateEventDTO, ): Promise { - // Update original message with respondedAction - await this.chatRepo.updateMessage(messageId, { - respondedAction: "confirm", - }); + // Update specific proposal with respondedAction + await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm"); // Perform the actual event operation let content: string; @@ -343,9 +499,10 @@ export class ChatService { userId: string, conversationId: string, messageId: string, + proposalId: string, ): Promise { - // Update original message with respondedAction - await this.chatRepo.updateMessage(messageId, { respondedAction: "reject" }); + // Update specific proposal with respondedAction + await this.chatRepo.updateProposalResponse(messageId, proposalId, "reject"); // Save response message to DB const message = await this.chatRepo.createMessage(conversationId, { diff --git a/apps/server/src/services/interfaces/AIProvider.ts b/apps/server/src/services/interfaces/AIProvider.ts index 92ae297..de82e15 100644 --- a/apps/server/src/services/interfaces/AIProvider.ts +++ b/apps/server/src/services/interfaces/AIProvider.ts @@ -13,7 +13,7 @@ export interface AIContext { export interface AIResponse { content: string; - proposedChange?: ProposedEventChange; + proposedChanges?: ProposedEventChange[]; } export interface AIProvider { diff --git a/apps/server/src/services/interfaces/ChatRepository.ts b/apps/server/src/services/interfaces/ChatRepository.ts index f858b4d..2a681da 100644 --- a/apps/server/src/services/interfaces/ChatRepository.ts +++ b/apps/server/src/services/interfaces/ChatRepository.ts @@ -26,4 +26,10 @@ export interface ChatRepository { messageId: string, updates: UpdateMessageDTO, ): Promise; + + updateProposalResponse( + messageId: string, + proposalId: string, + respondedAction: "confirm" | "reject", + ): Promise; } diff --git a/apps/server/src/utils/recurrenceExpander.ts b/apps/server/src/utils/recurrenceExpander.ts index aa4f312..74ab071 100644 --- a/apps/server/src/utils/recurrenceExpander.ts +++ b/apps/server/src/utils/recurrenceExpander.ts @@ -58,8 +58,10 @@ export function expandRecurringEvents( // Recurring event: parse RRULE and expand try { + // Strip RRULE: prefix if present (AI may include it) + const ruleString = event.recurrenceRule.replace(/^RRULE:/i, ""); const rule = rrulestr( - `DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${event.recurrenceRule}`, + `DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`, ); // Get occurrences within the range (using fake UTC dates) diff --git a/packages/shared/src/models/ChatMessage.ts b/packages/shared/src/models/ChatMessage.ts index b51df45..83ebf1f 100644 --- a/packages/shared/src/models/ChatMessage.ts +++ b/packages/shared/src/models/ChatMessage.ts @@ -7,10 +7,12 @@ export type EventAction = "create" | "update" | "delete"; export type RespondedAction = "confirm" | "reject"; export interface ProposedEventChange { + id: string; // Unique ID for each proposal action: EventAction; eventId?: string; // Required for update/delete event?: CreateEventDTO; // Required for create updates?: UpdateEventDTO; // Required for update + respondedAction?: RespondedAction; // User's response to this specific proposal } export interface ChatMessage { @@ -18,8 +20,7 @@ export interface ChatMessage { conversationId: string; sender: MessageSender; content: string; - proposedChange?: ProposedEventChange; - respondedAction?: RespondedAction; + proposedChanges?: ProposedEventChange[]; // Array of event proposals createdAt?: Date; } @@ -38,7 +39,7 @@ export interface SendMessageDTO { export interface CreateMessageDTO { sender: MessageSender; content: string; - proposedChange?: ProposedEventChange; + proposedChanges?: ProposedEventChange[]; } export interface GetMessagesOptions { @@ -47,6 +48,7 @@ export interface GetMessagesOptions { } export interface UpdateMessageDTO { + proposalId?: string; // Identifies which proposal to update respondedAction?: RespondedAction; }