feat: Complete AI Chat Agent, Admin Settings (MCP/AI), and Translations
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-15 21:52:36 +01:00
parent 71d5c825c5
commit 9736864425
24 changed files with 1679 additions and 401 deletions
+139 -15
View File
@@ -6,6 +6,28 @@ interface ChatMessage {
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) { }
@@ -19,11 +41,19 @@ export class AiService {
throw new Error("AI API Key not configured");
}
const systemPrompt = `You are TaskFlow AI, an intelligent assistant for the TaskFlow application.
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}
User Name: \${user.username}
Current Date/Time: \${currentDate.toLocaleString('de-DE')} (Day: \${currentDate.toLocaleDateString('en-US', { weekday: 'long' })})
Current Context:
${context}
\${context}
Answer the user's questions based on this context. Be concise, helpful, and friendly.
@@ -32,15 +62,18 @@ 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.
- *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"**: 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.
- **"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'.
@@ -48,12 +81,21 @@ You can create, search, update, and delete tasks using the provided tools.
### 📅 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").
- **"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.
`;
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 },
@@ -296,6 +338,21 @@ CRITICAL: After executing tools, provide a concise summary of your actions.
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: {
@@ -398,7 +455,25 @@ CRITICAL: After executing tools, provide a concise summary of your actions.
try {
console.log(`[AI] Executing ${fnName}:`, args);
if (fnName === "createTask") {
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,
@@ -564,6 +639,55 @@ CRITICAL: After executing tools, provide a concise summary of your actions.
}
}
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<string> {
const systemMessage = messages.find(m => m.role === "system")?.content || "";
+48
View File
@@ -1,6 +1,7 @@
import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes.js";
import { initializeDatabase, closeDatabase } from "./db.js";
import { storage } from "./storage";
const app = express();
app.set("trust proxy", true);
@@ -77,6 +78,53 @@ app.use((req, res, next) => {
}
}
// Seed default AI System Prompt if missing
const currentPrompt = await storage.getSystemSettings("ai_system_prompt");
if (!currentPrompt) {
const defaultPrompt = `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.`;
await storage.setSystemSettings("ai_system_prompt", defaultPrompt);
console.log("Seeded default AI System Prompt");
}
const server = await registerRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
+167 -25
View File
@@ -5,11 +5,13 @@ import { insertLabelSchema, insertTaskSchema, insertNoteSchema, insertXpEventSch
import { z } from "zod";
import { EmailService } from "./email.js";
import { mcpServer } from "./mcp";
import { AiService } from "./ai.js";
import { AiService, DEFAULT_SYSTEM_PROMPT } from "./ai.js";
import { RecurrenceService } from "./services/recurrence.js";
import { GamificationService } from "./gamification.js";
const emailService = new EmailService(storage);
const aiService = new AiService(storage);
const recurrenceService = new RecurrenceService(storage);
const gamificationService = new GamificationService(storage);
import { setupAuth, hashPassword, comparePassword } from "./auth.js";
@@ -59,13 +61,25 @@ export async function registerRoutes(app: Express): Promise<Server> {
req.login(adminUser, (err) => {
if (err) return res.status(500).json({ error: "Login failed after setup" });
return res.json(adminUser);
req.session.save(() => {
return res.json(adminUser);
});
});
} catch (e) {
res.status(500).json({ error: "Failed to create admin user" });
}
});
// Check if AI is configured (for UI empty states)
app.get("/api/ai/status", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const key = await storage.getSystemSettings("ai_api_key");
const provider = await storage.getSystemSettings("ai_provider");
// Consider configured if we have a key OR if provider is 'ollama'
const isConfigured = (provider === 'ollama') || (!!key && key.length > 0);
res.json({ configured: isConfigured, provider });
});
// --- Password Reset Routes ---
app.post("/api/auth/forgot-password", async (req, res) => {
try {
@@ -256,16 +270,37 @@ export async function registerRoutes(app: Express): Promise<Server> {
settings[key] = val || ""; // Return empty string if undefined for inputs
}
}
// Inject System Prompt with Default Fallback
const storedPrompt = await storage.getSystemSettings("ai_system_prompt");
settings["ai_system_prompt"] = storedPrompt || DEFAULT_SYSTEM_PROMPT;
res.json(settings);
});
app.post("/api/admin/settings", isAdmin, async (req, res) => {
const updates = req.body;
const currentPrompt = await storage.getSystemSettings("ai_system_prompt");
// Check for System Prompt changes & Log
if (updates.ai_system_prompt && updates.ai_system_prompt !== currentPrompt) {
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "SYSTEM_SETTINGS",
entityId: "ai_system_prompt",
details: { message: "AI System Prompt updated" },
source: "ADMIN"
});
}
const keys = [
"registration_enabled",
"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure",
"ai_provider", "ai_api_key", "ai_model", "ai_base_url",
"ai_provider", "ai_api_key", "ai_model", "ai_base_url", "ai_system_prompt",
"mcp_enabled", "mcp_port"
];
for (const key of keys) {
if (req.body[key] !== undefined) {
await storage.setSystemSettings(key, String(req.body[key]));
@@ -508,28 +543,36 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (conv.userId !== user.id) return res.sendStatus(403);
// Store User Message
await storage.addMessage({
const userMessage = await storage.addMessage({
conversationId,
role: 'user',
content
});
// Fetch history for context
const dbMessages = await storage.getMessages(conversationId);
// Convert to format expected by AiService (role, content)
const history = dbMessages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }));
// Send immediate response to client (Session Aware: request completes)
res.json(userMessage);
// Build User Context (Tasks etc)
const tasks = await storage.getTasksForUser(user.id);
const activeTasks = tasks.filter(t => t.status !== 'done');
const completedTasks = tasks.filter(t => t.status === 'done');
// Background Processing (Fire & Forget)
(async () => {
try {
// Fetch history for context
const dbMessages = await storage.getMessages(conversationId);
// Convert to format expected by AiService (role, content)
const history = dbMessages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }));
const context = `
// Build User Context (Tasks etc)
const tasks = await storage.getTasksForUser(user.id);
const activeTasks = tasks.filter(t => t.status !== 'done');
const completedTasks = tasks.filter(t => t.status === 'done');
const currentClientTime = clientTime || new Date().toLocaleString();
const context = `
User Context:
- User ID: ${user.id}
- Username: ${user.username}
- XP: ${user.xp} (Level ${user.level})
- Current Date/Time: ${clientTime || new Date().toLocaleString()}
- Current Date/Time: ${currentClientTime}
Task Summary:
- Total Active Tasks: ${activeTasks.length}
@@ -542,23 +585,54 @@ Recent Active Tasks:
${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
`;
// Call AI Service
const responseContent = await aiService.chat(history, user, context);
// Call AI Service
const responseContent = await aiService.chat(history, user, context);
// Store AI Response
const botMessage = await storage.addMessage({
conversationId,
role: 'assistant',
content: responseContent
});
// Store AI Response
await storage.addMessage({
conversationId,
role: 'assistant',
content: responseContent
});
} catch (bgError) {
console.error("Background AI Processing Error:", bgError);
// Optional: Add a system message saying it failed?
}
})();
res.json(botMessage);
} catch (e: any) {
console.error("AI Route Error:", e);
res.status(500).json({ error: e.message || "Failed to generate AI response" });
}
});
// Analyze completed task for follow-up
app.post("/api/ai/analyze-completion", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
if (!user.aiEnabled) return res.json({ needed: false }); // Silently fail/return false
try {
const { taskId } = req.body;
const task = await storage.getTask(taskId);
if (!task) return res.status(404).json({ error: "Task not found" });
// Build context
const context = `
User Context:
- User ID: ${user.id}
- Username: ${user.username}
- Current Date/Time: ${new Date().toLocaleString()}
`;
const analysis = await aiService.analyzeTaskCompletion(task, context);
res.json(analysis);
} catch (e: any) {
console.error("Analysis Error:", e);
res.status(500).json({ error: "Failed to analyze task" });
}
});
// Edit message and regenerate (Regenerate Response)
app.put("/api/ai/chat/:messageId", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
@@ -1402,12 +1476,80 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
app.patch("/api/goals/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const updated = await storage.updateGoal(req.params.id, req.body);
const updated = await storage.updateTask(req.params.id, req.body);
// Check for Recurrence if task is marked done
if (updated && updated.status === 'done' && updated.isRecurring && req.body.status === 'done') {
// Fire and forget, or await? Await to ensure it happens.
console.log(`[Recurrence] Checking recurrence for task ${updated.id}`);
try {
const nextTask = await recurrenceService.handleTaskCompletion(updated);
if (nextTask) {
console.log(`[Recurrence] Created next task: ${nextTask.id} (${nextTask.title})`);
}
} catch (err) {
console.error("[Recurrence] Error creating next task:", err);
}
}
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update goal" });
res.status(500).json({ error: "Failed to update task" });
}
});
// Export User Data
app.post("/api/user/export", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const user = req.user as User;
const { includeTasks, includeLabels, includeSettings } = req.body;
const exportData: any = {
exportedAt: new Date(),
user: { username: user.username, email: user.email }
};
if (includeTasks) {
exportData.tasks = await storage.getTasksForUser(user.id);
}
if (includeLabels) {
exportData.labels = await storage.getLabels(user.id);
}
if (includeSettings && user.role === 'admin') {
// Only admins can request system settings dump, though arguably they might mean "User Settings"
// The prompt said "system settings". I will include system settings if they are admin.
// But maybe they meant user preferences? The prompt specifically said "system settings".
const allSettings: Record<string, string> = {};
const keys = [
"registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_from",
"ai_provider", "ai_model", "ai_base_url", "ai_system_prompt", "mcp_enabled"
];
for (const key of keys) {
const val = await storage.getSystemSettings(key);
if (val) allSettings[key] = val;
}
exportData.systemSettings = allSettings;
}
await storage.createAuditLog({
userId: user.id,
action: "EXPORT_DATA",
entityType: "USER_DATA",
entityId: user.id.toString(),
source: "USER",
details: { includeTasks, includeLabels, includeSettings }
});
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="taskflow_export_${user.username}.json"`);
res.json(exportData);
} catch (e) {
console.error("Export Failed:", e);
res.status(500).json({ error: "Failed to export data" });
}
}); // End of routes
return httpServer;
}
+112
View File
@@ -0,0 +1,112 @@
import { IStorage } from "../storage";
import { Task, InsertTask } from "@shared/schema";
import { addDays, addWeeks, addMonths, addYears } from "date-fns";
export class RecurrenceService {
private storage: IStorage;
constructor(storage: IStorage) {
this.storage = storage;
}
async handleTaskCompletion(task: Task): Promise<Task | undefined> {
if (!task.isRecurring || !task.recurrenceInterval) {
return;
}
// Determine the next due date
const nextDate = this.calculateNextDate(task);
if (!nextDate) return;
// Check if we passed the end date
if (task.recurrenceEnd && nextDate > task.recurrenceEnd) {
return;
}
// Create the next task
// Note: startDate is nullable in schema but required in InsertTask type if strict.
// We cast to any to avoid strict type issues with recent schema changes if types aren't perfectly synced.
const newTask: any = {
title: task.title,
description: task.description,
priority: task.priority,
status: "todo",
dueDate: nextDate,
estimatedDuration: task.estimatedDuration,
labelId: task.labelId,
projectId: task.projectId,
energyLevel: task.energyLevel,
userId: task.userId,
// Copy recurrence settings
isRecurring: true,
recurrenceInterval: task.recurrenceInterval,
recurrenceIntervalValue: task.recurrenceIntervalValue,
recurrenceDays: task.recurrenceDays,
recurrenceEnd: task.recurrenceEnd,
startDate: null,
};
const created = await this.storage.createTask(newTask);
// Log it
await this.storage.createAuditLog({
userId: task.userId!,
action: "CREATE",
entityType: "TASK",
entityId: created.id,
details: { message: `Recurring task created from ${task.id}`, recurrence: task.recurrenceInterval },
source: "SYSTEM"
});
return created;
}
private calculateNextDate(task: Task): Date | null {
// Base calculation on the original due date or today if missing (though recurring tasks should have due dates)
const baseDate = task.dueDate ? new Date(task.dueDate) : new Date();
const intervalValue = task.recurrenceIntervalValue || 1;
let nextDate: Date = new Date(); // Default init to satisfy typescript, overwritten below
switch (task.recurrenceInterval) {
case 'daily':
nextDate = addDays(baseDate, intervalValue);
break;
case 'weekly':
if (task.recurrenceDays && task.recurrenceDays.length > 0) {
// Complex logic for specific days (e.g., Mon, Wed)
// Simplified approach for MVP: Outlook style often just means "same day next week" if no days specified.
// If days ARE specified, find the next matching day.
let potentialDate = addDays(baseDate, 1);
// Search for next 14 days maximum to avoid infinite loops
let found = false;
for (let i = 0; i < 14; i++) {
const dayOfWeek = potentialDate.getDay(); // 0=Sun, 1=Mon
if (task.recurrenceDays.includes(dayOfWeek)) {
nextDate = potentialDate;
found = true;
break;
}
potentialDate = addDays(potentialDate, 1);
}
if (!found) nextDate = addWeeks(baseDate, intervalValue); // Fallback
} else {
nextDate = addWeeks(baseDate, intervalValue);
}
break;
case 'monthly':
nextDate = addMonths(baseDate, intervalValue);
break;
case 'yearly':
nextDate = addYears(baseDate, intervalValue);
break;
default:
return null;
}
return nextDate;
}
}