diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts index 071c169..655cb16 100644 --- a/app/src/services/chatService.ts +++ b/app/src/services/chatService.ts @@ -227,7 +227,7 @@ class ChatService { }, 30000); this.pendingRequests.set(requestId, { resolve: resolve as any, reject, timer }); - const payload: any = { type: 'send_message', message: content, requestId }; + const payload: any = { type: 'send_message', message: content, requestId, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }; if (conversationId) { // Defense-in-depth: validate conversationId format on the client side too if (!/^[a-zA-Z0-9_-]{1,64}$/.test(conversationId)) { diff --git a/backend/src/agent/agent.ts b/backend/src/agent/agent.ts index 5dd7b04..1662aff 100644 --- a/backend/src/agent/agent.ts +++ b/backend/src/agent/agent.ts @@ -23,19 +23,19 @@ When the user asks to create, update, or delete events, use the corresponding to Always confirm what you're about to do before taking action. Be concise and helpful. All times to be displayed to the user must be in their local time always unless they specify otherwise.`; -export async function createCalendarAgent(accessToken: string) { +export async function createCalendarAgent(accessToken: string, timezone?: string) { const llm = createLLM(); const tools = [ - createGetCurrentDateTimeTool(), + createGetCurrentDateTimeTool(timezone), createListCalendarsTool(accessToken), - createListEventsTool(accessToken), - createSearchEventsTool(accessToken), + createListEventsTool(accessToken, timezone), + createSearchEventsTool(accessToken, timezone), createGetFreeBusyTool(accessToken), - createCreateEventTool(), - createUpdateEventTool(), + createCreateEventTool(timezone), + createUpdateEventTool(timezone), createDeleteEventTool(), - createDateTimeMathTool(), + createDateTimeMathTool(timezone), ]; const agent = createReactAgent({ diff --git a/backend/src/agent/tools/createEventTool.ts b/backend/src/agent/tools/createEventTool.ts index 1b7bef3..e4d2723 100644 --- a/backend/src/agent/tools/createEventTool.ts +++ b/backend/src/agent/tools/createEventTool.ts @@ -1,13 +1,13 @@ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; -export function createCreateEventTool() { +export function createCreateEventTool(clientTimezone?: string) { return tool( async (input) => { return JSON.stringify({ pendingAction: true, actionType: 'create_event', - payload: input, + payload: { ...input, timeZone: input.timeZone || clientTimezone }, }); }, { @@ -20,6 +20,7 @@ export function createCreateEventTool() { calendarId: z.string().describe('Calendar ID. Use "primary" for default calendar.'), description: z.string().optional().describe('Event description'), location: z.string().optional().describe('Event location'), + timeZone: z.string().optional().describe('IANA timezone for the event (e.g. "America/New_York"). Defaults to user\'s browser timezone.'), }), } ); diff --git a/backend/src/agent/tools/dateTimeMathTool.ts b/backend/src/agent/tools/dateTimeMathTool.ts index 5809719..da78255 100644 --- a/backend/src/agent/tools/dateTimeMathTool.ts +++ b/backend/src/agent/tools/dateTimeMathTool.ts @@ -2,7 +2,7 @@ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; import { DateTime, Duration } from 'luxon'; -export function createDateTimeMathTool() { +export function createDateTimeMathTool(clientTimezone?: string) { return tool( async (input) => { const { operation } = input; @@ -10,7 +10,10 @@ export function createDateTimeMathTool() { switch (operation) { case 'add': case 'subtract': { - const dt = DateTime.fromISO(input.datetime!); + let dt = DateTime.fromISO(input.datetime!); + if (clientTimezone && !input.datetime!.match(/[+-]\d{2}:\d{2}$|Z$/)) { + dt = DateTime.fromISO(input.datetime!, { zone: clientTimezone }); + } if (!dt.isValid) return JSON.stringify({ error: `Invalid datetime: ${input.datetime}` }); const dur = Duration.fromObject({ diff --git a/backend/src/agent/tools/getCurrentDateTimeTool.ts b/backend/src/agent/tools/getCurrentDateTimeTool.ts index 586b119..2e22581 100644 --- a/backend/src/agent/tools/getCurrentDateTimeTool.ts +++ b/backend/src/agent/tools/getCurrentDateTimeTool.ts @@ -2,10 +2,12 @@ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; import { DateTime } from 'luxon'; -export function createGetCurrentDateTimeTool() { +export function createGetCurrentDateTimeTool(clientTimezone?: string) { return tool( async () => { - const now = DateTime.now(); + const now = clientTimezone + ? DateTime.now().setZone(clientTimezone) + : DateTime.now(); return JSON.stringify({ date: now.toFormat('yyyy-MM-dd'), diff --git a/backend/src/agent/tools/listEventsTool.ts b/backend/src/agent/tools/listEventsTool.ts index d5dbaff..f845c48 100644 --- a/backend/src/agent/tools/listEventsTool.ts +++ b/backend/src/agent/tools/listEventsTool.ts @@ -5,20 +5,20 @@ import { DateTime } from 'luxon'; import { calendarService } from '../../services/calendar.service'; import { CalendarEvent } from '../../types'; -function convertEventTimesToLocal(events: CalendarEvent[]): CalendarEvent[] { - const localZone = DateTime.now().zoneName; +function convertEventTimesToLocal(events: CalendarEvent[], clientTimezone?: string): CalendarEvent[] { + const zone = clientTimezone || DateTime.now().zoneName; return events.map((event) => ({ ...event, start: event.start?.dateTime - ? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(localZone).toISO()! } + ? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(zone).toISO()! } : event.start, end: event.end?.dateTime - ? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(localZone).toISO()! } + ? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(zone).toISO()! } : event.end, })); } -export function createListEventsTool(accessToken: string) { +export function createListEventsTool(accessToken: string, clientTimezone?: string) { const oauth2Client = new google.auth.OAuth2(); oauth2Client.setCredentials({ access_token: accessToken }); @@ -28,7 +28,7 @@ export function createListEventsTool(accessToken: string) { timeMin: input.timeMin, timeMax: input.timeMax, }); - return JSON.stringify(convertEventTimesToLocal(events)); + return JSON.stringify(convertEventTimesToLocal(events, clientTimezone)); }, { name: 'list_events', diff --git a/backend/src/agent/tools/searchEventsTool.ts b/backend/src/agent/tools/searchEventsTool.ts index aa8df25..46a20a9 100644 --- a/backend/src/agent/tools/searchEventsTool.ts +++ b/backend/src/agent/tools/searchEventsTool.ts @@ -5,20 +5,20 @@ import { DateTime } from 'luxon'; import { calendarService } from '../../services/calendar.service'; import { CalendarEvent } from '../../types'; -function convertEventTimesToLocal(events: CalendarEvent[]): CalendarEvent[] { - const localZone = DateTime.now().zoneName; +function convertEventTimesToLocal(events: CalendarEvent[], clientTimezone?: string): CalendarEvent[] { + const zone = clientTimezone || DateTime.now().zoneName; return events.map((event) => ({ ...event, start: event.start?.dateTime - ? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(localZone).toISO()! } + ? { ...event.start, dateTime: DateTime.fromISO(event.start.dateTime).setZone(zone).toISO()! } : event.start, end: event.end?.dateTime - ? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(localZone).toISO()! } + ? { ...event.end, dateTime: DateTime.fromISO(event.end.dateTime).setZone(zone).toISO()! } : event.end, })); } -export function createSearchEventsTool(accessToken: string) { +export function createSearchEventsTool(accessToken: string, clientTimezone?: string) { const oauth2Client = new google.auth.OAuth2(); oauth2Client.setCredentials({ access_token: accessToken }); @@ -33,7 +33,7 @@ export function createSearchEventsTool(accessToken: string) { timeMax: input.timeMax, } ); - return JSON.stringify(convertEventTimesToLocal(events)); + return JSON.stringify(convertEventTimesToLocal(events, clientTimezone)); }, { name: 'search_events', diff --git a/backend/src/agent/tools/updateEventTool.ts b/backend/src/agent/tools/updateEventTool.ts index 876561a..4e1a01f 100644 --- a/backend/src/agent/tools/updateEventTool.ts +++ b/backend/src/agent/tools/updateEventTool.ts @@ -1,13 +1,13 @@ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; -export function createUpdateEventTool() { +export function createUpdateEventTool(clientTimezone?: string) { return tool( async (input) => { return JSON.stringify({ pendingAction: true, actionType: 'update_event', - payload: input, + payload: { ...input, timeZone: input.timeZone || clientTimezone }, }); }, { @@ -21,6 +21,7 @@ export function createUpdateEventTool() { endDateTime: z.string().optional().describe('New end date/time in ISO 8601 format with timezone offset (e.g. "2026-01-30T10:00:00-05:00"). Use the utcOffset from get_current_datetime.'), description: z.string().optional().describe('New event description'), location: z.string().optional().describe('New event location'), + timeZone: z.string().optional().describe('IANA timezone for the event (e.g. "America/New_York"). Defaults to user\'s browser timezone.'), }), } ); diff --git a/backend/src/services/chat.service.ts b/backend/src/services/chat.service.ts index 14317d7..73f9c56 100644 --- a/backend/src/services/chat.service.ts +++ b/backend/src/services/chat.service.ts @@ -19,12 +19,13 @@ const MUTATING_ACTIONS: Record = { */ async function callAgent( langchainMessages: BaseMessage[], - accessToken: string + accessToken: string, + timezone?: string ): Promise<{ reply: string; toolCalls: { name: string; params: Record; description: string }[]; }> { - const agent = await createCalendarAgent(accessToken); + const agent = await createCalendarAgent(accessToken, timezone); const result = await agent.invoke({ messages: langchainMessages }); @@ -62,7 +63,8 @@ export class ChatService { userId: string, conversationId: string | null, message: string, - accessToken: string + accessToken: string, + timezone?: string ): Promise { // Load or create conversation let convId = conversationId; @@ -92,7 +94,7 @@ export class ChatService { const langchainMessages = await chatHistory.getMessages(); // Call agent - const agentResult = await callAgent(langchainMessages, accessToken); + const agentResult = await callAgent(langchainMessages, accessToken, timezone); // Save assistant reply (manual persistence — no duplication since agent // has no checkpointer and does not auto-persist) diff --git a/backend/src/ws.ts b/backend/src/ws.ts index 7364395..49f5a09 100644 --- a/backend/src/ws.ts +++ b/backend/src/ws.ts @@ -81,12 +81,16 @@ export function setupWebSocket(server: HttpServer): WebSocketServer { return; } + // Client timezone (IANA, e.g. "America/New_York") + const timezone = typeof parsed.timezone === 'string' ? parsed.timezone : undefined; + try { const result = await chatService.sendMessage( userId, conversationId, message, accessToken, + timezone, ); const data: any = { ...result }; if (result.pendingAction) {