Compare commits

...

2 Commits

Author SHA1 Message Date
675785ec93 feat: replace Claude with GPT for AI chat integration
- Replace ClaudeAdapter with GPTAdapter using OpenAI GPT (gpt-5-mini)
- Implement function calling for calendar operations (getDay, proposeCreate/Update/Delete, searchEvents)
- Add provider-agnostic AI utilities in ai/utils/ (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
- Add USE_TEST_RESPONSES env var to toggle between real AI and test responses
- Switch ChatService.processMessage to use real AI provider
- Add npm run format command for Prettier
- Update CLAUDE.md with new architecture
2026-01-10 00:22:59 +01:00
c897b6d680 feat: implement chat persistence with MongoDB
- Add full chat persistence to database (conversations and messages)
- Implement MongoChatRepository with cursor-based pagination
- Add getConversations/getConversation endpoints in ChatController
- Save user and assistant messages in ChatService.processMessage()
- Track respondedAction (confirm/reject) on proposed event messages
- Load existing messages on chat screen mount
- Add addMessages() bulk action and chatMessageToMessageData() helper to ChatStore
- Add RespondedAction type and UpdateMessageDTO to shared types
2026-01-09 16:21:01 +01:00
23 changed files with 839 additions and 101 deletions

View File

@@ -13,6 +13,7 @@ This is a fullstack TypeScript monorepo with npm workspaces.
### Root (monorepo)
```bash
npm install # Install all dependencies for all workspaces
npm run format # Format all TypeScript files with Prettier
```
### Client (apps/client) - Expo React Native app
@@ -44,7 +45,7 @@ npm run start -w @caldav/server # Run compiled server (port 3000)
| Backend | Express.js | Web framework |
| | MongoDB | Database |
| | Mongoose | ODM |
| | Claude (Anthropic) | AI/LLM for chat |
| | GPT (OpenAI) | AI/LLM for chat |
| | JWT | Authentication |
| Planned | iCalendar | Event export/import |
@@ -91,7 +92,7 @@ src/
└── stores/ # Zustand state management
├── index.ts # Re-exports all stores
├── AuthStore.ts # user, token, isAuthenticated, login(), logout(), setToken()
├── ChatStore.ts # messages[], addMessage(), updateMessage(), clearMessages()
├── ChatStore.ts # messages[], addMessage(), addMessages(), updateMessage(), clearMessages(), chatMessageToMessageData()
└── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
```
@@ -134,7 +135,14 @@ src/
│ ├── MongoEventRepository.ts
│ └── MongoChatRepository.ts
├── ai/
── ClaudeAdapter.ts # Implements AIProvider
── GPTAdapter.ts # Implements AIProvider using OpenAI GPT
│ ├── index.ts # Re-exports GPTAdapter
│ └── utils/ # Shared AI utilities (provider-agnostic)
│ ├── index.ts # Re-exports
│ ├── eventFormatter.ts # formatExistingEvents() for system prompt
│ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs
│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents
└── utils/
├── jwt.ts # signToken(), verifyToken()
├── password.ts # hash(), compare()
@@ -172,7 +180,7 @@ src/
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction
│ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO
│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX, DAY_INDEX_TO_DAY,
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
└── utils/
@@ -184,12 +192,14 @@ src/
- `User`: id, email, displayName, 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?
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?, respondedAction?
- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates?
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
- `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)
- `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events)
- `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `Month`: "January" | "February" | ... | "December"
@@ -262,6 +272,8 @@ Server requires `.env` file in `apps/server/`:
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=1h
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
OPENAI_API_KEY=sk-proj-...
USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI
```
## Current Implementation Status
@@ -281,14 +293,16 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander
- `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
- `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
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
- **Stubbed (TODO):**
- `AuthMiddleware.authenticate()`: Currently uses fake user for testing
- `AuthController`: refresh(), logout()
- `AuthService`: refreshToken()
- `ChatController`: getConversations(), getConversation()
- `MongoChatRepository`: Database persistence for chat
- **Not started:**
- `ClaudeAdapter` (AI integration - currently using test responses)
**Shared:** Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, and date utilities defined and exported.
@@ -302,20 +316,22 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid
- Uses `useFocusEffect` for automatic reload on tab focus
- Chat screen functional with FlashList, message sending, and event confirm/reject
- Messages persisted via ChatStore (survives tab switches)
- Chat screen fully functional with FlashList, message sending, and event confirm/reject
- 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
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `ApiClient`: get(), post(), put(), delete() implemented
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions
- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions)
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
- `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages() - loads from server on mount and persists across tab switches
- Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons
- AuthStore defined with `throw new Error('Not implemented')`

