import { IStorage } from "./storage"; import { User, InsertTask, insertTaskSchema } from "../shared/schema"; interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } export const DEFAULT_SYSTEM_PROMPT = `You are TaskFlow AI, an intelligent productivity assistant integrated into a personal task management system. Your goal is to help the user organize their life, manage tasks, and achieve their goals. Available Tools: - create_task: Create a new task with title, description, due date, priority. - list_tasks: Search and list tasks. - update_task: Modify existing tasks. - create_label: Create organization labels. - create_note: Save quick notes or ideas. Context Variables: - \${user.username}: The current user's name. - \${currentDate}: The current date and time. - \${context}: The page the user is currently looking at. Personality: - Be concise, helpful, and direct. - Proactively suggest organization improvements. - When creating tasks, infer priorities and due dates if not specified. - Use emoji sparingly but effectively to make the chat friendly. `; 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 currentDate = new Date(); // Fetch system prompt from DB or use default/fallback const storedPrompt = await this.storage.getSystemSettings("ai_system_prompt"); let systemPrompt = storedPrompt; if (!systemPrompt) { // Fallback (should be seeded, but just in case) 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 Date/Time: \${currentDate.toLocaleString('de-DE')} (Day: \${currentDate.toLocaleDateString('en-US', { weekday: 'long' })}) 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. - *Bulk Creation*: If the user provides a list of tasks, call 'createTask' multiple times in parallel. - *Relative Dates*: Understand natural language! "Morgen" = Tomorrow, "Next Friday" = Date of next Friday. Always calculate the specific ISO string based on 'Current Date/Time'. - *Returns*: The tool returns the created Task ID. Remember this ID for immediate edits. - **Update/Delete**: First SEARCH for the task ID using 'searchTasks' (search by title), then use 'updateTask' or 'deleteTask'. - *Editing Recently Created*: If the user says "Change that to...", refer to the ID of the task you just created. - *Delete All*: Search all, then delete each. - **Labels**: Use 'getLabels' tags. **2. 🧠 Smart Planning & Scheduling** - **"Break this down"**: Create subtasks with 'parentTaskId'. - **"Find time for this"**: Use 'scheduleTask'. - **Time Boxing**: Set 'estimatedDuration' (minutes) if mentioned. **3. Gamification** - Check achievements/XP with 'getAchievements'. - Check highscores with 'getLeaderboard'. ### 📅 DATE & TIME RULES - **"Today"**: Use the 'Current Date/Time' context. - **"Morgen" / "Tomorrow"**: Add 1 day to Current Date. - **Scheduling**: When using 'scheduleTask', inform the user specifically *when* you scheduled it. IMPORTANT: Do NOT show Task IDs to the user. Reference tasks by Title. CRITICAL: After executing tools, provide a concise summary of your actions.`; } // Variable replacement if using stored template // Note: The stored prompt will have ${user.username} as text. We need to replace it. // Simple manual replacement for the keys we expect. systemPrompt = systemPrompt .replace(/\${user.username}/g, user.username) .replace(/\${currentDate.toLocaleString\('de-DE'\)}/g, currentDate.toLocaleString('de-DE')) .replace(/\${currentDate.toLocaleDateString\('en-US', { weekday: 'long' }\)}/g, currentDate.toLocaleDateString('en-US', { weekday: 'long' })) .replace(/\${context}/g, context); 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 user = await this.storage.getUser(userId); if (!user) { return { success: false, error: "User not found." }; } // 1. Determine Context (Work vs Personal) let domain = "neutral"; if (task.labelId) { const label = await this.storage.getLabel(task.labelId); if (label) { domain = label.domain; // 'work', 'personal', 'neutral' } } // 2. Get Availability Config // Fallback to old workHours if availability is missing (backward compatibility) const availability = user.availability || { work: user.workHours || { start: "09:00", end: "17:00", days: [1, 2, 3, 4, 5] }, personal: { start: "18:00", end: "22:00", days: [1, 2, 3, 4, 5, 0, 6] } }; const startAfter = startAfterStr ? new Date(startAfterStr) : new Date(); const durationMins = task.estimatedDuration || 60; // PLANNED TIME LOGIC let effectiveStart = startAfter; if (task.startDate) { const plannedStart = new Date(task.startDate); if (plannedStart > effectiveStart) { effectiveStart = plannedStart; } } let currentDay = new Date(effectiveStart); let scheduledDate: Date | null = null; // Helper to check if a specific time is within available hours const isTimeAvailable = (date: Date): boolean => { const day = date.getDay(); const timeStr = date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); // Checks const inSchedule = (sched: { start: string, end: string, days: number[] }) => { if (!sched.days.includes(day)) return false; return timeStr >= sched.start && timeStr < sched.end; }; if (domain === 'work') return inSchedule(availability.work); if (domain === 'personal') return inSchedule(availability.personal); // Neutral: Available in either return inSchedule(availability.work) || inSchedule(availability.personal); }; // Look ahead 7 days for (let dayOffset = 0; dayOffset < 7; dayOffset++) { const dayStart = new Date(currentDay); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(currentDay); dayEnd.setHours(23, 59, 59, 999); // Fetch tasks for collision detection const allTasks = await this.storage.searchTasks("", userId); 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(); }); // Iterate through the day in 15min chunks // Start from 'now' if checking today, otherwise start of day let attemptTime = new Date(currentDay); if (attemptTime < dayStart) attemptTime = dayStart; // Should not happen due to setHours logic but safety // Advance to next 15m slot if needed const remainder = attemptTime.getMinutes() % 15; if (remainder !== 0) { attemptTime.setMinutes(attemptTime.getMinutes() + (15 - remainder)); } attemptTime.setSeconds(0, 0); // Loop until end of day while (attemptTime < dayEnd) { // 1. Check if this START time is within allowed hours if (!isTimeAvailable(attemptTime)) { attemptTime.setMinutes(attemptTime.getMinutes() + 15); continue; } // 2. Check if the END time is within allowed hours (don't span into offline time) const attemptEndTime = new Date(attemptTime.getTime() + durationMins * 60000); // We check the end time loosely, or strictly? Strictly ensures we don't work late. // But simplified: check if end is also available (or roughly available) // Let's check the End Time as well. // Note: If schedule is 9-5 and 6-10, a task could technically span 4:30-5:30 if we strictly check 'inSchedule' for all points. // Simplification: Check Start and End. if (!isTimeAvailable(new Date(attemptEndTime.getTime() - 1))) { // Check just before end attemptTime.setMinutes(attemptTime.getMinutes() + 15); continue; } // 3. Collision Check 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 && attemptEndTime > tStart); }); if (!hasCollision) { scheduledDate = attemptTime; break; } attemptTime.setMinutes(attemptTime.getMinutes() + 15); } if (scheduledDate) break; // Prepare next day currentDay.setDate(currentDay.getDate() + 1); currentDay.setHours(0, 0, 0, 0); // Start at midnight } 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' })} (${domain} time).` }; } else { return { success: false, error: "Could not find a free slot in the next 7 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: "createLabel", description: "Create a new label or get existing one if name matches.", parameters: { type: "object", properties: { name: { type: "string" }, color: { type: "string", description: "Hex color code (optional)" } }, required: ["name"] } } }, { 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 === "createLabel") { const labels = await this.storage.getLabels(user.id); const existing = labels.find(l => l.name.toLowerCase() === args.name.toLowerCase()); if (existing) { result = { success: true, labelId: existing.id, message: "Label already exists." }; } else { const newLabel = await this.storage.createLabel({ name: args.name, color: args.color || "#6366f1", // Default Indigo // userId: user.id // Removed as it's not in InsertLabelSchema ?? Wait, check schema. // Actually, let's check schema. If schema demands it, I must provide it. // But lint said: 'userId' does not exist in type '{ name: string; color: string; creatorId?: string | null | undefined; }'. // So it's probably 'creatorId' or implied by context? // Start with assuming it is creatorId based on error message hint. creatorId: user.id }); result = { success: true, labelId: newLabel.id, message: "Label created." }; } } else 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; } } async analyzeTaskCompletion(task: InsertTask, context: string): Promise<{ needed: boolean; title?: string; description?: string }> { 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 { needed: false }; const prompt = `Analyze this COMPLETED task and determine if a follow-up task is logically required. Task Title: ${task.title} Task Description: ${task.description || "None"} Context: ${context} Examples: - "Send contract to client" -> Follow-up: "Check if client signed contract" - "Buy groceries" -> No follow-up usually. - "Email Sarah about project" -> Follow-up: "Follow up with Sarah if no reply" Return ONLY a JSON object. Do not include markdown formatting. Format: { "needed": boolean, "title": "string (optional, required if needed is true)", "description": "string (optional)" }`; const messages: ChatMessage[] = [ { role: "system", content: "You are a helpful task management assistant. Output valid JSON only." }, { role: "user", content: prompt } ]; try { let responseText = ""; if (provider === "openai" || provider === "ollama") { responseText = await this.chatOpenAI(provider, apiKey || "", model, baseUrl, messages, undefined, []); } else if (provider === "anthropic") { responseText = await this.chatAnthropic(apiKey || "", model, messages); } else if (provider === "google") { responseText = await this.chatGemini(apiKey || "", model, messages); } // Clean response (sometimes LMs add backticks) const cleanJson = responseText.replace(/```json/g, '').replace(/```/g, '').trim(); return JSON.parse(cleanJson); } catch (error) { console.error("AI Analysis Error:", error); return { needed: false }; } } 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."; } }