feat: support multiple event proposals in single AI response

- Change proposedChange to proposedChanges array in ChatMessage type
- Add unique id and individual respondedAction to each ProposedEventChange
- Implement arrow navigation UI for multiple proposals with "Event X von Y" counter
- Add updateProposalResponse() method for per-proposal confirm/reject tracking
- GPTAdapter now collects multiple tool call results into proposals array
- Add RRULE documentation to system prompt (separate events for different times)
- Fix RRULE parsing to strip RRULE: prefix if present
- Add log summarization for large args (conversationHistory, existingEvents)
- Keep proposedChanges logged in full for debugging AI issues
This commit is contained in:
2026-01-10 23:30:32 +01:00
parent 8efe6c304e
commit e6b9dd9d34
18 changed files with 533 additions and 158 deletions

View File

@@ -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

View File

@@ -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 = () => {
<ChatMessage
side={item.side}
content={item.content}
proposedChange={item.proposedChange}
respondedAction={item.respondedAction}
onConfirm={() =>
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 (
<View
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
style={{
borderColor: borderColor,
maxWidth: "80%",
minWidth: hasProposals ? "75%" : undefined,
elevation: 8,
}}
>
<Text className="p-2">{content}</Text>
{proposedChange && onConfirm && onReject && (
<ProposedEventCard
proposedChange={proposedChange}
respondedAction={respondedAction}
onConfirm={onConfirm}
onReject={onReject}
{hasProposals && currentProposal && onConfirm && onReject && (
<View>
{/* Event card with optional navigation arrows */}
<View className="flex-row items-center">
{/* Left arrow */}
{hasMultiple && (
<Pressable
onPress={goToPrev}
disabled={!canGoPrev}
className="p-1"
style={{ opacity: canGoPrev ? 1 : 0.3 }}
>
<Ionicons
name="chevron-back"
size={24}
color={currentTheme.primeFg}
/>
</Pressable>
)}
{/* Event Card */}
<View className="flex-1">
<ProposedEventCard
proposedChange={currentProposal}
onConfirm={() => onConfirm(currentProposal.id, currentProposal)}
onReject={() => onReject(currentProposal.id)}
/>
</View>
{/* Right arrow */}
{hasMultiple && (
<Pressable
onPress={goToNext}
disabled={!canGoNext}
className="p-1"
style={{ opacity: canGoNext ? 1 : 0.3 }}
>
<Ionicons
name="chevron-forward"
size={24}
color={currentTheme.primeFg}
/>
</Pressable>
)}
</View>
{/* Event counter */}
{hasMultiple && (
<Text
className="text-center text-sm pb-2"
style={{ color: currentTheme.textSecondary || "#666" }}
>
Event {currentIndex + 1} von {proposedChanges.length}
</Text>
)}
</View>
)}
</View>
);

View File

@@ -27,7 +27,7 @@ const Header = (props: HeaderProps) => {
{props.children}
<Pressable
onPress={handleLogout}
className="absolute right-1 top-0 p-2"
className="absolute left-1 bottom-0 p-2"
hitSlop={8}
>
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />

View File

@@ -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 = ({
>
<ConfirmRejectButtons
isDisabled={isDisabled}
respondedAction={respondedAction}
respondedAction={proposedChange.respondedAction}
onConfirm={onConfirm}
onReject={onReject}
/>

View File

@@ -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<ChatResponse> => {
return ApiClient.post<ChatResponse>("/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<ChatResponse> => {
const body: ConfirmEventRequest = { action, event, eventId, updates };
const body: ConfirmEventRequest = {
proposalId,
action,
event,
eventId,
updates,
};
return ApiClient.post<ChatResponse>(
`/chat/confirm/${conversationId}/${messageId}`,
body,
@@ -40,9 +52,12 @@ export const ChatService = {
rejectEvent: async (
conversationId: string,
messageId: string,
proposalId: string,
): Promise<ChatResponse> => {
const body: RejectEventRequest = { proposalId };
return ApiClient.post<ChatResponse>(
`/chat/reject/${conversationId}/${messageId}`,
body,
);
},

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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}`;
}

View File

@@ -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<ProposedEventChange, "id" | "respondedAction">;
/**
* Result of executing a tool call.
*/
export interface ToolResult {
content: string;
proposedChange?: ProposedEventChange;
proposedChange?: ToolProposedChange;
}
/**

View File

@@ -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) {

View File

@@ -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<string, unknown> = {};
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);

View File

@@ -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<ChatMessage | null> {
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;
}
}

View File

@@ -44,6 +44,7 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
const ProposedChangeSchema = new Schema<ProposedEventChange>(
{
id: { type: String, required: true },
action: {
type: String,
enum: ["create", "update", "delete"],
@@ -52,6 +53,10 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
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,
},
},
{

View File

@@ -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,7 +162,9 @@ 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: {
proposedChanges: [
{
id: "test-1",
action: "create",
event: {
title: "Meeting mit Jens",
@@ -44,12 +173,15 @@ const staticResponses: TestResponse[] = [
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: {
proposedChanges: [
{
id: "test-2",
action: "create",
event: {
title: "Badezimmer putzen",
@@ -59,6 +191,7 @@ const staticResponses: TestResponse[] = [
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
},
},
],
},
// Response 3: 2-week overview (DYNAMIC - placeholder)
{ content: "" },
@@ -68,7 +201,9 @@ const staticResponses: TestResponse[] = [
{
content:
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
proposedChange: {
proposedChanges: [
{
id: "test-5",
action: "create",
event: {
title: "Arzttermin Dr. Müller",
@@ -77,12 +212,15 @@ const staticResponses: TestResponse[] = [
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: {
proposedChanges: [
{
id: "test-6",
action: "create",
event: {
title: "Mamas Geburtstag",
@@ -92,12 +230,15 @@ const staticResponses: TestResponse[] = [
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: {
proposedChanges: [
{
id: "test-7",
action: "create",
event: {
title: "Fitnessstudio Probetraining",
@@ -107,6 +248,7 @@ const staticResponses: TestResponse[] = [
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
},
},
],
},
// Response 8: 1-week overview (DYNAMIC - placeholder)
{ content: "" },
@@ -114,7 +256,9 @@ const staticResponses: TestResponse[] = [
{
content:
"Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
proposedChange: {
proposedChanges: [
{
id: "test-9",
action: "create",
event: {
title: "Telefonat mit Mama",
@@ -122,13 +266,16 @@ const staticResponses: TestResponse[] = [
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: {
proposedChanges: [
{
id: "test-11",
action: "create",
event: {
title: "Geburtstagsfeier Lisa",
@@ -137,12 +284,15 @@ const staticResponses: TestResponse[] = [
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: {
proposedChanges: [
{
id: "test-12",
action: "create",
event: {
title: "Spanischkurs VHS",
@@ -152,6 +302,7 @@ const staticResponses: TestResponse[] = [
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8",
},
},
],
},
// Response 13: Monthly overview (DYNAMIC - placeholder)
{ content: "" },
@@ -177,7 +328,9 @@ async function getTestResponse(
if (jensEvent) {
return {
content: "Soll ich diesen Termin wirklich löschen?",
proposedChange: {
proposedChanges: [
{
id: "test-4",
action: "delete",
eventId: jensEvent.id,
event: {
@@ -188,6 +341,7 @@ async function getTestResponse(
isRecurring: jensEvent.isRecurring,
},
},
],
};
}
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
@@ -210,7 +364,9 @@ async function getTestResponse(
return {
content:
"Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:",
proposedChange: {
proposedChanges: [
{
id: "test-10",
action: "update",
eventId: mamaEvent.id,
updates: { startTime: newStart, endTime: newEnd },
@@ -222,6 +378,7 @@ async function getTestResponse(
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<ChatResponse> {
// 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<ChatResponse> {
// 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, {

View File

@@ -13,7 +13,7 @@ export interface AIContext {
export interface AIResponse {
content: string;
proposedChange?: ProposedEventChange;
proposedChanges?: ProposedEventChange[];
}
export interface AIProvider {

View File

@@ -26,4 +26,10 @@ export interface ChatRepository {
messageId: string,
updates: UpdateMessageDTO,
): Promise<ChatMessage | null>;
updateProposalResponse(
messageId: string,
proposalId: string,
respondedAction: "confirm" | "reject",
): Promise<ChatMessage | null>;
}

View File

@@ -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)

View File

@@ -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;
}