View File

@@ -1,18 +1,29 @@
import { View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, Keyboard } from "react-native";
import {
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
Keyboard,
} from "react-native";
import currentTheme from "../../Themes";
import { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect } from "react";
import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
import { ChatService } from "../../services";
import { useChatStore, MessageData } from "../../stores";
import {
useChatStore,
chatMessageToMessageData,
MessageData,
} from "../../stores";
import { ProposedEventChange } from "@caldav/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard";
// TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios)
// TODO: max width for messages
// TODO: create new messages
type BubbleSide = "left" | "right";
@@ -30,14 +41,43 @@ type ChatInputProps = {
};
const Chat = () => {
const { messages, addMessage, updateMessage } = useChatStore();
const listRef = useRef<FlashList<MessageData>>(null);
const { messages, addMessage, addMessages, updateMessage } = useChatStore();
const listRef =
useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null);
const [currentConversationId, setCurrentConversationId] = useState<
string | undefined
>();
useEffect(() => {
const keyboardDidShow = Keyboard.addListener("keyboardDidShow", scrollToEnd);
const keyboardDidShow = Keyboard.addListener(
"keyboardDidShow",
scrollToEnd,
);
return () => keyboardDidShow.remove();
}, []);
// Load existing messages from database on mount
useEffect(() => {
const fetchMessages = async () => {
try {
const conversationSummaries = await ChatService.getConversations();
if (conversationSummaries.length > 0) {
const conversationId = conversationSummaries[0].id;
setCurrentConversationId(conversationId);
const serverMessages =
await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages);
scrollToEnd();
}
} catch (error) {
console.error("Failed to load messages:", error);
}
};
fetchMessages();
}, []);
const scrollToEnd = () => {
setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true });
@@ -87,13 +127,22 @@ const Chat = () => {
id: Date.now().toString(),
side: "right",
content: text,
conversationId: currentConversationId,
};
addMessage(userMessage);
scrollToEnd();
try {
// Fetch server response
const response = await ChatService.sendMessage({ content: text });
// Fetch server response (include conversationId for existing conversations)
const response = await ChatService.sendMessage({
content: text,
conversationId: currentConversationId,
});
// Track conversation ID for subsequent messages
if (!currentConversationId) {
setCurrentConversationId(response.conversationId);
}
// Show bot response
const botMessage: MessageData = {

View File

@@ -47,13 +47,20 @@ export const ChatService = {
},
getConversations: async (): Promise<ConversationSummary[]> => {
throw new Error("Not implemented");
return ApiClient.get<ConversationSummary[]>("/chat/conversations");
},
getConversation: async (
_id: string,
_options?: GetMessagesOptions,
id: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> => {
throw new Error("Not implemented");
const params = new URLSearchParams();
if (options?.before) params.append("before", options.before);
if (options?.limit) params.append("limit", options.limit.toString());
const queryString = params.toString();
const url = `/chat/conversations/${id}${queryString ? `?${queryString}` : ""}`;
return ApiClient.get<ChatMessage[]>(url);
},
};

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { ProposedEventChange } from "@caldav/shared";
import { ChatMessage, ProposedEventChange } from "@caldav/shared";
type BubbleSide = "left" | "right";
@@ -14,6 +14,7 @@ export type MessageData = {
interface ChatState {
messages: MessageData[];
addMessages: (messages: MessageData[]) => void;
addMessage: (message: MessageData) => void;
updateMessage: (id: string, updates: Partial<MessageData>) => void;
clearMessages: () => void;
@@ -21,6 +22,9 @@ interface ChatState {
export const useChatStore = create<ChatState>((set) => ({
messages: [],
addMessages(messages) {
set((state) => ({ messages: [...state.messages, ...messages] }));
},
addMessage: (message: MessageData) => {
set((state) => ({ messages: [...state.messages, message] }));
},
@@ -35,3 +39,15 @@ export const useChatStore = create<ChatState>((set) => ({
set({ messages: [] });
},
}));
// Helper to convert server ChatMessage to client MessageData
export function chatMessageToMessageData(msg: ChatMessage): MessageData {
return {
id: msg.id,
side: msg.sender === "assistant" ? "left" : "right",
content: msg.content,
proposedChange: msg.proposedChange,
respondedAction: msg.respondedAction,
conversationId: msg.conversationId,
};
}

View File

@@ -1,3 +1,7 @@
export { useAuthStore } from "./AuthStore";
export { useChatStore, type MessageData } from "./ChatStore";
export {
useChatStore,
chatMessageToMessageData,
type MessageData,
} from "./ChatStore";
export { useEventsStore } from "./EventsStore";

View File

@@ -8,13 +8,13 @@
"start": "node dist/app.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@caldav/shared": "*",
"bcrypt": "^6.0.0",
"dotenv": "^16.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.1.1",
"openai": "^6.15.0",
"rrule": "^2.8.1"
},
"devDependencies": {

View File

@@ -1,21 +0,0 @@
import Anthropic from "@anthropic-ai/sdk";
import { AIProvider, AIContext, AIResponse } from "../services/interfaces";
export class ClaudeAdapter implements AIProvider {
private client: Anthropic;
private model: string;
constructor(apiKey?: string, model: string = "claude-3-haiku-20240307") {
this.client = new Anthropic({
apiKey: apiKey || process.env.ANTHROPIC_API_KEY,
});
this.model = model;
}
async processMessage(
message: string,
context: AIContext,
): Promise<AIResponse> {
throw new Error("Not implemented");
}
}

View File

@@ -0,0 +1,116 @@
import OpenAI from "openai";
import { ProposedEventChange } from "@caldav/shared";
import { AIProvider, AIContext, AIResponse } from "../services/interfaces";
import {
buildSystemPrompt,
TOOL_DEFINITIONS,
executeToolCall,
ToolDefinition,
} from "./utils";
/**
* Convert tool definitions to OpenAI format.
*/
function toOpenAITools(
defs: ToolDefinition[],
): OpenAI.Chat.Completions.ChatCompletionTool[] {
return defs.map((def) => ({
type: "function" as const,
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
export class GPTAdapter implements AIProvider {
private client: OpenAI;
private model: string;
private tools: OpenAI.Chat.Completions.ChatCompletionTool[];
constructor(apiKey?: string, model: string = "gpt-5-mini") {
this.client = new OpenAI({
apiKey: apiKey || process.env.OPENAI_API_KEY,
});
this.model = model;
this.tools = toOpenAITools(TOOL_DEFINITIONS);
}
async processMessage(
message: string,
context: AIContext,
): Promise<AIResponse> {
const systemPrompt = buildSystemPrompt(context);
// Build messages array with conversation history
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "developer", content: systemPrompt },
];
// Add conversation history
for (const msg of context.conversationHistory) {
messages.push({
role: msg.sender === "user" ? "user" : "assistant",
content: msg.content,
});
}
// Add current user message
messages.push({ role: "user", content: message });
let proposedChange: ProposedEventChange | undefined;
// Tool calling loop
while (true) {
const response = await this.client.chat.completions.create({
model: this.model,
messages,
tools: this.tools,
});
const assistantMessage = response.choices[0].message;
// If no tool calls, return the final response
if (
!assistantMessage.tool_calls ||
assistantMessage.tool_calls.length === 0
) {
return {
content:
assistantMessage.content || "Ich konnte keine Antwort generieren.",
proposedChange,
};
}
// Process tool calls
for (const toolCall of assistantMessage.tool_calls) {
// Skip non-function tool calls
if (toolCall.type !== "function") continue;
const { name, arguments: argsRaw } = toolCall.function;
const args = JSON.parse(argsRaw);
const result = executeToolCall(name, args, context);
// If the tool returned a proposedChange, capture it
if (result.proposedChange) {
proposedChange = result.proposedChange;
}
// Add assistant message with tool call
messages.push({
role: "assistant",
tool_calls: [toolCall],
});
// Add tool result
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result.content,
});
}
}
}
}

View File

@@ -1 +1 @@
export * from "./ClaudeAdapter";
export * from "./GPTAdapter";

View File

@@ -0,0 +1,29 @@
import { CalendarEvent } from "@caldav/shared";
// German date/time formatting helpers
export const formatDate = (d: Date) => d.toLocaleDateString("de-DE");
export const formatTime = (d: Date) =>
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
export const formatDateTime = (d: Date) =>
`${formatDate(d)} ${d.toLocaleTimeString("de-DE")}`;
/**
* Format a list of events for display in the system prompt.
* Output is in German with date/time formatting.
*/
export function formatExistingEvents(events: CalendarEvent[]): string {
if (events.length === 0) {
return "Keine Termine vorhanden.";
}
return events
.map((e) => {
const start = new Date(e.startTime);
const end = new Date(e.endTime);
const timeStr = `${formatTime(start)} - ${formatTime(end)}`;
const recurring = e.isRecurring ? " (wiederkehrend)" : "";
const desc = e.description ? ` | ${e.description}` : "";
return `- ${e.title} (ID: ${e.id}) | ${formatDate(start)} ${timeStr}${recurring}${desc}`;
})
.join("\n");
}

View File

@@ -0,0 +1,13 @@
export {
formatExistingEvents,
formatDate,
formatTime,
formatDateTime,
} from "./eventFormatter";
export { buildSystemPrompt } from "./systemPrompt";
export {
TOOL_DEFINITIONS,
type ToolDefinition,
type ParameterDef,
} from "./toolDefinitions";
export { executeToolCall, type ToolResult } from "./toolExecutor";

View File

@@ -0,0 +1,37 @@
import { AIContext } from "../../services/interfaces";
import { formatExistingEvents } from "./eventFormatter";
/**
* Build the system prompt for the AI assistant.
* This prompt is provider-agnostic and can be used with any LLM.
*/
export function buildSystemPrompt(context: AIContext): string {
const currentDate = context.currentDate.toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const eventsText = formatExistingEvents(context.existingEvents);
return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat".
Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen.
Antworte immer auf Deutsch.
Aktuelles Datum und Uhrzeit: ${currentDate}
Wichtige Regeln:
- Nutze getDay() für relative Datumsberechnungen (z.B. "nächsten Freitag um 14 Uhr")
- 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
- 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
Existierende Termine des Benutzers:
${eventsText}`;
}

View File

@@ -0,0 +1,170 @@
/**
* Parameter definition for tool parameters.
*/
export interface ParameterDef {
type: "string" | "number" | "boolean" | "object" | "array";
description?: string;
enum?: string[];
}
/**
* Provider-agnostic tool definition format.
* Can be converted to OpenAI, Claude, or other provider formats.
*/
export interface ToolDefinition {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, ParameterDef>;
required: string[];
};
}
/**
* All available tools for the calendar assistant.
*/
export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: "getDay",
description:
"Get a date for a specific weekday relative to today. Returns an ISO date string.",
parameters: {
type: "object",
properties: {
day: {
type: "string",
enum: [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
],
description: "The target weekday",
},
offset: {
type: "number",
description:
"1 = next occurrence, 2 = the one after, -1 = last occurrence, etc.",
},
hour: {
type: "number",
description: "Hour of day (0-23)",
},
minute: {
type: "number",
description: "Minute (0-59)",
},
},
required: ["day", "offset", "hour", "minute"],
},
},
{
name: "getCurrentDateTime",
description: "Get the current date and time as an ISO string",
parameters: {
type: "object",
properties: {},
required: [],
},
},
{
name: "proposeCreateEvent",
description:
"Propose creating a new calendar event. The user must confirm before it's saved. Call this when the user wants to create a new appointment.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Event title",
},
startTime: {
type: "string",
description: "Start time as ISO date string",
},
endTime: {
type: "string",
description: "End time as ISO date string",
},
description: {
type: "string",
description: "Optional event description",
},
isRecurring: {
type: "boolean",
description: "Whether this is a recurring event",
},
recurrenceRule: {
type: "string",
description: "RRULE format string for recurring events",
},
},
required: ["title", "startTime", "endTime"],
},
},
{
name: "proposeUpdateEvent",
description:
"Propose updating an existing event. The user must confirm. Use this when the user wants to modify an appointment.",
parameters: {
type: "object",
properties: {
eventId: {
type: "string",
description: "ID of the event to update",
},
title: {
type: "string",
description: "New title (optional)",
},
startTime: {
type: "string",
description: "New start time as ISO date string (optional)",
},
endTime: {
type: "string",
description: "New end time as ISO date string (optional)",
},
description: {
type: "string",
description: "New description (optional)",
},
},
required: ["eventId"],
},
},
{
name: "proposeDeleteEvent",
description:
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment.",
parameters: {
type: "object",
properties: {
eventId: {
type: "string",
description: "ID of the event to delete",
},
},
required: ["eventId"],
},
},
{
name: "searchEvents",
description:
"Search for events by title in the user's calendar. Returns matching events.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query to match against event titles",
},
},
required: ["query"],
},
},
];

View File

@@ -0,0 +1,156 @@
import {
ProposedEventChange,
getDay,
Day,
DAY_TO_GERMAN,
} from "@caldav/shared";
import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
/**
* Result of executing a tool call.
*/
export interface ToolResult {
content: string;
proposedChange?: ProposedEventChange;
}
/**
* Execute a tool call and return the result.
* This function is provider-agnostic and can be used with any LLM.
*/
export function executeToolCall(
name: string,
args: Record<string, unknown>,
context: AIContext,
): ToolResult {
switch (name) {
case "getDay": {
const date = getDay(
args.day as Day,
args.offset as number,
args.hour as number,
args.minute as number,
);
const dayName = DAY_TO_GERMAN[args.day as Day];
return {
content: `${date.toISOString()} (${dayName}, ${formatDate(date)} um ${formatTime(date)} Uhr)`,
};
}
case "getCurrentDateTime": {
const now = context.currentDate;
return {
content: `${now.toISOString()} (${formatDateTime(now)})`,
};
}
case "proposeCreateEvent": {
const event = {
title: args.title as string,
startTime: new Date(args.startTime as string),
endTime: new Date(args.endTime as string),
description: args.description as string | undefined,
isRecurring: args.isRecurring as boolean | undefined,
recurrenceRule: args.recurrenceRule as string | undefined,
};
const dateStr = formatDate(event.startTime);
const startStr = formatTime(event.startTime);
const endStr = formatTime(event.endTime);
return {
content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr`,
proposedChange: {
action: "create",
event,
},
};
}
case "proposeUpdateEvent": {
const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
}
const updates: Record<string, unknown> = {};
if (args.title) updates.title = args.title;
if (args.startTime)
updates.startTime = new Date(args.startTime as string);
if (args.endTime) updates.endTime = new Date(args.endTime as string);
if (args.description) updates.description = args.description;
// Build event object for display (merge existing with updates)
const displayEvent = {
title: (updates.title as string) || existingEvent.title,
startTime: (updates.startTime as Date) || existingEvent.startTime,
endTime: (updates.endTime as Date) || existingEvent.endTime,
description:
(updates.description as string) || existingEvent.description,
isRecurring: existingEvent.isRecurring,
};
return {
content: `Update-Vorschlag für "${existingEvent.title}" erstellt.`,
proposedChange: {
action: "update",
eventId,
updates,
event: displayEvent,
},
};
}
case "proposeDeleteEvent": {
const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` };
}
return {
content: `Lösch-Vorschlag für "${existingEvent.title}" erstellt.`,
proposedChange: {
action: "delete",
eventId,
event: {
title: existingEvent.title,
startTime: existingEvent.startTime,
endTime: existingEvent.endTime,
description: existingEvent.description,
isRecurring: existingEvent.isRecurring,
},
},
};
}
case "searchEvents": {
const query = (args.query as string).toLowerCase();
const matches = context.existingEvents.filter((e) =>
e.title.toLowerCase().includes(query),
);
if (matches.length === 0) {
return { content: `Keine Termine mit "${args.query}" gefunden.` };
}
const results = matches
.map((e) => {
const start = new Date(e.startTime);
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr`;
})
.join("\n");
return { content: `Gefundene Termine:\n${results}` };
}
default:
return { content: `Unbekannte Funktion: ${name}` };
}
}

View File

@@ -10,7 +10,7 @@ import {
MongoEventRepository,
MongoChatRepository,
} from "./repositories";
import { ClaudeAdapter } from "./ai";
import { GPTAdapter } from "./ai";
const app = express();
const port = process.env.PORT || 3000;
@@ -43,7 +43,7 @@ const eventRepo = new MongoEventRepository();
const chatRepo = new MongoChatRepository();
// Initialize AI provider
const aiProvider = new ClaudeAdapter();
const aiProvider = new GPTAdapter();
// Initialize services
const authService = new AuthService(userRepo);

View File

@@ -4,6 +4,7 @@ import {
CreateEventDTO,
UpdateEventDTO,
EventAction,
GetMessagesOptions,
} from "@caldav/shared";
import { ChatService } from "../services";
import { AuthenticatedRequest } from "../middleware";
@@ -66,13 +67,43 @@ export class ChatController {
req: AuthenticatedRequest,
res: Response,
): Promise<void> {
throw new Error("Not implemented");
try {
const userId = req.user!.userId;
const conversations = await this.chatService.getConversations(userId);
res.json(conversations);
} catch (error) {
res.status(500).json({ error: "Failed to get conversations" });
}
}
async getConversation(
req: AuthenticatedRequest,
res: Response,
): Promise<void> {
throw new Error("Not implemented");
try {
const userId = req.user!.userId;
const { id } = req.params;
const { before, limit } = req.query as {
before?: string;
limit?: string;
};
const options: GetMessagesOptions = {};
if (before) options.before = before;
if (limit) options.limit = parseInt(limit, 10);
const messages = await this.chatService.getConversation(
userId,
id,
options,
);
res.json(messages);
} catch (error) {
if ((error as Error).message === "Conversation not found") {
res.status(404).json({ error: "Conversation not found" });
} else {
res.status(500).json({ error: "Failed to get conversation" });
}
}
}
}

