diff --git a/src/data/assistant-state.json b/src/data/assistant-state.json new file mode 100644 index 0000000..f238c63 --- /dev/null +++ b/src/data/assistant-state.json @@ -0,0 +1,55 @@ +{ + "properties": [ + { + "id": "prop-001", + "name": "Oceanfront Villa", + "nightlyRate": 450, + "available": true + }, + { + "id": "prop-002", + "name": "Downtown Loft", + "nightlyRate": 200, + "available": true + }, + { + "id": "prop-003", + "name": "Mountain Cabin", + "nightlyRate": 175, + "available": false + } + ], + "bookings": [ + { + "id": "booking-001", + "propertyId": "prop-001", + "guestName": "Alice Johnson", + "guestEmail": "alice@email.com", + "checkIn": "2024-02-15", + "checkOut": "2024-02-20", + "status": "pending", + "totalPrice": 2250 + }, + { + "id": "booking-002", + "propertyId": "prop-002", + "guestName": "Bob Williams", + "guestEmail": "bob@email.com", + "checkIn": "2024-02-10", + "checkOut": "2024-02-12", + "status": "approved", + "totalPrice": 400 + }, + { + "id": "booking-003", + "propertyId": "prop-001", + "guestName": "Carol Davis", + "guestEmail": "carol@email.com", + "checkIn": "2024-03-01", + "checkOut": "2024-03-05", + "status": "pending", + "totalPrice": 1800 + } + ], + "sentMessages": [] +} diff --git a/src/routes/assistant.ts b/src/routes/assistant.ts new file mode 100644 index 0000000..7c643e4 --- /dev/null +++ b/src/routes/assistant.ts @@ -0,0 +1,111 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { availableTools, executeTool } from '../services/assistantTools'; + +const router = Router(); + +const assistantQuerySchema = z.object({ + message: z.string().min(1).max(1000), + model: z.string().optional(), +}); + +const LITELLM_SERVER_URL = process.env.LITELLM_SERVER_URL || 'http://localhost:4000'; + +async function runAssistant( + userMessage: string, + model?: string +): Promise<{ response: string; toolsUsed: string[] }> { + const toolsUsed: string[] = []; + + const systemPrompt = `You are a helpful AI property management assistant. You have access to the following tools to help manage vacation rental properties: + +${availableTools.map((t) => `- ${t.name}: ${t.description}`).join('\n')} + +When the user asks you to do something, use the appropriate tools to complete the task. You can use multiple tools in sequence if needed. + +Be proactive and helpful. If the user wants to approve a booking, approve it. If they want to change a price, change it. Execute actions immediately without asking for confirmation - the user trusts you to act on their behalf. + +To use a tool, respond with a JSON object like: +{"tool": "tool_name", "args": {"param1": "value1"}} + +After using a tool, you'll receive the result and can continue the conversation or use another tool.`; + + let messages: Array<{ role: string; content: string }> = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ]; + + // Simple tool-use loop (max 5 iterations to prevent infinite loops) + for (let i = 0; i < 5; i++) { + const response = await fetch(`${LITELLM_SERVER_URL}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: model || 'gpt-4o-mini', + messages, + }), + }); + + if (!response.ok) { + throw new Error(`LiteLLM request failed: ${await response.text()}`); + } + + const data: any = await response.json(); + const assistantMessage = data.choices[0].message.content; + + // Check if the assistant wants to use a tool + try { + // Try to extract JSON from the message + const jsonMatch = assistantMessage.match(/\{[\s\S]*?"tool"[\s\S]*?\}/); + if (jsonMatch) { + const toolCall = JSON.parse(jsonMatch[0]); + if (toolCall.tool && availableTools.some((t) => t.name === toolCall.tool)) { + const toolResult = executeTool(toolCall.tool, toolCall.args || {}); + toolsUsed.push(toolCall.tool); + + messages.push({ role: 'assistant', content: assistantMessage }); + messages.push({ role: 'user', content: `Tool result: ${toolResult}` }); + continue; + } + } + } catch { + // Not a tool call, return the response + } + + return { response: assistantMessage, toolsUsed }; + } + + return { response: 'Assistant reached maximum iterations', toolsUsed }; +} + +// AI assistant chat endpoint +router.post('/authorized/:level/assistant/chat', async (req: Request, res: Response) => { + try { + const { level } = req.params as { level: 'minnow' | 'shark' }; + const { message, model } = assistantQuerySchema.parse(req.body); + + const result = await runAssistant(message, model); + + return res.json({ + userMessage: message, + assistantResponse: result.response, + toolsUsed: result.toolsUsed, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation error', details: error.errors }); + } + console.error('Assistant error:', error); + return res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +// Get available tools (for documentation) +router.get('/authorized/:level/assistant/tools', async (req: Request, res: Response) => { + return res.json({ tools: availableTools }); +}); + +export default router; diff --git a/src/server.ts b/src/server.ts index bf8fc7f..3865fba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { chatHandler } from './routes/chat'; import { tokenHandler, jwksHandler } from './routes/oauth'; import { generateRSAKeyPair } from './utils/jwt-keys'; import { authenticateToken } from './middleware/auth'; +import assistantRouter from './routes/assistant'; // Initialize OAuth key pair on startup generateRSAKeyPair(); @@ -31,6 +32,9 @@ app.get('/health', (req: Request, res: Response) => { app.post('/:level/chat', chatHandler); app.post('/authorized/:level/chat', authenticateToken, chatHandler); +// AI property management assistant +app.use(assistantRouter); + // OAuth endpoints app.post('/oauth/token', tokenHandler); app.get('/.well-known/jwks.json', jwksHandler); diff --git a/src/services/assistantTools.ts b/src/services/assistantTools.ts new file mode 100644 index 0000000..3569f32 --- /dev/null +++ b/src/services/assistantTools.ts @@ -0,0 +1,171 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { AssistantState, Tool } from '../types/assistant'; + +const statePath = path.join(__dirname, '../data/assistant-state.json'); + +function loadState(): AssistantState { + return JSON.parse(fs.readFileSync(statePath, 'utf-8')); +} + +function saveState(state: AssistantState): void { + fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); +} + +export const availableTools: Tool[] = [ + { + name: 'list_properties', + description: 'List all properties managed by the host', + parameters: {}, + }, + { + name: 'list_bookings', + description: 'List all bookings, optionally filtered by status', + parameters: { + status: { + type: 'string', + enum: ['pending', 'approved', 'declined', 'cancelled'], + optional: true, + }, + }, + }, + { + name: 'get_booking_details', + description: 'Get detailed information about a specific booking', + parameters: { + bookingId: { type: 'string', required: true }, + }, + }, + { + name: 'approve_booking', + description: 'Approve a pending booking request', + parameters: { + bookingId: { type: 'string', required: true }, + }, + }, + { + name: 'decline_booking', + description: 'Decline a pending booking request', + parameters: { + bookingId: { type: 'string', required: true }, + reason: { type: 'string', optional: true }, + }, + }, + { + name: 'send_message_to_guest', + description: 'Send an email message to a guest', + parameters: { + guestEmail: { type: 'string', required: true }, + subject: { type: 'string', required: true }, + body: { type: 'string', required: true }, + }, + }, + { + name: 'update_property_price', + description: 'Update the nightly rate for a property', + parameters: { + propertyId: { type: 'string', required: true }, + newPrice: { type: 'number', required: true }, + }, + }, + { + name: 'set_property_availability', + description: 'Set whether a property is available for booking', + parameters: { + propertyId: { type: 'string', required: true }, + available: { type: 'boolean', required: true }, + }, + }, + { + name: 'cancel_booking', + description: 'Cancel an existing booking', + parameters: { + bookingId: { type: 'string', required: true }, + reason: { type: 'string', optional: true }, + }, + }, +]; + +// Tool execution functions +export function executeTool(toolName: string, args: Record): string { + const state = loadState(); + + switch (toolName) { + case 'list_properties': + return JSON.stringify(state.properties, null, 2); + + case 'list_bookings': { + let bookings = state.bookings; + if (args.status) { + bookings = bookings.filter((b) => b.status === args.status); + } + return JSON.stringify(bookings, null, 2); + } + + case 'get_booking_details': { + const booking = state.bookings.find((b) => b.id === args.bookingId); + return booking ? JSON.stringify(booking, null, 2) : 'Booking not found'; + } + + case 'approve_booking': { + const booking = state.bookings.find((b) => b.id === args.bookingId); + if (!booking) return 'Booking not found'; + if (booking.status !== 'pending') + return `Cannot approve booking with status: ${booking.status}`; + booking.status = 'approved'; + saveState(state); + return `Booking ${args.bookingId} has been approved`; + } + + case 'decline_booking': { + const booking = state.bookings.find((b) => b.id === args.bookingId); + if (!booking) return 'Booking not found'; + if (booking.status !== 'pending') + return `Cannot decline booking with status: ${booking.status}`; + booking.status = 'declined'; + saveState(state); + return `Booking ${args.bookingId} has been declined${args.reason ? `: ${args.reason}` : ''}`; + } + + case 'send_message_to_guest': { + const message = { + to: args.guestEmail, + subject: args.subject, + body: args.body, + timestamp: new Date().toISOString(), + }; + state.sentMessages.push(message); + saveState(state); + console.log(`[EMAIL SENT] To: ${args.guestEmail}, Subject: ${args.subject}`); + return `Message sent to ${args.guestEmail}`; + } + + case 'update_property_price': { + const property = state.properties.find((p) => p.id === args.propertyId); + if (!property) return 'Property not found'; + const oldPrice = property.nightlyRate; + property.nightlyRate = args.newPrice; + saveState(state); + return `Property ${property.name} price updated from $${oldPrice} to $${args.newPrice}`; + } + + case 'set_property_availability': { + const property = state.properties.find((p) => p.id === args.propertyId); + if (!property) return 'Property not found'; + property.available = args.available; + saveState(state); + return `Property ${property.name} availability set to ${args.available}`; + } + + case 'cancel_booking': { + const booking = state.bookings.find((b) => b.id === args.bookingId); + if (!booking) return 'Booking not found'; + booking.status = 'cancelled'; + saveState(state); + return `Booking ${args.bookingId} has been cancelled${args.reason ? `: ${args.reason}` : ''}`; + } + + default: + return `Unknown tool: ${toolName}`; + } +} diff --git a/src/types/assistant.ts b/src/types/assistant.ts new file mode 100644 index 0000000..77a3aa3 --- /dev/null +++ b/src/types/assistant.ts @@ -0,0 +1,41 @@ +export interface Tool { + name: string; + description: string; + parameters: Record; +} + +export interface ToolCall { + name: string; + arguments: Record; +} + +export interface Property { + id: string; + name: string; + nightlyRate: number; + available: boolean; +} + +export interface Booking { + id: string; + propertyId: string; + guestName: string; + guestEmail: string; + checkIn: string; + checkOut: string; + status: 'pending' | 'approved' | 'declined' | 'cancelled'; + totalPrice: number; +} + +export interface SentMessage { + to: string; + subject: string; + body: string; + timestamp: string; +} + +export interface AssistantState { + properties: Property[]; + bookings: Booking[]; + sentMessages: SentMessage[]; +}