Rename all workspace packages to reflect the actual project name: - @caldav/client -> @calchat/client - @caldav/server -> @calchat/server - @caldav/shared -> @calchat/shared - Root package: caldav-mono -> calchat-mono Update all import statements across client and server to use the new package names. Update default MongoDB database name and logging service identifier accordingly.
550 lines
16 KiB
TypeScript
550 lines
16 KiB
TypeScript
import {
|
|
ChatMessage,
|
|
ChatResponse,
|
|
SendMessageDTO,
|
|
ConversationSummary,
|
|
GetMessagesOptions,
|
|
ProposedEventChange,
|
|
getDay,
|
|
CreateEventDTO,
|
|
UpdateEventDTO,
|
|
EventAction,
|
|
CreateMessageDTO,
|
|
} from "@calchat/shared";
|
|
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
|
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
|
|
|
type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] };
|
|
|
|
// Test response index (cycles through responses)
|
|
let responseIndex = 0;
|
|
|
|
// Static test responses (event proposals)
|
|
const staticResponses: TestResponse[] = [
|
|
// {{{
|
|
// === 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" +
|
|
'• "Erstelle einen Termin für morgen um 15 Uhr"\n' +
|
|
'• "Was habe ich nächste Woche vor?"\n' +
|
|
'• "Verschiebe das Meeting auf Donnerstag"\n\n' +
|
|
"Wie kann ich dir helfen?",
|
|
},
|
|
// Response 1: Meeting mit Jens - next Friday 14:00
|
|
{
|
|
content:
|
|
"Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
|
|
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:",
|
|
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: "" },
|
|
// Response 4: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
|
|
{ content: "" },
|
|
// Response 5: Doctor appointment with description
|
|
{
|
|
content:
|
|
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
|
|
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:",
|
|
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:",
|
|
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: "" },
|
|
// Response 9: Phone call - short appointment (Wednesday, so +2 days = Friday)
|
|
{
|
|
content:
|
|
"Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
|
|
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ß!",
|
|
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:",
|
|
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: "" },
|
|
// }}}
|
|
];
|
|
|
|
async function getTestResponse(
|
|
index: number,
|
|
eventRepo: EventRepository,
|
|
userId: string,
|
|
): Promise<TestResponse> {
|
|
const responseIdx = index % staticResponses.length;
|
|
|
|
// Dynamic responses: fetch events from DB and format
|
|
if (responseIdx === 3) {
|
|
return { content: await getWeeksOverview(eventRepo, userId, 2) };
|
|
}
|
|
|
|
if (responseIdx === 4) {
|
|
// Delete "Meeting mit Jens"
|
|
const events = await eventRepo.findByUserId(userId);
|
|
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
|
|
if (jensEvent) {
|
|
return {
|
|
content: "Soll ich diesen Termin wirklich löschen?",
|
|
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." };
|
|
}
|
|
|
|
if (responseIdx === 8) {
|
|
return { content: await getWeeksOverview(eventRepo, userId, 1) };
|
|
}
|
|
|
|
if (responseIdx === 10) {
|
|
// Update "Telefonat mit Mama" +3 days and change time to 13:00
|
|
const events = await eventRepo.findByUserId(userId);
|
|
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
|
|
if (mamaEvent) {
|
|
const newStart = new Date(mamaEvent.startTime);
|
|
newStart.setDate(newStart.getDate() + 3);
|
|
newStart.setHours(13, 0, 0, 0);
|
|
const newEnd = new Date(newStart);
|
|
newEnd.setMinutes(30);
|
|
return {
|
|
content:
|
|
"Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:",
|
|
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." };
|
|
}
|
|
|
|
if (responseIdx === 13) {
|
|
const now = new Date();
|
|
return {
|
|
content: await getMonthOverview(
|
|
eventRepo,
|
|
userId,
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
),
|
|
};
|
|
}
|
|
|
|
return staticResponses[responseIdx];
|
|
}
|
|
|
|
export class ChatService {
|
|
constructor(
|
|
private chatRepo: ChatRepository,
|
|
private eventRepo: EventRepository,
|
|
private aiProvider: AIProvider,
|
|
) {}
|
|
|
|
async processMessage(
|
|
userId: string,
|
|
data: SendMessageDTO,
|
|
): Promise<ChatResponse> {
|
|
let conversationId = data.conversationId;
|
|
if (!conversationId) {
|
|
const conversation = await this.chatRepo.createConversation(userId);
|
|
conversationId = conversation.id;
|
|
}
|
|
|
|
// Save user message
|
|
await this.chatRepo.createMessage(conversationId, {
|
|
sender: "user",
|
|
content: data.content,
|
|
});
|
|
|
|
let response: TestResponse;
|
|
|
|
if (process.env.USE_TEST_RESPONSES === "true") {
|
|
// Test mode: use static responses
|
|
response = await getTestResponse(responseIndex, this.eventRepo, userId);
|
|
responseIndex++;
|
|
} else {
|
|
// Production mode: use real AI
|
|
const events = await this.eventRepo.findByUserId(userId);
|
|
const history = await this.chatRepo.getMessages(conversationId, {
|
|
limit: 20,
|
|
});
|
|
|
|
response = await this.aiProvider.processMessage(data.content, {
|
|
userId,
|
|
conversationHistory: history,
|
|
existingEvents: events,
|
|
currentDate: new Date(),
|
|
});
|
|
}
|
|
|
|
// Save and then return assistant response
|
|
const answerMessage = await this.chatRepo.createMessage(conversationId, {
|
|
sender: "assistant",
|
|
content: response.content,
|
|
proposedChanges: response.proposedChanges,
|
|
});
|
|
|
|
return { message: answerMessage, conversationId: conversationId };
|
|
}
|
|
|
|
async confirmEvent(
|
|
userId: string,
|
|
conversationId: string,
|
|
messageId: string,
|
|
proposalId: string,
|
|
action: EventAction,
|
|
event?: CreateEventDTO,
|
|
eventId?: string,
|
|
updates?: UpdateEventDTO,
|
|
): Promise<ChatResponse> {
|
|
// Update specific proposal with respondedAction
|
|
await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm");
|
|
|
|
// Perform the actual event operation
|
|
let content: string;
|
|
|
|
if (action === "create" && event) {
|
|
const createdEvent = await this.eventRepo.create(userId, event);
|
|
content = `Der Termin "${createdEvent.title}" wurde erstellt.`;
|
|
} else if (action === "update" && eventId && updates) {
|
|
const updatedEvent = await this.eventRepo.update(eventId, updates);
|
|
content = updatedEvent
|
|
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
|
|
: "Termin nicht gefunden.";
|
|
} else if (action === "delete" && eventId) {
|
|
await this.eventRepo.delete(eventId);
|
|
content = event?.title
|
|
? `Der Termin "${event.title}" wurde gelöscht.`
|
|
: "Der Termin wurde gelöscht.";
|
|
} else {
|
|
content = "Ungültige Aktion.";
|
|
}
|
|
|
|
// Save response message to DB
|
|
const message = await this.chatRepo.createMessage(conversationId, {
|
|
sender: "assistant",
|
|
content,
|
|
});
|
|
|
|
return { message, conversationId };
|
|
}
|
|
|
|
async rejectEvent(
|
|
userId: string,
|
|
conversationId: string,
|
|
messageId: string,
|
|
proposalId: string,
|
|
): Promise<ChatResponse> {
|
|
// Update specific proposal with respondedAction
|
|
await this.chatRepo.updateProposalResponse(messageId, proposalId, "reject");
|
|
|
|
// Save response message to DB
|
|
const message = await this.chatRepo.createMessage(conversationId, {
|
|
sender: "assistant",
|
|
content: "Der Vorschlag wurde abgelehnt.",
|
|
});
|
|
|
|
return { message, conversationId };
|
|
}
|
|
|
|
async getConversations(userId: string): Promise<ConversationSummary[]> {
|
|
const conversations = await this.chatRepo.getConversationsByUser(userId);
|
|
|
|
// For each conversation, get the last message
|
|
const summaries: ConversationSummary[] = await Promise.all(
|
|
conversations.map(async (conv) => {
|
|
const messages = await this.chatRepo.getMessages(conv.id, { limit: 1 });
|
|
return {
|
|
id: conv.id,
|
|
lastMessage: messages[0],
|
|
createdAt: conv.createdAt,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return summaries;
|
|
}
|
|
|
|
async getConversation(
|
|
userId: string,
|
|
conversationId: string,
|
|
options?: GetMessagesOptions,
|
|
): Promise<ChatMessage[]> {
|
|
// Verify conversation belongs to user
|
|
const conversations = await this.chatRepo.getConversationsByUser(userId);
|
|
const conversation = conversations.find((c) => c.id === conversationId);
|
|
|
|
if (!conversation) {
|
|
throw new Error("Conversation not found");
|
|
}
|
|
|
|
return this.chatRepo.getMessages(conversationId, options);
|
|
}
|
|
}
|