View File

@@ -3,6 +3,7 @@ import {
Conversation,
CreateMessageDTO,
GetMessagesOptions,
UpdateMessageDTO,
} from "@caldav/shared";
import { ChatRepository } from "../../services/interfaces";
import { ChatMessageModel, ConversationModel } from "./models";
@@ -10,11 +11,15 @@ import { ChatMessageModel, ConversationModel } from "./models";
export class MongoChatRepository implements ChatRepository {
// Conversations
async getConversationsByUser(userId: string): Promise<Conversation[]> {
throw new Error("Not implemented");
const conversations = await ConversationModel.find({ userId });
return conversations.map((c) => c.toJSON() as unknown as Conversation);
}
async createConversation(userId: string): Promise<Conversation> {
throw new Error("Not implemented");
const conversation = await ConversationModel.create({
userId,
});
return conversation.toJSON() as unknown as Conversation;
}
// Messages (cursor-based pagination)
@@ -22,13 +27,44 @@ export class MongoChatRepository implements ChatRepository {
conversationId: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> {
throw new Error("Not implemented");
const limit = options?.limit ?? 20;
const query: Record<string, unknown> = { conversationId };
// Cursor: load messages before this ID (for "load more" scrolling up)
if (options?.before) {
query._id = { $lt: options.before };
}
// Fetch newest first, then reverse for chronological order
const docs = await ChatMessageModel.find(query)
.sort({ _id: -1 })
.limit(limit);
return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage);
}
async createMessage(
conversationId: string,
message: CreateMessageDTO,
): Promise<ChatMessage> {
throw new Error("Not implemented");
const repoMessage = await ChatMessageModel.create({
conversationId: conversationId,
sender: message.sender,
content: message.content,
proposedChange: message.proposedChange,
});
return repoMessage.toJSON() as unknown as ChatMessage;
}
async updateMessage(
messageId: string,
updates: UpdateMessageDTO,
): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findByIdAndUpdate(
messageId,
{ $set: updates },
{ new: true },
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
}

View File

@@ -80,6 +80,10 @@ const ChatMessageSchema = new Schema<
proposedChange: {
type: ProposedChangeSchema,
},
respondedAction: {
type: String,
enum: ["confirm", "reject"],
},
},
{
timestamps: true,

View File

@@ -9,6 +9,7 @@ import {
CreateEventDTO,
UpdateEventDTO,
EventAction,
CreateMessageDTO,
} from "@caldav/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
@@ -252,22 +253,47 @@ export class ChatService {
userId: string,
data: SendMessageDTO,
): Promise<ChatResponse> {
const response = await getTestResponse(
responseIndex,
this.eventRepo,
userId,
);
responseIndex++;
let conversationId = data.conversationId;
if (!conversationId) {
const conversation = await this.chatRepo.createConversation(userId);
conversationId = conversation.id;
}
const message: ChatMessage = {
id: Date.now().toString(),
conversationId: data.conversationId || "temp-conv-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,
proposedChange: response.proposedChange,
};
});
return { message, conversationId: message.conversationId };
return { message: answerMessage, conversationId: conversationId };
}
async confirmEvent(
@@ -279,6 +305,12 @@ export class ChatService {
eventId?: string,
updates?: UpdateEventDTO,
): Promise<ChatResponse> {
// Update original message with respondedAction
await this.chatRepo.updateMessage(messageId, {
respondedAction: "confirm",
});
// Perform the actual event operation
let content: string;
if (action === "create" && event) {
@@ -298,12 +330,12 @@ export class ChatService {
content = "Ungültige Aktion.";
}
const message: ChatMessage = {
id: Date.now().toString(),
conversationId,
// Save response message to DB
const message = await this.chatRepo.createMessage(conversationId, {
sender: "assistant",
content,
};
});
return { message, conversationId };
}
@@ -312,17 +344,34 @@ export class ChatService {
conversationId: string,
messageId: string,
): Promise<ChatResponse> {
const message: ChatMessage = {
id: Date.now().toString(),
conversationId,
// Update original message with respondedAction
await this.chatRepo.updateMessage(messageId, { respondedAction: "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[]> {
throw new Error("Not implemented");
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(
@@ -330,6 +379,14 @@ export class ChatService {
conversationId: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> {
throw new Error("Not implemented");
// 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);
}
}

View File

@@ -3,6 +3,7 @@ import {
Conversation,
CreateMessageDTO,
GetMessagesOptions,
UpdateMessageDTO,
} from "@caldav/shared";
export interface ChatRepository {
@@ -15,8 +16,14 @@ export interface ChatRepository {
conversationId: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]>;
createMessage(
conversationId: string,
message: CreateMessageDTO,
): Promise<ChatMessage>;
updateMessage(
messageId: string,
updates: UpdateMessageDTO,
): Promise<ChatMessage | null>;
}

43
package-lock.json generated
View File

@@ -62,13 +62,13 @@
"name": "@caldav/server",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@caldav/shared": "*",
"bcrypt": "^6.0.0",
"dotenv": "^16.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.1.1",
"openai": "^6.15.0",
"rrule": "^2.8.1"
},
"devDependencies": {
@@ -106,26 +106,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.71.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -11252,6 +11232,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openai": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz",
"integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",

View File

@@ -6,6 +6,9 @@
"apps/*",
"packages/*"
],
"scripts": {
"format": "prettier --write \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\""
},
"devDependencies": {
"eslint": "^9.25.0",
"prettier": "^3.7.4",

View File

@@ -4,6 +4,8 @@ export type MessageSender = "user" | "assistant";
export type EventAction = "create" | "update" | "delete";
export type RespondedAction = "confirm" | "reject";
export interface ProposedEventChange {
action: EventAction;
eventId?: string; // Required for update/delete
@@ -17,6 +19,7 @@ export interface ChatMessage {
sender: MessageSender;
content: string;
proposedChange?: ProposedEventChange;
respondedAction?: RespondedAction;
createdAt?: Date;
}
@@ -43,6 +46,10 @@ export interface GetMessagesOptions {
limit?: number; // Default: 20
}
export interface UpdateMessageDTO {
respondedAction?: RespondedAction;
}
export interface ChatResponse {
message: ChatMessage;
conversationId: string;