f165ae52e6
continuous-integration/drone/push Build is passing
Mobile Responsiveness: - Fix headers on all Focus Tools pages (responsive text sizes, proper spacing) - Fix BodyDoublingLobby form grid (stacks on mobile) - Add shrink-0 and min-w-0 to prevent flex overflow issues AI Chat Improvements: - Add labelName parameter to createTask tool for easier label assignment - Auto-match or create labels when labelName is provided - Improve system prompt to act immediately without confirmation - Clearer instructions about using tools and handling labels - Better support for Ollama models with function calling
922 lines
44 KiB
TypeScript
922 lines
44 KiB
TypeScript
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<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") {
|
|
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.
|
|
|
|
**CRITICAL: ALWAYS USE TOOLS IMMEDIATELY**
|
|
- When the user asks to create a task, IMMEDIATELY call createTask - DO NOT ask for confirmation.
|
|
- When the user provides multiple tasks, call createTask for EACH task in parallel.
|
|
- Never say "I will create..." or "Should I create..." - just DO IT.
|
|
|
|
**1. Task Management**
|
|
- **Create**: Use 'createTask'. Title is required.
|
|
- *Labels*: Use 'labelName' parameter with the label text (e.g., "Work", "Personal", "Shopping"). The system will automatically match or create the label.
|
|
- *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'.
|
|
- *Priority*: Infer priority from context. Urgent/important = high, regular = medium, minor = low.
|
|
- *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.
|
|
|
|
### 🏷️ LABEL RULES
|
|
- When the user mentions a category like "Work task", "Personal errand", "Shopping item" - use the labelName parameter.
|
|
- Common labels: Work, Personal, Shopping, Health, Finance, Home, Family, Study
|
|
- The system automatically matches existing labels or creates new ones.
|
|
|
|
### 🛡️ SAFETY RULES
|
|
- **DELETION CONFIRMATION**: Before using 'deleteTask', you MUST:
|
|
1. LIST the tasks you are about to delete (Title + ID).
|
|
2. ASK the user for explicit confirmation ("Are you sure you want to delete these X tasks?").
|
|
3. WAIT for the user to say "Yes".
|
|
- ONLY then proceed to valid 'deleteTask' calls.
|
|
|
|
**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.
|
|
- **"Next week"**: Add 7 days 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: ACT IMMEDIATELY. Do not ask for confirmation before creating tasks.`;
|
|
}
|
|
|
|
// 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<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 "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. ALWAYS call this tool immediately when the user asks to create a task - do not ask for confirmation first.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string", description: "The title of the task (required)." },
|
|
description: { type: "string", description: "Optional task description." },
|
|
priority: { type: "string", enum: ["low", "medium", "high"], description: "Task priority. Default: medium." },
|
|
status: { type: "string", enum: ["todo", "inProgress", "done"], description: "Task status. Default: todo." },
|
|
dueDate: { type: "string", description: "Due date in ISO 8601 format (YYYY-MM-DD). Calculate from relative dates like 'tomorrow', 'next week'." },
|
|
labelId: { type: "string", description: "The label ID (UUID). Use getLabels first to find the correct ID." },
|
|
labelName: { type: "string", description: "Alternative: Label name (e.g., 'Work', 'Personal'). Will be matched or created automatically." },
|
|
estimatedDuration: { type: "integer", description: "Estimated duration in minutes." },
|
|
parentTaskId: { type: "string", description: "Parent task ID for subtasks." },
|
|
startDate: { type: "string", description: "Start date in ISO 8601 format." }
|
|
},
|
|
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<string> {
|
|
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") {
|
|
// Handle labelName -> labelId conversion
|
|
let resolvedLabelId = args.labelId || null;
|
|
let labelMessage = "";
|
|
|
|
if (!resolvedLabelId && args.labelName) {
|
|
const labels = await this.storage.getLabels(user.id);
|
|
const matchedLabel = labels.find(l =>
|
|
l.name.toLowerCase() === args.labelName.toLowerCase() ||
|
|
l.name.toLowerCase().includes(args.labelName.toLowerCase()) ||
|
|
args.labelName.toLowerCase().includes(l.name.toLowerCase())
|
|
);
|
|
|
|
if (matchedLabel) {
|
|
resolvedLabelId = matchedLabel.id;
|
|
labelMessage = ` with label "${matchedLabel.name}"`;
|
|
} else {
|
|
// Create new label if not found
|
|
const newLabel = await this.storage.createLabel({
|
|
name: args.labelName,
|
|
color: "#6366f1",
|
|
creatorId: user.id
|
|
});
|
|
resolvedLabelId = newLabel.id;
|
|
labelMessage = ` with new label "${args.labelName}"`;
|
|
}
|
|
}
|
|
|
|
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: resolvedLabelId,
|
|
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, labelId: resolvedLabelId },
|
|
source: "AI"
|
|
});
|
|
result = { success: true, taskId: createdTask.id, message: `Task created${labelMessage}.` };
|
|
} 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 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Break down a large task into smaller, manageable subtasks for ADHD users.
|
|
*/
|
|
async breakdownTask(task: { title: string; description?: string; estimatedDuration?: number }): Promise<{
|
|
subtasks: { title: string; estimatedMinutes: number; order: number }[];
|
|
totalEstimatedMinutes: number;
|
|
encouragement: 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");
|
|
|
|
// Default fallback if AI is not configured
|
|
if (!apiKey && provider !== "ollama") {
|
|
return {
|
|
subtasks: [
|
|
{ title: `Start: ${task.title}`, estimatedMinutes: 5, order: 1 },
|
|
{ title: `Continue: ${task.title}`, estimatedMinutes: 10, order: 2 },
|
|
{ title: `Finish: ${task.title}`, estimatedMinutes: 5, order: 3 },
|
|
],
|
|
totalEstimatedMinutes: 20,
|
|
encouragement: "Du schaffst das! Nimm dir einen Schritt nach dem anderen vor.",
|
|
};
|
|
}
|
|
|
|
const prompt = `Du bist ein ADHS-Coach. Der Nutzer hat eine überwältigende Aufgabe, die in kleinere Schritte zerlegt werden muss.
|
|
|
|
Aufgabe: "${task.title}"
|
|
${task.description ? `Beschreibung: "${task.description}"` : ''}
|
|
${task.estimatedDuration ? `Geschätzte Dauer: ${task.estimatedDuration} Minuten` : ''}
|
|
|
|
Zerlege diese Aufgabe in 3-7 kleine, konkrete Schritte.
|
|
Jeder Schritt sollte:
|
|
- In 5-15 Minuten erledigt werden können
|
|
- Eine klare, aktionsorientierte Beschreibung haben (auf Deutsch)
|
|
- Einen konkreten Endpunkt haben
|
|
|
|
Antworte NUR mit einem JSON-Objekt. Keine Markdown-Formatierung.
|
|
Format:
|
|
{
|
|
"subtasks": [
|
|
{ "title": "Schritt beschreibung", "estimatedMinutes": 5, "order": 1 },
|
|
...
|
|
],
|
|
"totalEstimatedMinutes": number,
|
|
"encouragement": "Eine ermutigende Nachricht auf Deutsch"
|
|
}`;
|
|
|
|
const messages: ChatMessage[] = [
|
|
{ role: "system", content: "Du bist ein hilfreicher ADHS-Coach. Antworte nur mit validem JSON." },
|
|
{ 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
|
|
const cleanJson = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
|
|
return JSON.parse(cleanJson);
|
|
} catch (error) {
|
|
console.error("AI Breakdown Error:", error);
|
|
// Fallback
|
|
return {
|
|
subtasks: [
|
|
{ title: `Schritt 1: ${task.title} vorbereiten`, estimatedMinutes: 5, order: 1 },
|
|
{ title: `Schritt 2: ${task.title} durchführen`, estimatedMinutes: 10, order: 2 },
|
|
{ title: `Schritt 3: ${task.title} abschließen`, estimatedMinutes: 5, order: 3 },
|
|
],
|
|
totalEstimatedMinutes: 20,
|
|
encouragement: "Jeder kleine Schritt zählt! Du machst das großartig.",
|
|
};
|
|
}
|
|
}
|
|
|
|
private async chatAnthropic(apiKey: string, model: string, messages: any[]): Promise<string> {
|
|
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<string> {
|
|
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.";
|
|
}
|
|
}
|