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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<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, {
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface AIContext {
|
||||
|
||||
export interface AIResponse {
|
||||
content: string;
|
||||
proposedChange?: ProposedEventChange;
|
||||
proposedChanges?: ProposedEventChange[];
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user