import { IStorage } from "./storage"; import { User, InsertTask, insertTaskSchema } from "../shared/schema"; interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } export class AiService { constructor(private storage: IStorage) { } async chat(messages: ChatMessage[], user: User, context: string): Promise { const provider = await this.storage.getSystemSettings("ai_provider") || "openai"; const apiKey = await this.storage.getSystemSettings("ai_api_key"); const model = await this.storage.getSystemSettings("ai_model") || "gpt-4o"; const baseUrl = await this.storage.getSystemSettings("ai_base_url"); if (!apiKey && provider !== "ollama") { throw new Error("AI API Key not configured"); } const systemPrompt = `You are TaskFlow AI, an intelligent assistant for the TaskFlow application. You have access to the user's current tasks and context. User Name: ${user.username} Current Context: ${context} Answer the user's questions based on this context. Be concise, helpful, and friendly. ### 🛠️ AVAILABLE TOOLS You can create, search, update, and delete tasks using the provided tools. **1. Task Management** - **Create**: Use 'createTask'. Title is required. - *Subtasks*: To break a task down, create new tasks with 'parentTaskId' set to the main task's ID. - *Planning*: You can set 'estimatedDuration' (minutes) and 'startDate'. - **Update/Delete**: First SEARCH for the task ID using 'searchTasks', then use 'updateTask' or 'deleteTask'. - **Labels**: Use 'getLabels' to see available tags. **2. 🧠 Smart Planning & Scheduling** - **"Break this down"**: If a user asks to break down a project, create strictly hierarchical subtasks using 'createTask' with 'parentTaskId'. - **"Find time for this"**: Use 'scheduleTask'. This tool automatically finds free slots in the user's calendar (based on task duration) and sets the due date. - **Time Boxing**: If a user mentions how long something takes ("...will take 2 hours"), ALWAYS set 'estimatedDuration' (in minutes) when creating/updating. **3. Gamification** - Check achievements/XP with 'getAchievements'. - Check highscores with 'getLeaderboard'. ### 📅 DATE & TIME RULES - **"Today"**: Use the 'Current Date/Time' context. - **Queries**: When asked for "today's tasks", only list tasks where 'dueDate' matches today. - **Scheduling**: When using 'scheduleTask', inform the user specifically *when* you scheduled it (e.g., "I've scheduled this for tomorrow at 2:00 PM"). IMPORTANT: Do NOT show Task IDs to the user. Reference tasks by Title. CRITICAL: After executing tools, provide a concise summary of your actions. `; const fullMessages = [ { role: "system", content: systemPrompt }, ...messages ]; try { if (provider === "openai" || provider === "ollama") { return await this.chatOpenAI(provider, apiKey || "", model, baseUrl, fullMessages, user); } else if (provider === "anthropic") { return await this.chatAnthropic(apiKey || "", model, fullMessages); } else if (provider === "google") { return await this.chatGemini(apiKey || "", model, fullMessages); } else { throw new Error(`Unsupported AI provider: ${provider}`); } } catch (error: any) { console.error("AI Chat Error:", error); throw new Error(`AI Service Error: ${error.message}`); } } async generateTitle(messages: ChatMessage[]): Promise { const provider = await this.storage.getSystemSettings("ai_provider") || "openai"; const apiKey = await this.storage.getSystemSettings("ai_api_key"); const model = await this.storage.getSystemSettings("ai_model") || "gpt-4o"; const baseUrl = await this.storage.getSystemSettings("ai_base_url"); if (!apiKey && provider !== "ollama") return "New Conversation"; const conversationText = messages.slice(0, 3).map(m => `${m.role}: ${m.content}`).join('\n'); const prompt = `Based on the following conversation, generate a short, concise title (max 5 words). Return ONLY the title text, no quotes or labels.\n\nConversation:\n${conversationText}`; const contextMessages = [ { role: "user", content: prompt } ]; try { let generatedTitle = ""; if (provider === "openai" || provider === "ollama") { generatedTitle = await this.chatOpenAI(provider, apiKey || "", model, baseUrl, contextMessages, undefined, []); } else if (provider === "anthropic") { generatedTitle = await this.chatAnthropic(apiKey || "", model, contextMessages); } else if (provider === "google") { generatedTitle = await this.chatGemini(apiKey || "", model, contextMessages); } generatedTitle = generatedTitle.trim(); if (generatedTitle && generatedTitle !== "No response generated." && generatedTitle.length < 60) { return generatedTitle.replace(/^["']|["']$/g, '').replace(/[*_#`]/g, '').trim(); } } catch (error) { console.error("Title generation failed:", error); } return "New Conversation"; } // New Public Method for Smart Scheduling async scheduleTask(taskId: string, userId: string, startAfterStr?: string): Promise<{ success: boolean, scheduledDate?: string, message?: string, error?: string }> { const task = await this.storage.getTask(taskId); if (!task) { return { success: false, error: "Task not found." }; } const startAfter = startAfterStr ? new Date(startAfterStr) : new Date(); const durationMins = task.estimatedDuration || 60; // Default to 1h if not set const workStartHour = 9; // PRIORITY LOGIC: High priority tasks can be scheduled until 20:00 (8 PM) const workEndHour = task.priority === 'high' ? 20 : 18; let scheduledDate: Date | null = null; // PLANNED TIME LOGIC: If startDate is set, do not schedule before it. // If starteAfterStr is provided (e.g. "tomorrow"), use the max of both. let effectiveStart = startAfter; if (task.startDate) { const plannedStart = new Date(task.startDate); if (plannedStart > effectiveStart) { effectiveStart = plannedStart; } } let currentDay = new Date(effectiveStart); // Reset to next slot if passed if (currentDay.getHours() >= workEndHour) { currentDay.setDate(currentDay.getDate() + 1); currentDay.setHours(workStartHour, 0, 0, 0); } else if (currentDay.getHours() < workStartHour) { currentDay.setHours(workStartHour, 0, 0, 0); } for (let dayOffset = 0; dayOffset < 3; dayOffset++) { // Look ahead 3 days const dayStart = new Date(currentDay); dayStart.setHours(workStartHour, 0, 0, 0); const dayEnd = new Date(currentDay); dayEnd.setHours(workEndHour, 0, 0, 0); // Get all tasks for this day that have a due date (and time) const allTasks = await this.storage.searchTasks("", userId); // Filter for tasks on this day const dayTasks = allTasks.filter(t => { if (!t.dueDate) return false; const d = new Date(t.dueDate); return d.getDate() === currentDay.getDate() && d.getMonth() === currentDay.getMonth() && d.getFullYear() === currentDay.getFullYear(); }); // Find gaps // Sort by time dayTasks.sort((a, b) => (a.dueDate!.getTime() - b.dueDate!.getTime())); // Check slots // Start checking from 'currentDay' time (if today) or 9am let attemptTime = new Date(currentDay); if (attemptTime < dayStart) attemptTime = dayStart; while (attemptTime.getTime() + (durationMins * 60000) <= dayEnd.getTime()) { const attemptEnd = new Date(attemptTime.getTime() + (durationMins * 60000)); // Check collision const hasCollision = dayTasks.some(t => { const tStart = new Date(t.dueDate!); const tDuration = t.estimatedDuration || 60; const tEnd = new Date(tStart.getTime() + (tDuration * 60000)); return (attemptTime < tEnd && attemptEnd > tStart); }); if (!hasCollision) { scheduledDate = attemptTime; break; } // specific increment? 30 mins attemptTime = new Date(attemptTime.getTime() + 30 * 60000); } if (scheduledDate) break; // Move to next day currentDay.setDate(currentDay.getDate() + 1); currentDay.setHours(workStartHour, 0, 0, 0); } if (scheduledDate) { await this.storage.updateTask(taskId, { dueDate: scheduledDate }); return { success: true, scheduledDate: scheduledDate.toISOString(), message: `Scheduled for ${scheduledDate.toLocaleDateString()} at ${scheduledDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}.` }; } else { return { success: false, error: "Could not find a free slot in the next 3 days." }; } } private getTaskTools() { return [ { type: "function", function: { name: "createTask", description: "Create a new task for the user.", parameters: { type: "object", properties: { title: { type: "string", description: "The title of the task (required)." }, description: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high"] }, status: { type: "string", enum: ["todo", "inProgress", "done"] }, dueDate: { type: "string", description: "ISO 8601 format (YYYY-MM-DD)." }, labelId: { type: "string" }, estimatedDuration: { type: "integer", description: "Estimated duration in minutes." }, parentTaskId: { type: "string" }, startDate: { type: "string" } }, required: ["title"] } } }, { type: "function", function: { name: "searchTasks", description: "Search for tasks by title, description, or label.", parameters: { type: "object", properties: { query: { type: "string" }, label: { type: "string" } }, required: ["query"] } } }, { type: "function", function: { name: "updateTask", description: "Update an existing task.", parameters: { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high"] }, status: { type: "string", enum: ["todo", "inProgress", "done"] }, dueDate: { type: "string" }, timeTracked: { type: "number" }, labelId: { type: "string" }, estimatedDuration: { type: "integer" }, startDate: { type: "string" } }, required: ["id"] } } }, { type: "function", function: { name: "deleteTask", description: "Delete an existing task.", parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } } }, { type: "function", function: { name: "getLabels", description: "Get all available labels.", parameters: { type: "object", properties: {}, required: [] } } }, { type: "function", function: { name: "getAchievements", description: "Get user's XP and rewards.", parameters: { type: "object", properties: {}, required: [] } } }, { type: "function", function: { name: "getLeaderboard", description: "Get the highscore leaderboard.", parameters: { type: "object", properties: {}, required: [] } } }, { type: "function", function: { name: "scheduleTask", description: "Finds the first available time slot for a task based on its duration and existing schedule.", parameters: { type: "object", properties: { taskId: { type: "string", description: "The ID of the task to schedule." }, startAfter: { type: "string", description: "ISO 8601 Date to start searching from (default: now)." } }, required: ["taskId"] } } } ]; } private async chatOpenAI(provider: string, apiKey: string, model: string, baseUrl: string | undefined, messages: any[], user?: User, tools: any[] = this.getTaskTools()): Promise { let apiBase = baseUrl; if (!apiBase) { if (provider === "ollama") { apiBase = "http://host.docker.internal:11434/v1"; } else { apiBase = "https://api.openai.com/v1"; } } if (apiBase.endsWith('/')) { apiBase = apiBase.slice(0, -1); } if (provider === "ollama" && !apiBase.includes("/v1") && !apiBase.includes("/api")) { apiBase += "/v1"; } const url = `${apiBase}/chat/completions`; console.log(`[AI] Sending Chat Request: provider=${provider} url=${url} model=${model}`); const body: any = { model: model, messages: messages, temperature: 0.7, }; if (tools && tools.length > 0) { body.tools = tools; body.tool_choice = "auto"; } try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify(body), }); if (!response.ok) { const err = await response.text(); console.error(`[AI] API Error: ${response.status} - ${err}`); throw new Error(`${provider} API Error ${response.status}: ${err}`); } const data = await response.json(); const choice = data.choices[0]; const message = choice.message; if (choice.finish_reason === "tool_calls" && message.tool_calls) { console.log(`[AI] Tool Calls detected: ${message.tool_calls.length}`); if (!user) throw new Error("Tools invoked but no user context provided."); messages.push(message); for (const toolCall of message.tool_calls) { const fnName = toolCall.function.name; const args = JSON.parse(toolCall.function.arguments); let result: any = {}; try { console.log(`[AI] Executing ${fnName}:`, args); if (fnName === "createTask") { const taskData = { title: args.title, description: args.description || null, priority: args.priority || "medium", status: args.status || "todo", dueDate: args.dueDate ? new Date(args.dueDate) : null, labelId: args.labelId || null, estimatedDuration: args.estimatedDuration || null, parentTaskId: args.parentTaskId || null, startDate: args.startDate ? new Date(args.startDate) : null, userId: user.id }; const createdTask = await this.storage.createTask(taskData); await this.storage.createAuditLog({ userId: user.id, action: "CREATE", entityType: "TASK", entityId: createdTask.id, details: { title: createdTask.title }, source: "AI" }); result = { success: true, taskId: createdTask.id, message: "Task created." }; } else if (fnName === "searchTasks") { const tasks = await this.storage.searchTasks(args.query, user.id); const labels = await this.storage.getLabels(user.id); const labelMap = new Map(labels.map(l => [l.id, l.name])); let filteredTasks = tasks; if (args.label) { const labelId = labels.find(l => l.name.toLowerCase() === args.label.toLowerCase())?.id; if (labelId) { filteredTasks = tasks.filter(t => t.labelId === labelId); } } result = { success: true, tasks: filteredTasks.map(t => ({ id: t.id, title: t.title, status: t.status, priority: t.priority, dueDate: t.dueDate, label: t.labelId ? labelMap.get(t.labelId) || "No Label" : "No Label" })) }; } else if (fnName === "updateTask") { const updates: any = {}; if (args.title) updates.title = args.title; if (args.description) updates.description = args.description; if (args.priority) updates.priority = args.priority; if (args.status) updates.status = args.status; if (args.dueDate !== undefined) updates.dueDate = args.dueDate ? new Date(args.dueDate) : null; if (args.timeTracked !== undefined) updates.timeTracked = args.timeTracked; if (args.labelId !== undefined) updates.labelId = args.labelId; if (args.estimatedDuration !== undefined) updates.estimatedDuration = args.estimatedDuration; if (args.startDate !== undefined) updates.startDate = args.startDate ? new Date(args.startDate) : null; const updatedTask = await this.storage.updateTask(args.id, updates); if (updatedTask) { await this.storage.createAuditLog({ userId: user.id, action: "UPDATE", entityType: "TASK", entityId: updatedTask.id, details: updates, source: "AI" }); result = { success: true, message: "Task updated successfully." }; } else { result = { success: false, error: "Task not found." }; } } else if (fnName === "deleteTask") { const deleted = await this.storage.deleteTask(args.id); if (deleted) { await this.storage.createAuditLog({ userId: user.id, action: "DELETE", entityType: "TASK", entityId: args.id, details: null, source: "AI" }); result = { success: true, message: "Task deleted successfully." }; } else { result = { success: false, error: "Task not found or could not be deleted." }; } } else if (fnName === "getLabels") { const labels = await this.storage.getLabels(user.id); result = { success: true, labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color })) }; } else if (fnName === "getAchievements") { const goals = await this.storage.getGoals(); const userGoals = goals.filter(g => g.userId === user.id); const rewards = await this.storage.getUserRewards(user.id); const xpEvents = await this.storage.getXpEvents(user.id); result = { success: true, xp: user.xp, level: user.level, goals: userGoals.map(g => ({ title: g.title, current: g.current, target: g.target, completed: g.completed })), rewards: rewards.length, recentXp: xpEvents.slice(0, 5).map(e => ({ source: e.source, amount: e.amount })) }; } else if (fnName === "getLeaderboard") { const leaderboard = await this.storage.getLeaderboard(); result = { success: true, topUsers: leaderboard.slice(0, 10).map((u, index) => ({ rank: index + 1, username: u.username, xp: u.xp, level: u.level })) }; } else if (fnName === "scheduleTask") { // Call the reusable public method from inside the tool result = await this.scheduleTask(args.taskId, user.id, args.startAfter); } else { result = { success: false, error: "Unknown tool function." }; } messages.push({ role: "tool", tool_call_id: toolCall.id, name: fnName, content: JSON.stringify(result) }); } catch (err: any) { console.error(`[AI] Tool Execution Error:`, err); messages.push({ role: "tool", tool_call_id: toolCall.id, name: fnName, content: JSON.stringify({ success: false, error: err.message }) }); } } console.log(`[AI] Sending follow-up request with tool results...`); return await this.chatOpenAI(provider, apiKey, model, baseUrl, messages, user, tools); } if (!message.content) { console.warn("[AI] Warning: Empty content received from OpenAI."); const hasExecutedTools = messages.some(m => m.role === 'tool'); if (hasExecutedTools) { return "I have successfully processed your request and updated your tasks."; } } return message.content || "No response generated."; } catch (error: any) { console.error(`[AI] Request Failed: ${error.message}`); if (error.code === 'ECONNREFUSED' && url.includes('localhost')) { throw new Error("Connection refused. If running in Docker, try using 'http://host.docker.internal:11434/v1' as Base URL."); } throw error; } } private async chatAnthropic(apiKey: string, model: string, messages: any[]): Promise { const systemMessage = messages.find(m => m.role === "system")?.content || ""; const userAssistantMessages = messages.filter(m => m.role !== "system"); const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01", }, body: JSON.stringify({ model: model, system: systemMessage, messages: userAssistantMessages, max_tokens: 1024, }), }); if (!response.ok) { const err = await response.text(); throw new Error(`Anthropic API Error ${response.status}: ${err}`); } const data = await response.json(); return data.content[0]?.text || "No response generated."; } private async chatGemini(apiKey: string, model: string, messages: any[]): Promise { const systemMessage = messages.find(m => m.role === "system")?.content || ""; const contentMessages = messages.filter(m => m.role !== "system").map(m => ({ role: m.role === "user" ? "user" : "model", parts: [{ text: m.content }] })); if (contentMessages.length > 0 && contentMessages[0].role === "user") { contentMessages[0].parts[0].text = `[System Instruction: ${systemMessage}]\n\nWait for user input... User Input: ` + contentMessages[0].parts[0].text; } const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents: contentMessages }), }); if (!response.ok) { const err = await response.text(); throw new Error(`Gemini API Error ${response.status}: ${err}`); } const data = await response.json(); return data.candidates?.[0]?.content?.parts?.[0]?.text || "No response generated."; } }